This FRD defines a shared, typed API client library that wraps fetch with Zod response parsing, canonical error-envelope handling, pagination helpers, idempotency-key support, and configurable retry middleware. The library builds on the existing src/lib/api-types.ts schemas (apiErrorSchema, paginationSchema, paginatedResponseSchema, Result) and produces a client that every application imports instead of writing its own fetch wrappers. TanStack Query integration examples are included but the core client is framework-agnostic.
Introduction
Every application in the platform makes HTTP API calls. Today, each app writes its own fetch wrapper with inconsistent error handling, ad-hoc retry logic, and no shared Zod validation of responses. The canonical ApiError and PaginatedResponse types exist in src/lib/api-types.ts but are only used as TypeScript types — no runtime code enforces them at the client layer. This library closes that gap with a single createApiClient factory that produces a fully typed, validated, and instrumented HTTP client.
Scope
In scope
createApiClient(config) factory that returns a typed client instance.
Request methods: get, post, put, patch, delete with typed request/response generics.
Zod response parsing: every response is validated against a caller-provided Zod schema.
Canonical error handling: non-2xx responses are parsed as ApiError and returned as Result<T> discriminated unions.
Pagination helpers: fetchPage and fetchAllPages that work with the canonical PaginatedResponse shape.
Idempotency-key header injection for POST/PUT/PATCH requests.
Retry middleware with configurable strategy (exponential backoff, max retries, retryable status codes).
Request/response interceptors for auth header injection, logging, and custom transforms.
POST, PUT, and PATCH requests automatically include an Idempotency-Key header generated from crypto.randomUUID() unless the caller provides one. GET and DELETE requests do not include it.
7.5 Retry middleware
Retries use exponential backoff with jitter. Only network errors and responses with retryable status codes (default: 502, 503, 504) are retried. Mutations (POST/PUT/PATCH/DELETE) are only retried when an idempotency key is present.
Requirements
ID
Priority
Requirement
API-01
P0
createApiClient factory with baseUrl, interceptors, and retry config.
API-02
P0
get, post, put, patch, delete methods returning Result<T>.
API-03
P0
Every response body is validated with the caller-provided Zod schema.
API-04
P0
Non-2xx responses are parsed as ApiError using apiErrorSchema.
API-05
P0
fetchPage and fetchAllPages pagination helpers.
API-06
P0
Automatic Idempotency-Key header on mutation requests.
API-07
P0
Retry middleware with exponential backoff and jitter.
API-08
P1
Request/response interceptor chain.
API-09
P1
Path-parameter substitution (:id in URL patterns).
API-10
P1
TanStack Query integration examples in documentation.
Functional Requirements
Client creation: createApiClient validates its config with Zod. baseUrl must be a valid URL. retry.maxRetries must be a non-negative integer, default 3.
Request execution: Each method builds a Request object, runs it through the interceptor chain, calls fetch, then processes the response.
Response parsing: 2xx responses are parsed as JSON and validated against the provided Zod schema. If Zod validation fails, the result is { ok: false, error: { code: "client.response_validation_failed", message, details, request_id } }.
Error parsing: Non-2xx responses attempt to parse the body as ApiError. If parsing fails (e.g., HTML error page), a synthetic ApiError is created with code: "client.unexpected_error" and the status code in details.
Pagination: fetchPage sends ?page=N&page_size=M query parameters and parses the response with paginatedResponseSchema(itemSchema). fetchAllPages iterates until pagination.page >= pagination.total_pages or maxPages is reached.
Idempotency: The idempotency key is stored per-request. On retry, the same key is sent again. Callers can provide their own key via options.idempotencyKey.
Retry logic: On a retryable failure, the client waits min(baseDelay * 2^attempt + jitter, maxDelay) milliseconds before retrying. Mutations without an idempotency key are not retried (the result is returned immediately).
Interceptors: Interceptors are functions (request: Request, next: () => Promise<Response>) => Promise<Response>. They compose in order: the first interceptor in the array is the outermost wrapper.
Non-Functional Requirements
Category
Requirement
Portability
Uses the Fetch API and Web Crypto API only. Runs on Node 20+, Deno, Cloudflare Workers, and browsers.
Bundle size
Core client under 3 KB gzipped (excluding Zod, which is already in the bundle).
Testability
createApiClient accepts a fetcher override for deterministic tests without network access.
Observability
Each request generates a request_id (UUID) attached to the request headers and included in error responses for correlation.
Type safety
Method return types are inferred from the provided Zod schema. Path parameters are type-checked against the URL pattern.
This is a headless library with no UI. Error messages in ApiError should be human-readable and suitable for display in UI components. Error codes should be stable, predictable strings (not numeric) so consuming components can map them to accessible error messages.
Content and Documentation Requirements
TSDoc on every exported function, type, and interface.
A docs/best-practices/api-client.mdx guide covering: client creation, making typed requests, pagination, error handling, retry configuration, idempotency, and TanStack Query integration patterns.
TanStack Query examples showing query key factories, queryFn adapters using the client, and optimistic mutation patterns with the Result type.
Storybook docs page under Docs/Best Practices/API Client.
Dependencies
Dependency
Type
Notes
src/lib/api-types.ts
Internal
Schemas and types for ApiError, Pagination, PaginatedResponse, Result.
zod
Existing
Response validation.
Web Crypto API
Runtime
crypto.randomUUID() for idempotency keys and request IDs.
@tanstack/react-query
Peer (optional)
Integration examples only; not a runtime dependency of the client.
Risks and Tradeoffs
Risk
Likelihood
Impact
Mitigation
Zod parsing on every response adds latency.
Low
Low
Zod parse for typical API responses is sub-millisecond. Provide an unsafeParse escape hatch for performance-critical paths that skips validation.
Retry with idempotency keys assumes the server respects them.
Medium
Medium
Document the server-side contract. The client generates the key; server enforcement is a backend responsibility.
Path-parameter substitution (:id) may conflict with URLs that contain literal colons.
Low
Low
Only substitute parameters explicitly passed in options.params. Unmatched :segments are left as-is.
fetchAllPages could fetch unbounded data if maxPages is not set.
Medium
Medium
Default maxPages to 100 with a warning in TSDoc. Throw if total pages exceeds the limit.
Open Questions
Should the client support streaming responses (ReadableStream) for large downloads, or is that out of scope for v2.0?
Should fetchAllPages use cursor-based pagination (via next_cursor) in addition to offset-based? The paginationSchema already includes next_cursor as optional.
Should interceptors have access to a typed context object (e.g., retry count, request timing) or just the raw Request?
Should the client support request cancellation via AbortSignal? Leaning yes as it is trivial to thread through.
Acceptance Criteria
createApiClient produces a client with get, post, put, patch, delete, fetchPage, and fetchAllPages methods.
All responses are validated with the caller-provided Zod schema; validation failures return Result with ok: false.
Non-2xx responses are parsed as ApiError; unparseable error bodies produce a synthetic ApiError with the status code.
POST, PUT, and PATCH requests include an Idempotency-Key header by default.
Retry middleware retries on 502/503/504 with exponential backoff and jitter.
Mutations without an idempotency key are not retried.
fetchPage sends correct query parameters and parses the paginated response.
fetchAllPages concatenates results from multiple pages up to maxPages.
withAuth interceptor injects Authorization: Bearer <token> on every request.
All methods accept a fetcher override for deterministic tests.
Unit tests cover: successful request, Zod validation failure, error parsing, retry with backoff, pagination, idempotency key injection, and interceptor ordering.
LLM Handoff Instructions
When implementing this FRD:
Create src/lib/api-client/ as a new module. Files: index.ts, client.ts (core factory and methods), interceptors.ts (built-in interceptors), pagination.ts (pagination helpers), retry.ts (retry middleware), errors.ts (client-specific error creation), types.ts (config and option types).
Import schemas and types from src/lib/api-types.ts. Do not duplicate them.
The Result<T> return type is already defined in api-types.ts. Use it directly.
For path-parameter substitution, implement a simple path.replace(/:(\w+)/g, (_, key) => params[key]) approach. Throw if a param key is missing from the provided params.
Interceptors are composed as a middleware chain. The innermost function calls fetch. Each interceptor wraps the next one.
Retry logic lives in retry.ts as an interceptor that wraps the fetch call. It checks the response status and the presence of an idempotency key before deciding to retry.
fetchAllPages should use a while loop, not recursion, to avoid stack overflow on large datasets.
TanStack Query examples go in docs/best-practices/api-client.mdx, not in the library source.
Tests go in src/lib/api-client/*.test.ts. Use a mock fetcher that returns predetermined Response objects.
Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.
Decision Log
Date
Decision
Rationale
2026-05-26
Return Result<T> instead of throwing on API errors.
Aligns with the existing Result type in api-types.ts. Forces callers to handle errors explicitly.
2026-05-26
Mutations without idempotency keys are not retried.
Prevents accidental duplicate mutations when the server does not support idempotency.
2026-05-26
Interceptors follow the middleware pattern (request + next).
Familiar pattern from Koa/Hono middleware. Composable and testable.
2026-05-26
No dependency on TanStack Query in the library itself.
Keeps the core client framework-agnostic. Integration is via examples and thin adapter functions.