Skip to content

FRD: API Client Library

FieldValue
IDFRD-012
OwnerDavid Holmes
StatusDraft
Last Updated2026-05-26
Open Source Librarieszod
RelatedADR-027 (Default Tech Stack)
Target Releasev2.0.0
TypeLibrary
ComplexityM

Document Summary

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.
  • TanStack Query integration examples (query key factories, queryFn adapters).
  • Tests covering happy path, error parsing, retries, pagination, and idempotency.

Out of scope

  • GraphQL client (REST/JSON only).
  • WebSocket or Server-Sent Events.
  • Request caching (TanStack Query handles this).
  • Authentication token refresh (handled by the auth library; the API client accepts a getAccessToken interceptor).

Users and Pain Points

UserPain Point
App developerWrites a new fetch wrapper for every app, duplicating error handling, header injection, and retry logic.
App developerForgets to validate API responses at runtime, leading to silent type mismatches when the backend evolves.
App developerImplements pagination manually for each list endpoint, with inconsistent cursor/offset handling.
App developerNo standard idempotency-key support, causing duplicate mutations on network retries.
QA engineerDifficult to test API error states because each app surfaces errors differently.

Definitions

TermDefinition
ApiErrorThe canonical error shape defined in src/lib/api-types.ts: { code, message, details?, request_id }.
Result<T>Discriminated union { ok: true, data: T } | { ok: false, error: ApiError } from src/lib/api-types.ts.
PaginatedResponse<T>The canonical { data: T[], pagination: Pagination } list envelope.
Idempotency keyA client-generated unique key sent in the Idempotency-Key header to prevent duplicate server-side processing of retried mutations.
InterceptorA function that transforms a request before sending or a response before returning to the caller.

Current State

src/lib/api-types.ts provides:

  • apiErrorSchema — Zod schema for the canonical error envelope.
  • paginationSchema — Zod schema for pagination metadata.
  • paginatedResponseSchema(itemSchema) — factory for paginated list response schemas.
  • Result<T> — discriminated union type for explicit ok/error handling.
  • TypeScript types: ApiError, Pagination, PaginatedResponse<T>.

No runtime client code exists. Each app writes its own fetch calls, parses JSON manually, and handles errors ad hoc.


Proposed Solution

7.1 Client factory

const api = createApiClient({
baseUrl: "https://api.example.com",
interceptors: [withAuth(() => getAccessToken()), withRequestId()],
retry: { maxRetries: 3, retryableStatuses: [502, 503, 504] },
});

7.2 Typed requests

const result = await api.get("/orders/:id", {
params: { id: "ord_123" },
schema: orderSchema, // Zod schema for the response body
});
if (!result.ok) {
// result.error is ApiError
console.error(result.error.code);
return;
}
// result.data is the Zod-parsed type

7.3 Pagination

const page = await api.fetchPage("/orders", {
schema: orderSchema,
page: 1,
pageSize: 25,
});
// page.data: Order[], page.pagination: Pagination
const all = await api.fetchAllPages("/orders", {
schema: orderSchema,
pageSize: 100,
maxPages: 10,
});
// all: Order[] (concatenated from all pages)

7.4 Idempotency

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

IDPriorityRequirement
API-01P0createApiClient factory with baseUrl, interceptors, and retry config.
API-02P0get, post, put, patch, delete methods returning Result<T>.
API-03P0Every response body is validated with the caller-provided Zod schema.
API-04P0Non-2xx responses are parsed as ApiError using apiErrorSchema.
API-05P0fetchPage and fetchAllPages pagination helpers.
API-06P0Automatic Idempotency-Key header on mutation requests.
API-07P0Retry middleware with exponential backoff and jitter.
API-08P1Request/response interceptor chain.
API-09P1Path-parameter substitution (:id in URL patterns).
API-10P1TanStack Query integration examples in documentation.

Functional Requirements

  1. Client creation: createApiClient validates its config with Zod. baseUrl must be a valid URL. retry.maxRetries must be a non-negative integer, default 3.
  2. Request execution: Each method builds a Request object, runs it through the interceptor chain, calls fetch, then processes the response.
  3. 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 } }.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. 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

CategoryRequirement
PortabilityUses the Fetch API and Web Crypto API only. Runs on Node 20+, Deno, Cloudflare Workers, and browsers.
Bundle sizeCore client under 3 KB gzipped (excluding Zod, which is already in the bundle).
TestabilitycreateApiClient accepts a fetcher override for deterministic tests without network access.
ObservabilityEach request generates a request_id (UUID) attached to the request headers and included in error responses for correlation.
Type safetyMethod return types are inferred from the provided Zod schema. Path parameters are type-checked against the URL pattern.

API/Interface Requirements

Client factory

interface ApiClientConfig {
baseUrl: string;
defaultHeaders?: Record<string, string>;
fetcher?: typeof fetch;
interceptors?: Interceptor[];
retry?: RetryConfig;
}
interface RetryConfig {
baseDelayMs?: number; // default: 500
maxDelayMs?: number; // default: 10000
maxRetries?: number; // default: 3
retryableStatuses?: number[]; // default: [502, 503, 504]
}
function createApiClient(config: ApiClientConfig): ApiClient;

Request methods

interface ApiClient {
get<T>(path: string, options: RequestOptions<T>): Promise<Result<T>>;
post<T>(path: string, options: MutationOptions<T>): Promise<Result<T>>;
put<T>(path: string, options: MutationOptions<T>): Promise<Result<T>>;
patch<T>(path: string, options: MutationOptions<T>): Promise<Result<T>>;
delete<T>(path: string, options: RequestOptions<T>): Promise<Result<T>>;
fetchPage<T>(path: string, options: PaginationOptions<T>): Promise<Result<PaginatedResponse<T>>>;
fetchAllPages<T>(path: string, options: FetchAllPagesOptions<T>): Promise<Result<T[]>>;
}

Interceptors

type Interceptor = (
request: Request,
next: () => Promise<Response>,
) => Promise<Response>;
function withAuth(getToken: () => Promise<string> | string): Interceptor;
function withRequestId(): Interceptor;

Accessibility Requirements

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

DependencyTypeNotes
src/lib/api-types.tsInternalSchemas and types for ApiError, Pagination, PaginatedResponse, Result.
zodExistingResponse validation.
Web Crypto APIRuntimecrypto.randomUUID() for idempotency keys and request IDs.
@tanstack/react-queryPeer (optional)Integration examples only; not a runtime dependency of the client.

Risks and Tradeoffs

RiskLikelihoodImpactMitigation
Zod parsing on every response adds latency.LowLowZod 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.MediumMediumDocument 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.LowLowOnly substitute parameters explicitly passed in options.params. Unmatched :segments are left as-is.
fetchAllPages could fetch unbounded data if maxPages is not set.MediumMediumDefault maxPages to 100 with a warning in TSDoc. Throw if total pages exceeds the limit.

Open Questions

  1. Should the client support streaming responses (ReadableStream) for large downloads, or is that out of scope for v2.0?
  2. Should fetchAllPages use cursor-based pagination (via next_cursor) in addition to offset-based? The paginationSchema already includes next_cursor as optional.
  3. Should interceptors have access to a typed context object (e.g., retry count, request timing) or just the raw Request?
  4. 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 &lt;token&gt; 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:

  1. 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).
  2. Import schemas and types from src/lib/api-types.ts. Do not duplicate them.
  3. The Result&lt;T&gt; return type is already defined in api-types.ts. Use it directly.
  4. 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.
  5. Interceptors are composed as a middleware chain. The innermost function calls fetch. Each interceptor wraps the next one.
  6. 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.
  7. fetchAllPages should use a while loop, not recursion, to avoid stack overflow on large datasets.
  8. TanStack Query examples go in docs/best-practices/api-client.mdx, not in the library source.
  9. Tests go in src/lib/api-client/*.test.ts. Use a mock fetcher that returns predetermined Response objects.
  10. Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.

Decision Log

DateDecisionRationale
2026-05-26Return Result&lt;T&gt; instead of throwing on API errors.Aligns with the existing Result type in api-types.ts. Forces callers to handle errors explicitly.
2026-05-26Mutations without idempotency keys are not retried.Prevents accidental duplicate mutations when the server does not support idempotency.
2026-05-26Interceptors follow the middleware pattern (request + next).Familiar pattern from Koa/Hono middleware. Composable and testable.
2026-05-26No dependency on TanStack Query in the library itself.Keeps the core client framework-agnostic. Integration is via examples and thin adapter functions.

Document History

VersionDateAuthorChanges
0.12026-05-26David HolmesInitial draft.