# 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<T>(fn: () => Promise<T>): Promise<T> {
    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<string>("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })();
    const p2 = cache.get<string>("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<string>({ 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<string>({ 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<string>({ 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<string>({ 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<string>({ 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<string>({ 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<string>({
      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<string>({
      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<string>({
      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<string>({
      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<string, unknown>,
  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<string, Promise<unknown>>();

  get<T>(key: string): Promise<T> | undefined {
    return this.store.get(key) as Promise<T> | undefined;
  }

  set<T>(key: string, promise: Promise<T>): void {
    this.store.set(key, promise);
  }
}

// --- (2) Client-side per-tab in-memory TTL cache ---

export class ClientMemoryCache<T> {
  private readonly cache: LRUCache<string, T>;

  constructor(options: { max: number; defaultTtlMs: number }) {
    this.cache = new LRUCache<string, T>({
      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<T> {
  private readonly cache: LRUCache<string, T>;

  constructor(options: {
    maxBytes: number;
    defaultTtlMs: number;
    sizeCalculation?: (value: T, key: string) => number;
  }) {
    this.cache = new LRUCache<string, T>({
      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<string, string>; 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls).toHaveLength(1);
  });

  it("throws ApiTimeoutError on timeout", async () => {
    vi.useFakeTimers();
    const neverResolve = vi.fn(() => new Promise<Response>(() => {}));
    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<typeof vi.fn>).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<number>;
  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<T>(
    path: string,
    query?: Record<string, string | number | boolean>,
  ): Promise<T> {
    const url = this.buildUrl(path, query);
    return this.executeWithRetry<T>(url, { method: "GET" });
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const url = this.buildUrl(path);
    return this.executeWithRetry<T>(url, {
      method: "POST",
      body: JSON.stringify(body),
      headers: { "Content-Type": "application/json" },
    });
  }

  private buildUrl(
    path: string,
    query?: Record<string, string | number | boolean>,
  ): 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<T>(
    url: string,
    init: RequestInit,
  ): Promise<T> {
    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<Response> {
    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<unknown>({ 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<unknown>({ 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<unknown>;
  serverLru?: ServerLruCache<unknown>;
  ttlMs?: number;
}

export class CachedApiClient {
  private readonly client: ApiClient;
  private readonly requestScoped?: RequestScopedCache;
  private readonly clientMemory?: ClientMemoryCache<unknown>;
  private readonly serverLru?: ServerLruCache<unknown>;
  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<T>(
    path: string,
    query?: Record<string, string | number | boolean>,
  ): Promise<T> {
    const key = this.buildKey(path, query);

    // (1) Check request-scoped cache (SSR dedup)
    if (this.requestScoped) {
      const cached = this.requestScoped.get<T>(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<T>(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, string | number | boolean>,
  ): 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<ApiClient | null>(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 (
    <ApiClientContext.Provider value={client}>
      {children}
    </ApiClientContext.Provider>
  );
}

/**
 * Returns the ApiClient from context. Throws if used outside
 * <ApiClientProvider>.
 */
export function useApiClient(): ApiClient {
  const client = useContext(ApiClientContext);
  if (!client) {
    throw new Error(
      "useApiClient() must be used within an <ApiClientProvider>. " +
      "Ensure the root layout wraps the tree with <ApiClientProvider>.",
    );
  }
  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.
