From 454fb0bdb9a7a6cce4f86e4bbba957ea08cf4610 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 23:27:50 +0300 Subject: [PATCH] Add Phase 1D API client implementation plan 7 tasks: TDD error classes, circuit breaker, three cache types (request-scoped, client TTL, server byte-capped LRU via lru-cache), ApiClient with retry+timeout, CachedApiClient decorator, provider. --- .../plans/2026-04-14-phase-1d-api-client.md | 1359 +++++++++++++++++ 1 file changed, 1359 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-phase-1d-api-client.md diff --git a/docs/superpowers/plans/2026-04-14-phase-1d-api-client.md b/docs/superpowers/plans/2026-04-14-phase-1d-api-client.md new file mode 100644 index 00000000..965e9266 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1d-api-client.md @@ -0,0 +1,1359 @@ +# Phase 1D — API Client + Caches + Circuit Breaker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the REST API client layer — typed error classes, three cache implementations (request-scoped SSR dedup, client-side TTL, server-side byte-capped LRU), a circuit breaker, a retry-capable `ApiClient`, a caching decorator `CachedApiClient`, and a React context provider — so that 1F-layout's smoke route and Phase 2 features can fetch data from the customer's REST API with dedup, caching, retry, and fault isolation. + +**Architecture:** `src/shared/api/` is a self-contained module with six files + one provider. The `ApiClient` class is the raw HTTP layer (retry + timeout). `CachedApiClient` wraps it as a decorator and consults the three caches in priority order. The circuit breaker wraps the entire outbound call. Feature code uses `useApiClient()` from the provider, which returns a `CachedApiClient` in production or a raw `ApiClient` in tests. + +**Tech Stack:** `lru-cache@^10` (server LRU with byte cap), `undici` (pinned for deterministic server-side HTTP). No runtime deps beyond those — the caches and circuit breaker are hand-rolled (~50-80 lines each) per YAGNI. + +**Scope boundaries:** +- No real API endpoints called — tests mock `fetch`. +- No `undici.RetryAgent` integration in this plan — the server-side retry path uses the same hand-rolled retry wrapper as the client path for simplicity. `undici.RetryAgent` can be swapped in later if benchmarks show a need. (The master plan says "uses `undici.RetryAgent` under the hood" — this is acceptable deviation because the behavioral contract is identical: retry on configured status codes with exponential backoff. The retry logic is tested regardless of the underlying transport.) +- No React rendering tests for the provider — 1F-layout exercises it. + +**Prerequisites:** 1A-1 (types), 1A-2, 1A-3 (ESLint boundaries), 1C (Language type from resolver). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/shared/api/errors.ts` | Typed error classes | 1 | +| `src/shared/api/errors.test.ts` | Error class tests | 1 | +| `src/shared/api/circuit-breaker.ts` | Circuit breaker state machine | 2 | +| `src/shared/api/circuit-breaker.test.ts` | State transition tests | 2 | +| `src/shared/api/cache.ts` | Three cache types + `cacheKey()` | 3 | +| `src/shared/api/cache.test.ts` | Cache behavior tests | 3 | +| `src/shared/api/client.ts` | `ApiClient` with retry + timeout | 4 | +| `src/shared/api/client.test.ts` | Retry, timeout, error mapping tests | 4 | +| `src/shared/api/cached-client.ts` | `CachedApiClient` decorator | 5 | +| `src/shared/api/cached-client.test.ts` | Cache integration tests | 5 | +| `src/shared/api/provider.tsx` | React context + `useApiClient()` | 6 | + +--- + +## Task 1 — TDD error classes + +**Files:** +- Create: `src/shared/api/errors.ts` +- Create: `src/shared/api/errors.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/shared/api/errors.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { ApiError, ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js"; + +describe("ApiError", () => { + it("extends Error with a message", () => { + const err = new ApiError("something broke"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe("something broke"); + expect(err.name).toBe("ApiError"); + }); +}); + +describe("ApiHttpError", () => { + it("has status and optional body", () => { + const err = new ApiHttpError("Not Found", 404, { detail: "missing" }); + expect(err).toBeInstanceOf(ApiError); + expect(err).toBeInstanceOf(ApiHttpError); + expect(err.status).toBe(404); + expect(err.body).toEqual({ detail: "missing" }); + expect(err.name).toBe("ApiHttpError"); + }); + + it("body defaults to undefined", () => { + const err = new ApiHttpError("Server Error", 500); + expect(err.status).toBe(500); + expect(err.body).toBeUndefined(); + }); +}); + +describe("ApiTimeoutError", () => { + it("has timeoutMs", () => { + const err = new ApiTimeoutError(5000); + expect(err).toBeInstanceOf(ApiError); + expect(err.timeoutMs).toBe(5000); + expect(err.message).toContain("5000"); + expect(err.name).toBe("ApiTimeoutError"); + }); +}); + +describe("ApiNetworkError", () => { + it("wraps a cause", () => { + const cause = new Error("ECONNREFUSED"); + const err = new ApiNetworkError(cause); + expect(err).toBeInstanceOf(ApiError); + expect(err.cause).toBe(cause); + expect(err.name).toBe("ApiNetworkError"); + }); + + it("works without a cause", () => { + const err = new ApiNetworkError(); + expect(err.cause).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/api/errors +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/api/errors.ts`: + +```typescript +export class ApiError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiError"; + } +} + +export class ApiHttpError extends ApiError { + readonly status: number; + readonly body?: unknown; + + constructor(message: string, status: number, body?: unknown) { + super(message); + this.name = "ApiHttpError"; + this.status = status; + this.body = body; + } +} + +export class ApiTimeoutError extends ApiError { + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms`); + this.name = "ApiTimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +export class ApiNetworkError extends ApiError { + override readonly cause?: Error; + + constructor(cause?: Error) { + super(cause ? `Network error: ${cause.message}` : "Network error"); + this.name = "ApiNetworkError"; + this.cause = cause; + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/shared/api/errors +``` + +- [ ] **Step 5: Typecheck + lint, then commit** + +```bash +pnpm typecheck && pnpm lint +git add src/shared/api/errors.ts src/shared/api/errors.test.ts +git commit -m "Add typed API error classes (ApiHttpError, ApiTimeoutError, ApiNetworkError)" +``` + +--- + +## Task 2 — TDD circuit breaker + +**Files:** +- Create: `src/shared/api/circuit-breaker.ts` +- Create: `src/shared/api/circuit-breaker.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/shared/api/circuit-breaker.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { CircuitBreaker } from "./circuit-breaker.js"; + +describe("CircuitBreaker", () => { + let cb: CircuitBreaker; + + beforeEach(() => { + cb = new CircuitBreaker({ failureThreshold: 3, openDurationMs: 1000 }); + }); + + it("starts in closed state", () => { + expect(cb.state).toBe("closed"); + }); + + it("passes through successful calls in closed state", async () => { + const result = await cb.exec(() => Promise.resolve("ok")); + expect(result).toBe("ok"); + expect(cb.state).toBe("closed"); + }); + + it("transitions to open after failureThreshold consecutive failures", async () => { + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow("fail"); + } + expect(cb.state).toBe("open"); + }); + + it("rejects immediately in open state without calling the function", async () => { + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow(); + } + + const fn = vi.fn(() => Promise.resolve("should not run")); + await expect(cb.exec(fn)).rejects.toThrow(/circuit.*open/i); + expect(fn).not.toHaveBeenCalled(); + }); + + it("transitions to half-open after openDurationMs elapses", async () => { + vi.useFakeTimers(); + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow(); + } + expect(cb.state).toBe("open"); + + vi.advanceTimersByTime(1001); + expect(cb.state).toBe("half-open"); + vi.useRealTimers(); + }); + + it("transitions from half-open to closed on success", async () => { + vi.useFakeTimers(); + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow(); + } + vi.advanceTimersByTime(1001); + expect(cb.state).toBe("half-open"); + + const result = await cb.exec(() => Promise.resolve("recovered")); + expect(result).toBe("recovered"); + expect(cb.state).toBe("closed"); + vi.useRealTimers(); + }); + + it("transitions from half-open to open on failure", async () => { + vi.useFakeTimers(); + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow(); + } + vi.advanceTimersByTime(1001); + expect(cb.state).toBe("half-open"); + + await expect(cb.exec(fail)).rejects.toThrow("fail"); + expect(cb.state).toBe("open"); + vi.useRealTimers(); + }); + + it("resets failure count on a successful call in closed state", async () => { + const fail = () => Promise.reject(new Error("fail")); + await expect(cb.exec(fail)).rejects.toThrow(); + await expect(cb.exec(fail)).rejects.toThrow(); + // 2 failures, threshold is 3 + await cb.exec(() => Promise.resolve("ok")); + // count should have reset, so 2 more failures should not open + await expect(cb.exec(fail)).rejects.toThrow(); + await expect(cb.exec(fail)).rejects.toThrow(); + expect(cb.state).toBe("closed"); + }); + + it("reset() returns to closed state", async () => { + const fail = () => Promise.reject(new Error("fail")); + for (let i = 0; i < 3; i++) { + await expect(cb.exec(fail)).rejects.toThrow(); + } + expect(cb.state).toBe("open"); + cb.reset(); + expect(cb.state).toBe("closed"); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/api/circuit-breaker +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/api/circuit-breaker.ts`: + +```typescript +export interface CircuitBreakerOptions { + failureThreshold?: number; + openDurationMs?: number; +} + +type State = "closed" | "open" | "half-open"; + +export class CircuitBreaker { + private readonly failureThreshold: number; + private readonly openDurationMs: number; + private failures = 0; + private lastFailureTime = 0; + private _state: State = "closed"; + + constructor(options?: CircuitBreakerOptions) { + this.failureThreshold = options?.failureThreshold ?? 5; + this.openDurationMs = options?.openDurationMs ?? 30_000; + } + + get state(): State { + if (this._state === "open") { + const elapsed = Date.now() - this.lastFailureTime; + if (elapsed >= this.openDurationMs) { + this._state = "half-open"; + } + } + return this._state; + } + + async exec(fn: () => Promise): Promise { + const currentState = this.state; + + if (currentState === "open") { + throw new Error("Circuit breaker is open — request rejected"); + } + + try { + const result = await fn(); + this.onSuccess(); + return result; + } catch (err) { + this.onFailure(); + throw err; + } + } + + reset(): void { + this._state = "closed"; + this.failures = 0; + this.lastFailureTime = 0; + } + + private onSuccess(): void { + this._state = "closed"; + this.failures = 0; + } + + private onFailure(): void { + this.failures++; + this.lastFailureTime = Date.now(); + if (this._state === "half-open" || this.failures >= this.failureThreshold) { + this._state = "open"; + } + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/shared/api/circuit-breaker +``` + +- [ ] **Step 5: Typecheck + lint, then commit** + +```bash +pnpm typecheck && pnpm lint +git add src/shared/api/circuit-breaker.ts src/shared/api/circuit-breaker.test.ts +git commit -m "Add circuit breaker with closed/open/half-open state machine" +``` + +--- + +## Task 3 — TDD three cache types + `cacheKey()` + +**Files:** +- Create: `src/shared/api/cache.ts` +- Create: `src/shared/api/cache.test.ts` + +- [ ] **Step 1: Install `lru-cache`** + +```bash +pnpm add lru-cache@^10.0.0 +``` + +- [ ] **Step 2: Write failing tests** + +Create `src/shared/api/cache.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + RequestScopedCache, + ClientMemoryCache, + ServerLruCache, + cacheKey, +} from "./cache.js"; + +describe("cacheKey", () => { + it("creates a deterministic key from endpoint, query, and locale", () => { + const k1 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru"); + const k2 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru"); + expect(k1).toBe(k2); + }); + + it("produces different keys for different locales", () => { + const k1 = cacheKey("/flights", { date: "2025-01-15" }, "ru"); + const k2 = cacheKey("/flights", { date: "2025-01-15" }, "en"); + expect(k1).not.toBe(k2); + }); + + it("produces different keys for different queries", () => { + const k1 = cacheKey("/flights", { a: "1" }, "ru"); + const k2 = cacheKey("/flights", { b: "2" }, "ru"); + expect(k1).not.toBe(k2); + }); + + it("sorts query keys for stability", () => { + const k1 = cacheKey("/flights", { b: "2", a: "1" }, "ru"); + const k2 = cacheKey("/flights", { a: "1", b: "2" }, "ru"); + expect(k1).toBe(k2); + }); +}); + +describe("RequestScopedCache", () => { + it("deduplicates concurrent promises for the same key", async () => { + const cache = new RequestScopedCache(); + let callCount = 0; + const factory = () => { + callCount++; + return Promise.resolve("value"); + }; + + const p1 = cache.get("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })(); + const p2 = cache.get("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })(); + + expect(await p1).toBe("value"); + expect(await p2).toBe("value"); + expect(callCount).toBe(1); + }); + + it("returns undefined for unknown keys", () => { + const cache = new RequestScopedCache(); + expect(cache.get("missing")).toBeUndefined(); + }); +}); + +describe("ClientMemoryCache", () => { + it("stores and retrieves values", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + }); + + it("evicts after TTL expires", () => { + vi.useFakeTimers(); + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 100 }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + + vi.advanceTimersByTime(101); + expect(cache.get("k")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("supports per-entry TTL override", () => { + vi.useFakeTimers(); + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("k", "v", 50); + vi.advanceTimersByTime(51); + expect(cache.get("k")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("evicts LRU when max count reached", () => { + const cache = new ClientMemoryCache({ max: 2, defaultTtlMs: 60000 }); + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBe("2"); + expect(cache.get("c")).toBe("3"); + }); + + it("tracks size", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + expect(cache.size).toBe(0); + cache.set("k", "v"); + expect(cache.size).toBe(1); + cache.delete("k"); + expect(cache.size).toBe(0); + }); + + it("clear() removes all entries", () => { + const cache = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + cache.set("a", "1"); + cache.set("b", "2"); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +describe("ServerLruCache", () => { + it("stores and retrieves values", () => { + const cache = new ServerLruCache({ + maxBytes: 1024, + defaultTtlMs: 5000, + }); + cache.set("k", "hello"); + expect(cache.get("k")).toBe("hello"); + }); + + it("evicts when byte cap is exceeded", () => { + const cache = new ServerLruCache({ + maxBytes: 50, + defaultTtlMs: 60000, + }); + // Each string is ~5-10 bytes when JSON.stringify'd, but the key is also counted + cache.set("a", "x".repeat(20)); + cache.set("b", "y".repeat(20)); + cache.set("c", "z".repeat(20)); + // At least one of the earlier entries should be evicted + const remaining = [cache.get("a"), cache.get("b"), cache.get("c")].filter(Boolean); + expect(remaining.length).toBeLessThan(3); + }); + + it("tracks calculatedSize", () => { + const cache = new ServerLruCache({ + maxBytes: 10000, + defaultTtlMs: 5000, + }); + expect(cache.calculatedSize).toBe(0); + cache.set("k", "hello"); + expect(cache.calculatedSize).toBeGreaterThan(0); + }); + + it("evicts after TTL expires", () => { + vi.useFakeTimers(); + const cache = new ServerLruCache({ + maxBytes: 10000, + defaultTtlMs: 100, + }); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + vi.advanceTimersByTime(101); + expect(cache.get("k")).toBeUndefined(); + vi.useRealTimers(); + }); + + it("supports custom sizeCalculation", () => { + const cache = new ServerLruCache<{ data: string }>({ + maxBytes: 100, + defaultTtlMs: 5000, + sizeCalculation: (value) => value.data.length, + }); + cache.set("k", { data: "x".repeat(50) }); + expect(cache.calculatedSize).toBe(50); + }); +}); +``` + +- [ ] **Step 3: Run — MUST FAIL** + +```bash +pnpm test src/shared/api/cache +``` + +- [ ] **Step 4: Write implementation** + +Create `src/shared/api/cache.ts`: + +```typescript +import { LRUCache } from "lru-cache"; +import type { Language } from "@/i18n/resolver"; + +// --- Cache key convention --- + +export function cacheKey( + endpoint: string, + query: Record, + locale: Language, +): string { + const sortedQuery = Object.keys(query) + .sort() + .map((k) => `${k}=${String(query[k])}`) + .join("&"); + return `${locale}:${endpoint}?${sortedQuery}`; +} + +// --- (1) Request-scoped SSR dedup cache --- + +export class RequestScopedCache { + private readonly store = new Map>(); + + get(key: string): Promise | undefined { + return this.store.get(key) as Promise | undefined; + } + + set(key: string, promise: Promise): void { + this.store.set(key, promise); + } +} + +// --- (2) Client-side per-tab in-memory TTL cache --- + +export class ClientMemoryCache { + private readonly cache: LRUCache; + + constructor(options: { max: number; defaultTtlMs: number }) { + this.cache = new LRUCache({ + max: options.max, + ttl: options.defaultTtlMs, + }); + } + + get(key: string): T | undefined { + return this.cache.get(key); + } + + set(key: string, value: T, ttlMs?: number): void { + this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined); + } + + delete(key: string): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } +} + +// --- (3) Shared per-VM LRU with byte cap --- + +export class ServerLruCache { + private readonly cache: LRUCache; + + constructor(options: { + maxBytes: number; + defaultTtlMs: number; + sizeCalculation?: (value: T, key: string) => number; + }) { + this.cache = new LRUCache({ + maxSize: options.maxBytes, + ttl: options.defaultTtlMs, + sizeCalculation: + options.sizeCalculation ?? + ((value: T) => { + try { + return JSON.stringify(value).length; + } catch { + return 1024; // fallback for non-serializable values + } + }), + }); + } + + get(key: string): T | undefined { + return this.cache.get(key); + } + + set(key: string, value: T, ttlMs?: number): void { + this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined); + } + + delete(key: string): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + get calculatedSize(): number { + return this.cache.calculatedSize; + } +} +``` + +- [ ] **Step 5: Run — ALL MUST PASS** + +```bash +pnpm test src/shared/api/cache +``` + +- [ ] **Step 6: Typecheck + lint, then commit** + +```bash +pnpm typecheck && pnpm lint +git add package.json pnpm-lock.yaml src/shared/api/cache.ts src/shared/api/cache.test.ts +git commit -m "Add three cache types (request-scoped, client TTL, server byte-capped LRU)" +``` + +--- + +## Task 4 — TDD `ApiClient` with retry + timeout + +**Files:** +- Create: `src/shared/api/client.ts` +- Create: `src/shared/api/client.test.ts` + +This is the most complex file. The test uses a mock fetch implementation. + +- [ ] **Step 1: Write failing tests** + +Create `src/shared/api/client.test.ts`: + +```typescript +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ApiClient } from "./client.js"; +import { ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js"; + +function mockFetch(responses: Array<{ status: number; body?: unknown; headers?: Record; delay?: number }>): typeof fetch { + let callIndex = 0; + return vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => { + const resp = responses[callIndex++]; + if (!resp) throw new Error("No more mock responses"); + if (resp.delay) await new Promise((r) => setTimeout(r, resp.delay)); + return new Response( + resp.body !== undefined ? JSON.stringify(resp.body) : null, + { + status: resp.status, + headers: new Headers(resp.headers ?? {}), + }, + ); + }) as unknown as typeof fetch; +} + +describe("ApiClient", () => { + it("deserializes a successful JSON response", async () => { + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: mockFetch([{ status: 200, body: { flights: [] } }]), + }); + const data = await client.get<{ flights: unknown[] }>("/flights"); + expect(data).toEqual({ flights: [] }); + }); + + it("appends query params and locale header", async () => { + const fetchSpy = mockFetch([{ status: 200, body: {} }]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "en", + fetchImpl: fetchSpy, + }); + await client.get("/flights", { date: "2025-01-15", dep: "SVO" }); + const calledUrl = (fetchSpy as ReturnType).mock.calls[0]?.[0] as string; + expect(calledUrl).toContain("/flights?"); + expect(calledUrl).toContain("date=2025-01-15"); + expect(calledUrl).toContain("dep=SVO"); + }); + + it("throws ApiHttpError on 404 without retrying", async () => { + const fetchSpy = mockFetch([{ status: 404, body: { error: "not found" } }]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: fetchSpy, + retry: { maxRetries: 3 }, + }); + await expect(client.get("/flights/999")).rejects.toThrow(ApiHttpError); + expect((fetchSpy as ReturnType).mock.calls).toHaveLength(1); + }); + + it("retries on 500 up to maxRetries then throws ApiHttpError", async () => { + const fetchSpy = mockFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + ]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: fetchSpy, + retry: { maxRetries: 3, timeoutFactor: 0 }, + }); + await expect(client.get("/flights")).rejects.toThrow(ApiHttpError); + // 1 initial + 3 retries = 4 total + expect((fetchSpy as ReturnType).mock.calls).toHaveLength(4); + }); + + it("retries on 429 and succeeds on retry", async () => { + const fetchSpy = mockFetch([ + { status: 429, headers: { "Retry-After": "0" } }, + { status: 200, body: { ok: true } }, + ]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: fetchSpy, + retry: { maxRetries: 3, timeoutFactor: 0 }, + }); + const data = await client.get<{ ok: boolean }>("/flights"); + expect(data.ok).toBe(true); + expect((fetchSpy as ReturnType).mock.calls).toHaveLength(2); + }); + + it("does not retry on 400", async () => { + const fetchSpy = mockFetch([{ status: 400, body: { error: "bad request" } }]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: fetchSpy, + retry: { maxRetries: 3 }, + }); + await expect(client.get("/flights")).rejects.toThrow(ApiHttpError); + expect((fetchSpy as ReturnType).mock.calls).toHaveLength(1); + }); + + it("throws ApiTimeoutError on timeout", async () => { + vi.useFakeTimers(); + const neverResolve = vi.fn(() => new Promise(() => {})); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: neverResolve as unknown as typeof fetch, + defaultTimeoutMs: 100, + retry: { maxRetries: 0 }, + }); + const promise = client.get("/flights"); + vi.advanceTimersByTime(101); + await expect(promise).rejects.toThrow(ApiTimeoutError); + vi.useRealTimers(); + }); + + it("post() sends JSON body", async () => { + const fetchSpy = mockFetch([{ status: 201, body: { id: 1 } }]); + const client = new ApiClient({ + baseUrl: "https://api.example", + locale: "ru", + fetchImpl: fetchSpy, + }); + const data = await client.post<{ id: number }>("/bookings", { flight: "SU100" }); + expect(data.id).toBe(1); + const callInit = (fetchSpy as ReturnType).mock.calls[0]?.[1] as RequestInit; + expect(callInit.method).toBe("POST"); + expect(callInit.body).toBe(JSON.stringify({ flight: "SU100" })); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/api/client +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/api/client.ts`: + +```typescript +import type { Language } from "@/i18n/resolver"; +import type { Logger } from "@/observability/logger/types"; +import { ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js"; + +export interface ApiClientRetryOptions { + maxRetries?: number; + timeoutFactor?: number; + statusCodes?: number[]; +} + +export interface ApiClientOptions { + baseUrl: string; + locale: Language; + traceId?: string; + fetchImpl?: typeof fetch; + defaultTimeoutMs?: number; + retry?: ApiClientRetryOptions; + logger?: Logger; +} + +const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]; + +export class ApiClient { + private readonly baseUrl: string; + private readonly locale: Language; + private readonly traceId?: string; + private readonly fetchFn: typeof fetch; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly timeoutFactor: number; + private readonly retryStatusCodes: Set; + private readonly logger?: Logger; + + constructor(options: ApiClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.locale = options.locale; + this.traceId = options.traceId; + this.fetchFn = options.fetchImpl ?? globalThis.fetch; + this.timeoutMs = options.defaultTimeoutMs ?? 5000; + this.maxRetries = options.retry?.maxRetries ?? 3; + this.timeoutFactor = options.retry?.timeoutFactor ?? 2; + this.retryStatusCodes = new Set( + options.retry?.statusCodes ?? DEFAULT_RETRY_STATUS_CODES, + ); + this.logger = options.logger; + } + + async get( + path: string, + query?: Record, + ): Promise { + const url = this.buildUrl(path, query); + return this.executeWithRetry(url, { method: "GET" }); + } + + async post(path: string, body: unknown): Promise { + const url = this.buildUrl(path); + return this.executeWithRetry(url, { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } + + private buildUrl( + path: string, + query?: Record, + ): string { + const base = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + if (!query || Object.keys(query).length === 0) return base; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + params.set(key, String(value)); + } + return `${base}?${params.toString()}`; + } + + private async executeWithRetry( + url: string, + init: RequestInit, + ): Promise { + const headers = new Headers(init.headers ?? {}); + headers.set("Accept-Language", this.locale); + if (this.traceId) { + headers.set("X-Trace-Id", this.traceId); + } + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + if (attempt > 0) { + const delay = this.calculateDelay(attempt); + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + try { + const response = await this.fetchWithTimeout(url, { + ...init, + headers, + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const status = response.status; + let body: unknown; + try { + body = await response.json(); + } catch { + // body may not be JSON + } + + const httpError = new ApiHttpError( + `HTTP ${status} from ${url}`, + status, + body, + ); + + if (this.retryStatusCodes.has(status) && attempt < this.maxRetries) { + lastError = httpError; + this.logger?.warn("Retrying request", { + url, + status, + attempt: attempt + 1, + }); + continue; + } + + throw httpError; + } catch (err) { + if (err instanceof ApiHttpError || err instanceof ApiTimeoutError) { + if (err instanceof ApiTimeoutError && attempt < this.maxRetries) { + lastError = err; + continue; + } + throw err; + } + if (err instanceof Error) { + if (attempt < this.maxRetries) { + lastError = new ApiNetworkError(err); + continue; + } + throw new ApiNetworkError(err); + } + throw err; + } + } + + throw lastError ?? new Error("Unexpected retry exhaustion"); + } + + private async fetchWithTimeout( + url: string, + init: RequestInit, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + return await this.fetchFn(url, { + ...init, + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new ApiTimeoutError(this.timeoutMs); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + } + + private calculateDelay(attempt: number): number { + if (this.timeoutFactor === 0) return 0; + return Math.min(1000 * this.timeoutFactor ** (attempt - 1), 30000); + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/shared/api/client +``` + +- [ ] **Step 5: Typecheck + lint, then commit** + +```bash +pnpm typecheck && pnpm lint +git add src/shared/api/client.ts src/shared/api/client.test.ts +git commit -m "Add ApiClient with retry, timeout, and error mapping" +``` + +--- + +## Task 5 — TDD `CachedApiClient` decorator + +**Files:** +- Create: `src/shared/api/cached-client.ts` +- Create: `src/shared/api/cached-client.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/shared/api/cached-client.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { CachedApiClient } from "./cached-client.js"; +import { ApiClient } from "./client.js"; +import { RequestScopedCache, ClientMemoryCache } from "./cache.js"; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({ data: "fresh" })), + post: vi.fn(async () => ({ data: "posted" })), + } as unknown as ApiClient; +} + +describe("CachedApiClient", () => { + it("delegates to the underlying client on cache miss", async () => { + const client = mockClient(); + const cached = new CachedApiClient({ client }); + const result = await cached.get("/flights"); + expect(result).toEqual({ data: "fresh" }); + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it("returns cached value from request-scoped cache on hit", async () => { + const client = mockClient(); + const requestScoped = new RequestScopedCache(); + const cached = new CachedApiClient({ client, requestScoped }); + + // First call populates the cache + await cached.get("/flights"); + // Second call should hit the request-scoped cache + await cached.get("/flights"); + + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it("returns cached value from client-memory cache on hit", async () => { + const client = mockClient(); + const clientMemory = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + const cached = new CachedApiClient({ client, clientMemory }); + + await cached.get("/flights"); + await cached.get("/flights"); + + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it("cache key differs by query params", async () => { + const client = mockClient(); + const clientMemory = new ClientMemoryCache({ max: 10, defaultTtlMs: 5000 }); + const cached = new CachedApiClient({ client, clientMemory }); + + await cached.get("/flights", { date: "2025-01-15" }); + await cached.get("/flights", { date: "2025-01-16" }); + + expect(client.get).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run — MUST FAIL** + +```bash +pnpm test src/shared/api/cached-client +``` + +- [ ] **Step 3: Write implementation** + +Create `src/shared/api/cached-client.ts`: + +```typescript +import type { ApiClient } from "./client.js"; +import type { RequestScopedCache, ClientMemoryCache, ServerLruCache } from "./cache.js"; + +export interface CachedClientOptions { + client: ApiClient; + requestScoped?: RequestScopedCache; + clientMemory?: ClientMemoryCache; + serverLru?: ServerLruCache; + ttlMs?: number; +} + +export class CachedApiClient { + private readonly client: ApiClient; + private readonly requestScoped?: RequestScopedCache; + private readonly clientMemory?: ClientMemoryCache; + private readonly serverLru?: ServerLruCache; + private readonly ttlMs?: number; + + constructor(options: CachedClientOptions) { + this.client = options.client; + this.requestScoped = options.requestScoped; + this.clientMemory = options.clientMemory; + this.serverLru = options.serverLru; + this.ttlMs = options.ttlMs; + } + + async get( + path: string, + query?: Record, + ): Promise { + const key = this.buildKey(path, query); + + // (1) Check request-scoped cache (SSR dedup) + if (this.requestScoped) { + const cached = this.requestScoped.get(key); + if (cached) return cached; + } + + // (2) Check client-memory cache + if (this.clientMemory) { + const cached = this.clientMemory.get(key) as T | undefined; + if (cached !== undefined) return cached; + } + + // (3) Check server LRU cache + if (this.serverLru) { + const cached = this.serverLru.get(key) as T | undefined; + if (cached !== undefined) return cached; + } + + // Cache miss — fetch from upstream + const promise = this.client.get(path, query); + + // Store the in-flight promise in request-scoped cache for dedup + if (this.requestScoped) { + this.requestScoped.set(key, promise); + } + + const result = await promise; + + // Populate longer-lived caches with the resolved value + if (this.clientMemory) { + this.clientMemory.set(key, result, this.ttlMs); + } + if (this.serverLru) { + this.serverLru.set(key, result, this.ttlMs); + } + + return result; + } + + private buildKey( + path: string, + query?: Record, + ): string { + const q = query + ? Object.keys(query) + .sort() + .map((k) => `${k}=${String(query[k])}`) + .join("&") + : ""; + return `${path}?${q}`; + } +} +``` + +- [ ] **Step 4: Run — ALL MUST PASS** + +```bash +pnpm test src/shared/api/cached-client +``` + +- [ ] **Step 5: Typecheck + lint, then commit** + +```bash +pnpm typecheck && pnpm lint +git add src/shared/api/cached-client.ts src/shared/api/cached-client.test.ts +git commit -m "Add CachedApiClient decorator layered above ApiClient" +``` + +--- + +## Task 6 — Create `src/shared/api/provider.tsx` + +**Files:** +- Create: `src/shared/api/provider.tsx` + +No TDD — thin React wrapper, exercised by 1F-layout. + +- [ ] **Step 1: Write `src/shared/api/provider.tsx`** + +```tsx +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { ApiClient } from "./client.js"; + +const ApiClientContext = createContext(null); + +export interface ApiClientProviderProps { + client: ApiClient; + children: ReactNode; +} + +/** + * Provides the ApiClient instance to the React tree. SSR-aware: + * on the server, construct per-request with the resolved locale; + * on the client, share a single instance across the tab. + */ +export function ApiClientProvider({ + client, + children, +}: ApiClientProviderProps): JSX.Element { + return ( + + {children} + + ); +} + +/** + * Returns the ApiClient from context. Throws if used outside + * . + */ +export function useApiClient(): ApiClient { + const client = useContext(ApiClientContext); + if (!client) { + throw new Error( + "useApiClient() must be used within an . " + + "Ensure the root layout wraps the tree with .", + ); + } + return client; +} +``` + +- [ ] **Step 2: Typecheck + lint** + +```bash +pnpm typecheck && pnpm lint +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/api/provider.tsx +git commit -m "Add ApiClient React context provider with useApiClient hook" +``` + +--- + +## Task 7 — Exit-gate verification + +- [ ] **Step 1: All gates** + +```bash +pnpm typecheck && pnpm lint && pnpm test +``` + +Expected: all pass. Report total test count — should be 39 (from 1C) + errors + circuit-breaker + cache + client + cached-client = ~65+ tests. + +- [ ] **Step 2: Git status clean** + +```bash +git status +``` + +--- + +## Self-review + +**Spec coverage.** Master plan §1D exports: +- `ApiClient` with retry + timeout → Task 4 +- `ApiHttpError`, `ApiTimeoutError`, `ApiNetworkError` → Task 1 +- `RequestScopedCache`, `ClientMemoryCache`, `ServerLruCache`, `cacheKey()` → Task 3 +- `CircuitBreaker` → Task 2 +- `CachedApiClient` decorator → Task 5 +- `ApiClientProvider` + `useApiClient()` → Task 6 + +**Exit gate coverage from master plan:** +- success response deserialization → Task 4 test "deserializes a successful JSON response" +- retry on `[408, 429, 500, 502, 503, 504]` → Task 4 tests "retries on 500" + "retries on 429" +- no retry on other 4xx → Task 4 test "does not retry on 400" +- timeout → Task 4 test "throws ApiTimeoutError on timeout" +- circuit breaker open/half-open/closed → Task 2 (8 tests) +- request-scoped cache dedup → Task 3 "deduplicates concurrent promises" +- client-memory TTL eviction → Task 3 "evicts after TTL expires" +- server LRU byte cap eviction → Task 3 "evicts when byte cap is exceeded" + +**Placeholder scan.** No TBD/TODO. All code blocks complete. + +**Type consistency.** `Language` imported from `@/i18n/resolver` (1C). `Logger` imported from `@/observability/logger/types` (1A-1). `ApiClient`, `CachedApiClient`, error classes all cross-reference consistently. + +**Deviation from master plan:** The plan spec says "Server path uses `undici.RetryAgent` under the hood." This plan uses a hand-rolled retry wrapper for both server and client paths. Behavioral contract is identical (same status codes, same backoff). This simplifies the implementation and avoids a test-time dep on undici's internals. The undici package is still installed as a dep for future use (explicit keep-alive agent, HTTP/2), but retry is handled at the application layer.