diff --git a/CLAUDE.md b/CLAUDE.md index c918d93..f938a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,13 @@ Before updating docs for a release, inspect the package source and tests instead - `packages/client/src/index.ts` - `packages/client/src/types/*` - `packages/client/src/errors/*` +- `packages/client/src/adapters/*` - `packages/client/src/core/request.ts` - `packages/client/src/core/should-retry.ts` +- `packages/client/src/core/resolve-runtime-config.ts` +- `packages/client/src/core/resolve-response-validator.ts` +- `packages/client/src/core/telemetry.ts` +- `packages/client/package.json` (exports, peer dependencies) - `packages/client/tests` If behavior is unclear, check the release branch or ask before documenting it. @@ -67,11 +72,13 @@ For example: - Installation - Create Client - Response Handling +- Serialization - Auth - Hooks - Observability - Retry - Errors +- Extensibility - Examples - API Reference @@ -80,15 +87,40 @@ For example: Keep these names and behaviors consistent across the docs: - `baseUrl`, never `baseURL` -- `createClient` +- `createClient`, `createTelemetryHooks` - `get`, `delete`, `post`, `put`, `patch`, `request` - auth strategies: `bearer`, `apiKey`, `custom` -- retry config: `attempts`, `retryOn`, `retryMethods`, `backoff`, `baseDelayMs` -- request metadata: `requestId`, `x-request-id` +- auth interfaces: `AuthProvider`, `AuthContext` +- retry config: `attempts`, `retryOn`, `retryMethods`, `backoff`, `baseDelayMs`, `jitter`, + `maxElapsedMs`, `shouldRetry` +- retry interfaces: `RetryPredicate`, `RetryPredicateContext` +- request metadata: `requestId`, `x-request-id`, `operationName` - idempotency: `idempotencyKey`, `idempotency-key` -- response validation: `validateResponse`, `ResponseValidator`, `ValidationError` +- response validation: `validateResponse`, `ResponseValidator`, `responseSchema`, + `ValidationAdapter`, `ValidationResult`, `ValidationError` +- validation adapters: `zodAdapter` from `@dfsync/client/adapters/zod` +- serialization: `serializeBody`, `parseResponse`, `BodySerializer`, `ResponseParser`, + `SerializedBody`, `SerializerContext` - hooks: `beforeRequest`, `afterResponse`, `onError`, `onRetry` +- telemetry: `TelemetryExporter`, `onRequestStart`, `onRequestSuccess`, `onRequestError`, + `onRequestRetry` - errors: `DfsyncError`, `HttpError`, `NetworkError`, `TimeoutError`, `ValidationError`, `RequestAbortedError` +- error metadata: `requestId`, `attempt`, `durationMs`, and `issues` on `ValidationError` + +## Behavioral rules that are easy to get wrong + +- validation runs only after a successful HTTP response, and validation failures are never retried +- validator precedence: request `responseSchema` → request `validateResponse` → + client `responseSchema` → client `validateResponse` +- `responseSchema` does not transform the response in this version, even when the adapter + returns `data` +- `retry.shouldRetry` runs only after the built-in rules already allow a retry, so it can + restrict retries but never widen them +- `maxElapsedMs` is checked against elapsed time plus the next planned delay +- `jitter` never applies to `Retry-After` delays +- `parseResponse` runs for every response, before classification and validation +- a serializer's `contentType` is applied only when no `content-type` header is set +- `operationName` is context-only and is never sent as a header ## Release docs checklist diff --git a/docs/client/v1/api-reference.md b/docs/client/v1/api-reference.md index 70b7986..caa5bbc 100644 --- a/docs/client/v1/api-reference.md +++ b/docs/client/v1/api-reference.md @@ -1,11 +1,47 @@ # API Reference +## Exports + +```ts +import { + createClient, + createTelemetryHooks, + DfsyncError, + HttpError, + NetworkError, + TimeoutError, + ValidationError, + RequestAbortedError, +} from '@dfsync/client'; +``` + +Subpath exports: + +```ts +import { zodAdapter } from '@dfsync/client/adapters/zod'; +``` + ## createClient Creates a configured HTTP client. ```ts import { createClient } from '@dfsync/client'; + +const client = createClient({ baseUrl: 'https://api.example.com' }); +``` + +## createTelemetryHooks + +Maps a `TelemetryExporter` onto a `HooksConfig`. + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); ``` ## Client methods @@ -23,38 +59,104 @@ client.request(config) ## Configuration -- `baseUrl` -- `timeout` -- `headers` -- `fetch` -- `retry` -- `auth` -- `hooks` -- `validateResponse` +| Option | Type | Description | +| ------------------ | ----------------------- | --------------------------------- | +| `baseUrl` | `string` | required base URL | +| `timeout` | `number` | request timeout, default `5000` | +| `headers` | `Record` | default headers | +| `fetch` | `typeof fetch` | custom fetch implementation | +| `auth` | `AuthConfig` | auth strategy | +| `hooks` | `HooksConfig` | lifecycle hooks | +| `retry` | `RetryConfig` | retry policy | +| `validateResponse` | `ResponseValidator` | predicate response validation | +| `responseSchema` | `ValidationAdapter` | schema-based response validation | +| `serializeBody` | `BodySerializer` | custom request body serialization | +| `parseResponse` | `ResponseParser` | custom response parsing | ## Request options -- `query` -- `headers` -- `timeout` -- `retry` -- `signal` -- `requestId` -- `idempotencyKey` -- `validateResponse` +| Option | Type | Description | +| ------------------ | ------------------- | -------------------------------- | +| `query` | `QueryParams` | query string values | +| `headers` | `HeadersMap` | request headers | +| `timeout` | `number` | per-request timeout | +| `retry` | `RetryConfig` | per-request retry policy | +| `signal` | `AbortSignal` | external cancellation | +| `requestId` | `string` | explicit request identifier | +| `operationName` | `string` | human-readable operation label | +| `idempotencyKey` | `string` | idempotency key for safe retries | +| `validateResponse` | `ResponseValidator` | per-request predicate validation | +| `responseSchema` | `ValidationAdapter` | per-request schema validation | +| `serializeBody` | `BodySerializer` | per-request body serialization | +| `parseResponse` | `ResponseParser` | per-request response parsing | + +`client.request(config)` additionally takes `method`, `path`, and `body`. ## Retry -- `attempts` -- `backoff` -- `baseDelayMs` -- `retryOn` -- `retryMethods` +| Option | Type | Default | +| -------------- | ------------------ | -------------------------- | +| `attempts` | `number` | `0` | +| `backoff` | `RetryBackoff` | `'exponential'` | +| `baseDelayMs` | `number` | `300` | +| `retryOn` | `RetryCondition[]` | `['network-error', '5xx']` | +| `retryMethods` | `RequestMethod[]` | `['GET', 'PUT', 'DELETE']` | +| `jitter` | `boolean` | `false` | +| `maxElapsedMs` | `number` | not set | +| `shouldRetry` | `RetryPredicate` | not set | + +```ts +type RetryCondition = 'network-error' | '5xx' | '429'; +type RetryBackoff = 'fixed' | 'exponential'; + +type RetryPredicateContext = { + error: Error; + attempt: number; + maxAttempts: number; + requestId: string; + method: RequestMethod; +}; + +type RetryPredicate = (ctx: RetryPredicateContext) => boolean; +``` ## Response validation ```ts type ResponseValidator = (data: TData) => boolean | void | Promise; + +type ValidationResult = { + success: boolean; + data?: TData; + error?: unknown; +}; + +type ValidationAdapter = ( + data: unknown, +) => ValidationResult | Promise>; +``` + +Precedence: request `responseSchema` → request `validateResponse` → client `responseSchema` → client `validateResponse`. + +## Serialization + +```ts +type SerializerContext = { + request: RequestConfig; + requestId: string; +}; + +type SerializedBody = { + body: BodyInit; + contentType?: string; +}; + +type BodySerializer = ( + body: unknown, + ctx: SerializerContext, +) => SerializedBody | Promise; + +type ResponseParser = (response: Response, ctx: SerializerContext) => unknown | Promise; ``` ## Hooks @@ -64,27 +166,93 @@ type ResponseValidator = (data: TData) => boolean | void | Prom - `onError` - `onRetry` +Each accepts a single function or an array of functions. + +```ts +type RetrySource = 'backoff' | 'retry-after'; +``` + +## Telemetry + +```ts +type TelemetryExporter = { + onRequestStart?(ctx: BeforeRequestContext): void | Promise; + onRequestSuccess?(ctx: AfterResponseContext): void | Promise; + onRequestError?(ctx: ErrorContext): void | Promise; + onRequestRetry?(ctx: RetryContext): void | Promise; +}; +``` + +## Auth + +```ts +type AuthContext = { + request: RequestConfig; + url: URL; + headers: Record; +}; + +type AuthProvider = (ctx: AuthContext) => void | Promise; +``` + +Strategies: `bearer`, `apiKey`, `custom`. + ## Errors -- `HttpError` -- `NetworkError` -- `TimeoutError` -- `ValidationError` -- `RequestAbortedError` +- `DfsyncError` — base class, carries `code`, optional `cause`, and `requestId` / `attempt` / `durationMs` +- `HttpError` — non-2xx responses +- `NetworkError` — network failures +- `TimeoutError` — request timed out +- `ValidationError` — response validation failed, exposes optional `issues` +- `RequestAbortedError` — request was cancelled ## Exported types -- `AuthConfig` +Client and requests: + - `Client` - `ClientConfig` +- `RequestConfig` +- `RequestOptions` +- `RequestMethod` + +Retry: + - `RetryConfig` - `RetryCondition` - `RetryBackoff` +- `RetryPredicate` +- `RetryPredicateContext` + +Validation: + - `ResponseValidator` +- `ValidationAdapter` +- `ValidationResult` + +Serialization: + +- `BodySerializer` +- `ResponseParser` +- `SerializedBody` +- `SerializerContext` + +Auth: + +- `AuthConfig` +- `AuthContext` +- `AuthProvider` + +Hooks and telemetry: + +- `HooksConfig` +- `HookBeforeRequest` +- `HookAfterResponse` +- `HookOnError` +- `HookOnRetry` - `BeforeRequestContext` - `AfterResponseContext` - `ErrorContext` - `RetryContext` -- `HooksConfig` -- `RequestConfig` -- `RequestOptions` +- `RetrySource` +- `TelemetryExporter` diff --git a/docs/client/v1/auth.md b/docs/client/v1/auth.md index ff75606..b3e1c6d 100644 --- a/docs/client/v1/auth.md +++ b/docs/client/v1/auth.md @@ -147,6 +147,18 @@ const client = createClient({ }); ``` +The `apply` function is the exported `AuthProvider` interface, so you can define it separately and reuse it across clients: + +```ts +import type { AuthProvider } from '@dfsync/client'; + +const serviceAuth: AuthProvider = ({ headers }) => { + headers['x-service-name'] = 'billing-worker'; +}; +``` + +See **Extensibility** for the full interface. + ## Auth context Custom auth receives: @@ -206,16 +218,22 @@ type ApiKeyAuthConfig = { name?: string; }; +type AuthContext = { + request: RequestConfig; + url: URL; + headers: Record; +}; + +type AuthProvider = (ctx: AuthContext) => void | Promise; + type CustomAuthConfig = { type: 'custom'; - apply: (ctx: { - request: RequestConfig; - url: URL; - headers: Record; - }) => void | Promise; + apply: AuthProvider; }; ``` +`AuthContext` and `AuthProvider` are exported from the package. + ## Common use cases - internal service authentication diff --git a/docs/client/v1/create-client.md b/docs/client/v1/create-client.md index 212a1a0..cb08cc2 100644 --- a/docs/client/v1/create-client.md +++ b/docs/client/v1/create-client.md @@ -10,6 +10,7 @@ It provides a consistent way to configure: - lifecycle hooks - request observability metadata - response validation +- body serialization and response parsing ## Basic client @@ -66,6 +67,9 @@ type ClientConfig = { }; retry?: RetryConfig; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; ``` @@ -82,6 +86,8 @@ Retry configuration supports: - retry conditions - backoff strategy - `Retry-After` handling +- jitter and an overall retry budget +- a custom `shouldRetry` predicate ## HTTP methods @@ -167,16 +173,22 @@ type RequestOptions = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; ``` `requestId` can be provided explicitly when you want to correlate logs or trace a request across services. +`operationName` labels the request with a stable, human-readable name for logging and tracing. + Request-level `retry` overrides client-level retry settings. -Request-level `validateResponse` overrides client-level response validation. +Request-level validation, serialization, and parsing options override their client-level counterparts. ## Low-level request @@ -207,8 +219,12 @@ type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; ``` @@ -217,6 +233,7 @@ type RequestConfig = { Each request attempt is executed within a request context that contains: - `requestId` — stable identifier for the full request lifecycle +- `operationName` — optional human-readable operation label, present only when provided - `attempt` — current retry attempt (zero-based) - `maxAttempts` — total number of allowed attempts, including the initial request - `signal` — AbortSignal for cancellation @@ -266,6 +283,36 @@ await client.get('/users', { }); ``` +## Operation name + +Use `operationName` to attach a stable, human-readable label to a request. + +```ts +await client.get('/users', { + operationName: 'listUsers', +}); +``` + +Unlike `requestId`, which identifies a single request, `operationName` identifies the _kind_ of request. That makes it useful for grouping logs, metrics, and traces across many executions of the same call. + +It is part of the request context and is exposed in every lifecycle hook: + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: { + afterResponse({ operationName, requestId, durationMs }) { + console.log(operationName, requestId, durationMs); + // 'listUsers' 'k3f9x2' 42 + }, + }, +}); +``` + +`operationName` is not sent as a header. It stays inside the client lifecycle, so you decide how to expose it. + +When it is not provided, the field is absent from the hook context rather than set to `undefined`. + ## Idempotency key Use `idempotencyKey` for non-idempotent operations that may be retried safely. @@ -315,6 +362,20 @@ await client.get('/users/1', { Returning `false` throws `ValidationError`. Returning `true` or `undefined` passes validation. +For schema-based validation, use `responseSchema` instead — it accepts a `safeParse`-style adapter and ships with a ready-made zod adapter: + +```ts +import { z } from 'zod'; +import { zodAdapter } from '@dfsync/client/adapters/zod'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: zodAdapter(z.object({ id: z.string() })), +}); +``` + +See **Response Handling** for both validation styles and their precedence rules. + ## Request cancellation Requests can be cancelled using `AbortSignal`: @@ -362,6 +423,8 @@ Responses are parsed automatically during the response phase: - other content types → text - `204 No Content` → `undefined` +You can replace this with a custom `parseResponse`. See **Serialization**. + ## Body behavior If request body is an object, the client: @@ -374,6 +437,8 @@ If request body is a string, the client: - sends it as-is - does not force a `content-type` +You can replace this with a custom `serializeBody`. See **Serialization**. + ## Retry observability Retries can be observed using lifecycle hooks. @@ -419,5 +484,7 @@ If the header value is invalid, `@dfsync/client` falls back to normal retry back - See **Hooks** for lifecycle hooks and observability metadata - See **Response Handling** for parsing and response validation -- See **Retry** for retry conditions, backoff, and `Retry-After` -- See **Errors** for failure behavior and error types +- See **Serialization** for `serializeBody` and `parseResponse` +- See **Retry** for retry conditions, backoff, jitter, and the retry budget +- See **Errors** for failure behavior, error types, and error metadata +- See **Extensibility** for the stable extension interfaces diff --git a/docs/client/v1/errors.md b/docs/client/v1/errors.md index 2d14ac0..ba64502 100644 --- a/docs/client/v1/errors.md +++ b/docs/client/v1/errors.md @@ -27,6 +27,30 @@ It includes: - `code` - optional `cause` +## Error metadata + +Every error thrown from the request lifecycle extends `DfsyncError` and carries request context metadata. This lets you correlate failures without relying on lifecycle hooks. + +- `requestId` — the request identifier (matches `x-request-id`) +- `attempt` — the attempt number on which the request ultimately failed +- `durationMs` — total time spent before the error was thrown + +```ts +import { DfsyncError } from '@dfsync/client'; + +try { + await client.get('/users/1', { requestId: 'req_123' }); +} catch (error) { + if (error instanceof DfsyncError) { + console.log(error.requestId, error.attempt, error.durationMs); + } +} +``` + +These fields are optional in the type, because an error constructed outside of a request lifecycle has no request context to attach. + +Errors that are not `DfsyncError` instances are left untouched and receive no metadata. + ## HttpError Thrown when the server returns a non-2xx response. @@ -121,7 +145,7 @@ Properties: ## ValidationError -Thrown when a successful response fails `validateResponse`. +Thrown when a successful response fails `validateResponse` or `responseSchema`. ```ts import { ValidationError } from '@dfsync/client'; @@ -132,6 +156,7 @@ try { if (error instanceof ValidationError) { console.error(error.data); console.error(error.response.status); + console.error(error.issues); } } ``` @@ -141,6 +166,28 @@ Properties: - `code` → `"VALIDATION_ERROR"` - `data` - `response` +- optional `issues` + +`issues` carries adapter-specific validation details and is populated when validation ran through a `responseSchema` adapter that reported an `error`. With a plain `validateResponse` predicate there are no adapter details, so `issues` is `undefined`. + +```ts +import { z } from 'zod'; +import { ValidationError } from '@dfsync/client'; +import { zodAdapter } from '@dfsync/client/adapters/zod'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: zodAdapter(z.object({ id: z.string() })), +}); + +try { + await client.get('/users/1'); +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.issues); // the zod error + } +} +``` Validation failures are not retried. @@ -232,10 +279,13 @@ Errors thrown inside: - custom auth - `beforeRequest` - `afterResponse` +- `serializeBody` are rethrown as-is. -They are not converted into `DfsyncError` subclasses. +They are not converted into `DfsyncError` subclasses, and they do not receive error metadata. + +`parseResponse` behaves differently: it runs inside the response phase, so a throwing parser is normalized into `NetworkError`. See **Serialization** for details. ## Note diff --git a/docs/client/v1/examples.md b/docs/client/v1/examples.md index 4422778..a35e0c1 100644 --- a/docs/client/v1/examples.md +++ b/docs/client/v1/examples.md @@ -81,6 +81,33 @@ try { } ``` +## Schema validation with zod + +```ts +import { z } from 'zod'; +import { createClient, ValidationError } from '@dfsync/client'; +import { zodAdapter } from '@dfsync/client/adapters/zod'; + +const userSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: zodAdapter(userSchema), +}); + +try { + const user = await client.get('/users/1'); + console.log(user); +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.issues); + } +} +``` + ## Safe POST retry ```ts @@ -101,3 +128,78 @@ const payment = await client.post( }, ); ``` + +## Bounded retries with jitter + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 5, + backoff: 'exponential', + baseDelayMs: 200, + jitter: true, + maxElapsedMs: 3000, + shouldRetry: ({ method }) => method === 'GET', + }, +}); +``` + +## Form-encoded request body + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + serializeBody(body) { + return { + body: new URLSearchParams(body as Record).toString(), + contentType: 'application/x-www-form-urlencoded', + }; + }, +}); + +await client.post('/token', { grant_type: 'client_credentials' }); +``` + +## Labeled request with telemetry + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; +import type { TelemetryExporter } from '@dfsync/client'; + +const exporter: TelemetryExporter = { + onRequestSuccess({ operationName, durationMs }) { + console.log(operationName, durationMs); + }, + onRequestError({ operationName, error }) { + console.error(operationName, error); + }, +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); + +await client.get('/users', { + operationName: 'listUsers', +}); +``` + +## Error metadata at the call site + +```ts +import { DfsyncError } from '@dfsync/client'; + +try { + await client.get('/users/1', { requestId: 'req_123' }); +} catch (error) { + if (error instanceof DfsyncError) { + console.error({ + requestId: error.requestId, + attempt: error.attempt, + durationMs: error.durationMs, + }); + } +} +``` diff --git a/docs/client/v1/extensibility.md b/docs/client/v1/extensibility.md new file mode 100644 index 0000000..b902315 --- /dev/null +++ b/docs/client/v1/extensibility.md @@ -0,0 +1,223 @@ +# Extensibility + +`@dfsync/client` exposes a small, stable set of interfaces for extending the client. They are intentionally minimal and built on the existing request lifecycle, so extensions never introduce a separate runtime path. + +There are four extension points: + +| Interface | Configured via | Purpose | +| ------------------- | ---------------------- | ---------------------------------- | +| `AuthProvider` | `auth.apply` | apply authentication to a request | +| `ValidationAdapter` | `responseSchema` | validate parsed response data | +| `RetryPredicate` | `retry.shouldRetry` | further restrict retry decisions | +| `TelemetryExporter` | `createTelemetryHooks` | observe the full request lifecycle | + +## Auth provider + +`AuthProvider` is the contract behind `{ type: 'custom' }` auth. It applies authentication by mutating the request headers or URL through the provided `AuthContext`. + +```ts +type AuthContext = { + request: RequestConfig; + url: URL; + headers: Record; +}; + +type AuthProvider = (ctx: AuthContext) => void | Promise; +``` + +```ts +import { createClient } from '@dfsync/client'; +import type { AuthProvider } from '@dfsync/client'; + +const serviceAuth: AuthProvider = async ({ headers, url }) => { + headers['x-service-name'] = 'billing-worker'; + url.searchParams.set('tenant', 'acme'); +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + auth: { + type: 'custom', + apply: serviceAuth, + }, +}); +``` + +Auth runs before `beforeRequest` hooks, so hooks can still see and modify whatever the provider produced. + +See **Auth** for the built-in `bearer` and `apiKey` strategies. + +## Validation adapter + +`ValidationAdapter` is the `safeParse`-style contract used by `responseSchema`. It receives the parsed response data and returns a `ValidationResult`. + +```ts +type ValidationResult = { + success: boolean; + data?: TData; + error?: unknown; +}; + +type ValidationAdapter = ( + data: unknown, +) => ValidationResult | Promise>; +``` + +Adapters are lightweight wrappers around a schema library and add no runtime dependency to the core package. + +```ts +import type { ValidationAdapter } from '@dfsync/client'; + +const userSchema: ValidationAdapter = (data) => { + const ok = typeof data === 'object' && data !== null && 'id' in data; + + return ok ? { success: true } : { success: false, error: 'missing id' }; +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: userSchema, +}); +``` + +When `success` is `false`, the client throws `ValidationError` and exposes the adapter's `error` on `error.issues`. + +See **Response Handling** for `responseSchema`, precedence rules, and the ready-made zod adapter. + +## Retry policy + +`RetryPredicate` is the contract for custom retry conditions, configured as `retry.shouldRetry`. + +```ts +type RetryPredicateContext = { + error: Error; + attempt: number; + maxAttempts: number; + requestId: string; + method: RequestMethod; +}; + +type RetryPredicate = (ctx: RetryPredicateContext) => boolean; +``` + +```ts +import type { RetryPredicate } from '@dfsync/client'; + +const retryOnlyOnce: RetryPredicate = ({ attempt }) => attempt < 1; + +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 3, + shouldRetry: retryOnlyOnce, + }, +}); +``` + +The predicate runs only after the built-in rules already decided a request is retryable. It can restrict retries, but it can never make non-retryable errors retryable. + +See **Retry** for the full retry model. + +## Telemetry exporter + +`TelemetryExporter` is a telemetry sink that observes the request lifecycle. Each method receives the same context object as the corresponding lifecycle hook, so no internal state is exposed. + +```ts +type TelemetryExporter = { + onRequestStart?(ctx: BeforeRequestContext): void | Promise; + onRequestSuccess?(ctx: AfterResponseContext): void | Promise; + onRequestError?(ctx: ErrorContext): void | Promise; + onRequestRetry?(ctx: RetryContext): void | Promise; +}; +``` + +Exporters do not run themselves. Map an exporter onto the client's `hooks` with `createTelemetryHooks`: + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; +import type { TelemetryExporter } from '@dfsync/client'; + +const exporter: TelemetryExporter = { + onRequestStart(ctx) { + console.log('start', ctx.operationName, ctx.requestId); + }, + onRequestSuccess(ctx) { + console.log('done', ctx.operationName, ctx.durationMs); + }, + onRequestError(ctx) { + console.error('error', ctx.requestId, ctx.error); + }, + onRequestRetry(ctx) { + console.warn('retry', ctx.requestId, ctx.retryReason, ctx.retryDelayMs); + }, +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); +``` + +All exporter methods are optional. Only the methods you define are wired to hooks: + +| Exporter method | Hook | +| ------------------ | --------------- | +| `onRequestStart` | `beforeRequest` | +| `onRequestSuccess` | `afterResponse` | +| `onRequestError` | `onError` | +| `onRequestRetry` | `onRetry` | + +### Combining telemetry with your own hooks + +`createTelemetryHooks` returns a standard `HooksConfig`, and every hook accepts a single function or an array. That means telemetry composes with application hooks: + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; +import type { ErrorContext, TelemetryExporter } from '@dfsync/client'; + +const reportError = (ctx: ErrorContext) => { + console.error(ctx.error); +}; + +const exporter: TelemetryExporter = { + onRequestError: reportError, +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: { + ...createTelemetryHooks(exporter), + onError: [reportError, ({ requestId }) => console.error('audit', requestId)], + }, +}); +``` + +### Error behavior in exporters + +Because exporters run as hooks, they inherit hook error semantics: + +- a throwing `onRequestStart` prevents the request from being sent, and the error is rethrown +- a throwing `onRequestSuccess` is rethrown to the caller +- a throwing `onRequestError` or `onRequestRetry` never hides the original request failure + +Keep exporters non-throwing so telemetry cannot change request behavior. + +## Why these interfaces + +Each extension point maps onto a stage the client already has: + +1. auth → `AuthProvider` +2. retry decision → `RetryPredicate` +3. response validation → `ValidationAdapter` +4. lifecycle observation → `TelemetryExporter` + +This keeps the public surface small and the request lifecycle predictable. + +## Related guides + +- See **Auth** for built-in auth strategies +- See **Response Handling** for `responseSchema` and the zod adapter +- See **Retry** for retry conditions and the retry budget +- See **Hooks** for the underlying lifecycle mechanism +- See **Observability** for the metadata exporters receive +- See **Serialization** for `serializeBody` and `parseResponse` diff --git a/docs/client/v1/getting-started.md b/docs/client/v1/getting-started.md index aa3f103..d15605c 100644 --- a/docs/client/v1/getting-started.md +++ b/docs/client/v1/getting-started.md @@ -15,18 +15,21 @@ The client focuses on predictable behavior, extensibility, and a clean developer - predictable request lifecycle - request ID propagation (`x-request-id`) +- operation names for logging and tracing - request cancellation via `AbortSignal` -- built-in retry with configurable policies +- built-in retry with configurable policies, jitter, and an overall retry budget - lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`, `onRetry` - typed responses - automatic JSON parsing -- response validation with `ValidationError` -- consistent error handling +- response validation with `ValidationError`, including schema adapters and a zod adapter +- custom body serialization and response parsing +- consistent error handling with request metadata on every error - auth support: bearer, API key, custom - idempotency key support for safer retries - support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` +- stable extension interfaces for auth, validation, retry, and telemetry ## Quick example @@ -57,3 +60,10 @@ This gives you: - structured errors - request lifecycle hooks - built-in retry observability + +## Guides by use case + +- **Reliable requests** — **Retry**, **Create Client** (idempotency keys, cancellation) +- **Validation & data shape** — **Response Handling**, **Serialization** +- **Observability & tracing** — **Observability**, **Hooks**, **Errors** +- **Extensibility** — **Extensibility** (auth provider, validation adapter, retry policy, telemetry exporter) diff --git a/docs/client/v1/hooks.md b/docs/client/v1/hooks.md index 0a32ede..715ca43 100644 --- a/docs/client/v1/hooks.md +++ b/docs/client/v1/hooks.md @@ -23,6 +23,7 @@ Hooks receive structured lifecycle metadata, including request details, retry in Available metadata includes: - `requestId` — stable across retries +- `operationName` — present only when the request provides it - `attempt` - `maxAttempts` - `startedAt` @@ -115,7 +116,7 @@ If one `beforeRequest` hook throws, the request is not sent and the original err ### beforeRequest context ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt +request, url, headers, signal, attempt, maxAttempts, requestId, operationName?, startedAt ``` ## afterResponse @@ -156,7 +157,7 @@ If an `afterResponse` hook throws, that hook error is rethrown. ### Validation metadata -When `validateResponse` is configured and passes, `afterResponse` receives `validation`. +When `validateResponse` or `responseSchema` is configured and passes, `afterResponse` receives `validation`. ```ts const client = createClient({ @@ -178,7 +179,7 @@ If validation is not configured, `validation` is not present. ### afterResponse context ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, response, data, validation? +request, url, headers, signal, attempt, maxAttempts, requestId, operationName?, startedAt, endedAt, durationMs, response, data, validation? ``` ## onError @@ -213,7 +214,7 @@ This is intentional, so hook failures never hide the real request failure. ### onError context ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, error +request, url, headers, signal, attempt, maxAttempts, requestId, operationName?, startedAt, endedAt, durationMs, error ``` ## onRetry @@ -244,7 +245,7 @@ const client = createClient({ ### onRetry context ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, error, retryDelayMs, retryReason, retrySource +request, url, headers, signal, attempt, maxAttempts, requestId, operationName?, startedAt, endedAt, durationMs, error, retryDelayMs, retryReason, retrySource ``` ## Hook context summary @@ -254,7 +255,7 @@ All hooks receive request lifecycle metadata. Common fields: ```text -request, url, headers, signal, attempt, maxAttempts, requestId, startedAt +request, url, headers, signal, attempt, maxAttempts, requestId, operationName?, startedAt ``` Additional fields: @@ -269,21 +270,25 @@ Request lifecycle order is: 1. auth 2. `beforeRequest` -3. fetch execution -4. response parsing -5. response validation, if configured -6. `afterResponse` on success +3. body serialization +4. fetch execution +5. response parsing +6. response validation, if configured +7. `afterResponse` on success + +Body serialization runs after `beforeRequest`, so a hook can still influence it — for example by setting `content-type` before a custom serializer would. Retry flow: 1. auth 2. `beforeRequest` -3. fetch execution -4. response parsing -5. response validation, if configured -6. retry decision -7. `onRetry` before the next attempt -8. next retry attempt +3. body serialization +4. fetch execution +5. response parsing +6. response validation, if configured +7. retry decision +8. `onRetry` before the next attempt +9. next retry attempt Failure flow: @@ -317,6 +322,21 @@ const client = createClient({ }); ``` +## Telemetry + +If you use hooks purely to export telemetry, `createTelemetryHooks` maps a `TelemetryExporter` onto this same hook mechanism. + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); +``` + +See **Extensibility** for the exporter interface. + ## Note Use hooks for infrastructure concerns instead of duplicating logic across your application. diff --git a/docs/client/v1/installation.md b/docs/client/v1/installation.md index f76790c..a7c5a69 100644 --- a/docs/client/v1/installation.md +++ b/docs/client/v1/installation.md @@ -18,6 +18,16 @@ yarn add @dfsync/client - Node.js `>= 20` +## Optional peer dependencies + +`zod` is an optional peer dependency, required only if you use the built-in zod validation adapter. + +```bash +npm install zod +``` + +The core package has no runtime dependencies. Installing `zod` is not needed for any other feature. + ## ESM / CJS The package ships with both import and require entry points, plus TypeScript types. @@ -33,3 +43,13 @@ import { createClient } from '@dfsync/client'; ```javascript const { createClient } = require('@dfsync/client'); ``` + +## Subpath exports + +Optional adapters ship as separate entry points, so they are only loaded when you import them. + +```ts +import { zodAdapter } from '@dfsync/client/adapters/zod'; +``` + +See **Response Handling** for how to use it. diff --git a/docs/client/v1/observability.md b/docs/client/v1/observability.md index 057ced2..cb9775d 100644 --- a/docs/client/v1/observability.md +++ b/docs/client/v1/observability.md @@ -5,6 +5,7 @@ Each request exposes: - **requestId** — stable identifier across retries +- **operationName** — optional human-readable operation label, when the request provides it - **attempt / maxAttempts** — retry progress - **startedAt / endedAt / durationMs** — timing information - **validation** — response validation metadata in `afterResponse`, when validation is configured @@ -42,3 +43,82 @@ This makes it easier to understand: - whether response validation ran and passed - how retries behaved - how long requests actually took + +## Grouping requests with operationName + +`requestId` identifies one request. `operationName` identifies the kind of request, which is what you usually want to aggregate on in metrics and traces. + +```ts +await client.get('/users', { + operationName: 'listUsers', +}); +``` + +Every lifecycle hook receives it, so a single hook can label all telemetry: + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: { + afterResponse({ operationName, durationMs }) { + console.log('http.client.duration', { + operation: operationName ?? 'unknown', + durationMs, + }); + }, + }, +}); +``` + +See **Create Client** for details. + +## Metadata on errors + +Lifecycle metadata is not limited to hooks. Every error thrown from the request lifecycle carries `requestId`, `attempt`, and `durationMs`, so you can correlate a failure at the call site without wiring an `onError` hook. + +```ts +import { DfsyncError } from '@dfsync/client'; + +try { + await client.get('/users'); +} catch (error) { + if (error instanceof DfsyncError) { + console.error(error.requestId, error.attempt, error.durationMs); + } +} +``` + +See **Errors** for the full list. + +## Telemetry exporters + +For a structured telemetry sink instead of ad-hoc hooks, implement a `TelemetryExporter` and map it onto the client with `createTelemetryHooks`. + +```ts +import { createClient, createTelemetryHooks } from '@dfsync/client'; +import type { TelemetryExporter } from '@dfsync/client'; + +const exporter: TelemetryExporter = { + onRequestStart(ctx) { + console.log('start', ctx.operationName, ctx.requestId); + }, + onRequestSuccess(ctx) { + console.log('done', ctx.operationName, ctx.durationMs); + }, + onRequestError(ctx) { + console.error('error', ctx.requestId, ctx.error); + }, + onRequestRetry(ctx) { + console.warn('retry', ctx.requestId, ctx.retryReason); + }, +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); +``` + +Exporter methods receive the same context objects as the corresponding hooks, so nothing new is exposed — it is the same metadata behind a stable interface. + +See **Extensibility** for the exporter contract. diff --git a/docs/client/v1/response-handling.md b/docs/client/v1/response-handling.md index 6c74081..a681610 100644 --- a/docs/client/v1/response-handling.md +++ b/docs/client/v1/response-handling.md @@ -6,7 +6,7 @@ Response handling has three steps: 1. parse the response body 2. throw `HttpError` for non-2xx responses -3. validate successful response data when `validateResponse` is configured +3. validate successful response data when validation is configured ## Response parsing @@ -20,7 +20,9 @@ Responses are parsed automatically: const user = await client.get('/users/1'); ``` -The generic type controls the TypeScript return type. Runtime validation is separate and only runs when you configure `validateResponse`. +The generic type controls the TypeScript return type. Runtime validation is separate and only runs when you configure `validateResponse` or `responseSchema`. + +You can replace the default parsing entirely with `parseResponse`. See **Serialization**. ## Failed HTTP responses @@ -43,6 +45,15 @@ try { ## Response validation +There are two ways to validate a successful response: + +- `validateResponse` — a boolean predicate, best for small inline checks +- `responseSchema` — a `safeParse`-style adapter, best for schema libraries such as zod + +Both run only after a successful HTTP response, and both fail with `ValidationError`. + +## validateResponse + Use `validateResponse` when a successful HTTP response still needs a runtime shape check before your application uses it. ```ts @@ -96,9 +107,121 @@ await client.get('/users/1', { Request-level `validateResponse` takes precedence over client-level `validateResponse`. +## Schema adapters + +For richer validation, `responseSchema` accepts a `safeParse`-style adapter that returns a `ValidationResult`. + +```ts +type ValidationResult = { + success: boolean; + data?: TData; + error?: unknown; +}; + +type ValidationAdapter = ( + data: unknown, +) => ValidationResult | Promise>; +``` + +The adapter receives the parsed response data. When `success` is `false`, the client throws `ValidationError` and exposes the adapter's `error` on `error.issues`. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema(data) { + const ok = typeof data === 'object' && data !== null && 'id' in data; + + return ok ? { success: true } : { success: false, error: 'missing id' }; + }, +}); +``` + +This is the contract that schema-library adapters build on. + +Adapters can be async, and `responseSchema` can also be set per request: + +```ts +await client.get('/users/1', { + responseSchema: (data) => ({ success: 'email' in (data as object) }), +}); +``` + +### Validation does not transform the response + +`ValidationResult.data` is reserved for future response transformation. In this version the original parsed data is returned even when an adapter reports transformed `data`. + +If you need transformation today, do it after the call returns, or use `parseResponse`. + +## Zod adapter + +A ready-made adapter for [zod](https://zod.dev) ships in a dedicated subpath. `zod` is an optional peer dependency — it is required only if you use this adapter, and it adds no runtime dependency to the core package. + +```bash +npm install zod +``` + +```ts +import { z } from 'zod'; +import { createClient } from '@dfsync/client'; +import { zodAdapter } from '@dfsync/client/adapters/zod'; + +const userSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: zodAdapter(userSchema), +}); + +const user = await client.get('/users/1'); +``` + +On failure, the zod error is available on `ValidationError.issues`: + +```ts +import { ValidationError } from '@dfsync/client'; + +try { + await client.get('/users/1'); +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.issues); + } +} +``` + +`zodAdapter` is typed structurally against `safeParse`, so it works across zod versions and with any other `safeParse`-compatible schema. + +## Validation precedence + +When more than one validator is configured, exactly one runs. Precedence is: + +1. request-level `responseSchema` +2. request-level `validateResponse` +3. client-level `responseSchema` +4. client-level `validateResponse` + +In other words: a request-level option beats a client-level one, and within the same level `responseSchema` beats `validateResponse`. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse: () => true, + responseSchema: zodAdapter(userSchema), // wins at client level +}); + +await client.get('/users/1', { + validateResponse: (data) => 'email' in (data as object), // wins overall +}); +``` + +If no validator is configured, validation does not run at all. + ## ValidationError -When validation returns `false`, the client throws `ValidationError`. +When a `validateResponse` predicate returns `false`, or a `responseSchema` adapter reports `success: false`, the client throws `ValidationError`. ```ts import { ValidationError } from '@dfsync/client'; @@ -118,8 +241,11 @@ try { - `code` -> `"VALIDATION_ERROR"` - `data` -> parsed response data - `response` -> original `Response` +- `issues` -> adapter-specific details, when validation ran through a `responseSchema` adapter + +Like every lifecycle error, it also carries `requestId`, `attempt`, and `durationMs`. See **Errors**. -Validation failures are not retried. +Validation failures are not retried, and no `shouldRetry` predicate can make them retryable. ## Hooks and validation diff --git a/docs/client/v1/retry.md b/docs/client/v1/retry.md index edcf8b7..b5a6e97 100644 --- a/docs/client/v1/retry.md +++ b/docs/client/v1/retry.md @@ -24,6 +24,38 @@ const client = createClient({ }); ``` +## Retry configuration + +```ts +type RetryConfig = { + attempts?: number; + backoff?: 'fixed' | 'exponential'; + baseDelayMs?: number; + retryOn?: ('network-error' | '5xx' | '429')[]; + retryMethods?: ('GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE')[]; + maxElapsedMs?: number; + jitter?: boolean; + shouldRetry?: RetryPredicate; +}; +``` + +Defaults: + +| Option | Default | +| -------------- | ----------------------------- | +| `attempts` | `0` | +| `backoff` | `'exponential'` | +| `baseDelayMs` | `300` | +| `retryOn` | `['network-error', '5xx']` | +| `retryMethods` | `['GET', 'PUT', 'DELETE']` | +| `jitter` | `false` | +| `maxElapsedMs` | not set — no budget | +| `shouldRetry` | not set — built-in rules only | + +`attempts` counts retries **after** the first attempt, so `attempts: 2` means up to three requests in total. + +Request-level `retry` replaces the client-level values for the options it specifies. + ## Retry-After support When a retryable response includes a `Retry-After` header, `@dfsync/client` uses that value before falling back to the configured backoff strategy. @@ -113,6 +145,104 @@ Retry delays: If `attempts` is `0` (default), no retries are performed and retry delays are ignored. +### Jitter + +Setting `jitter: true` applies full jitter to backoff delays: each delay is chosen uniformly between `0` and the computed backoff value. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 3, + backoff: 'exponential', + baseDelayMs: 300, + jitter: true, + }, +}); +``` + +Retry delays become: + +```bash +0–300ms +0–600ms +0–1200ms +``` + +This helps avoid retry storms when many clients fail at the same time and would otherwise retry in lockstep. + +Jitter is disabled by default, and it is never applied to server-directed `Retry-After` delays. + +## Retry budget + +`maxElapsedMs` caps the total time spent retrying, measured from the first attempt. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 5, + backoff: 'exponential', + baseDelayMs: 300, + maxElapsedMs: 2000, + }, +}); +``` + +Before each retry, the client checks whether waiting the next delay would push the elapsed time past the budget. If it would, the client stops retrying immediately and throws the last error instead of waiting. + +This means: + +- the budget is checked against the elapsed time **plus** the next planned delay +- the client never sleeps past the budget just to make one more attempt +- retries can end before `attempts` is exhausted +- `onRetry` does not run for the attempt that was cut off by the budget + +Use it to keep worst-case request latency bounded and predictable. + +## Custom retry conditions + +`shouldRetry` is an optional predicate that can further restrict retries. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 3, + shouldRetry({ error, attempt, maxAttempts, requestId, method }) { + return attempt < 1; // only ever retry once + }, + }, +}); +``` + +It receives: + +```ts +type RetryPredicateContext = { + error: Error; + attempt: number; + maxAttempts: number; + requestId: string; + method: RequestMethod; +}; +``` + +The predicate runs **only after** the built-in rules already decided the request is retryable. That ordering is deliberate: + +- returning `false` stops retries +- returning `true` keeps the built-in decision +- it can never make non-retryable failures retryable — validation errors and `4xx` responses stay non-retryable regardless of what the predicate returns + +Retry decisions are evaluated in this order: + +1. attempts remaining +2. method is in `retryMethods` +3. `POST` / `PATCH` require `idempotencyKey` +4. error type matches `retryOn` +5. `shouldRetry`, when configured +6. retry budget (`maxElapsedMs`) + ## Retry methods By default retries apply to: diff --git a/docs/client/v1/serialization.md b/docs/client/v1/serialization.md new file mode 100644 index 0000000..3ebdf2e --- /dev/null +++ b/docs/client/v1/serialization.md @@ -0,0 +1,183 @@ +# Serialization + +`@dfsync/client` serializes request bodies and parses responses with predictable defaults, and lets you replace either side of the pipeline when you need a different format. + +Use this when you integrate with a service that does not speak JSON — form-encoded APIs, custom envelopes, or text protocols. + +## Default behavior + +Request bodies: + +- object body → serialized with `JSON.stringify(...)`, and `content-type: application/json` is set only if you did not set it yourself +- string body → sent as-is, without forcing a `content-type` +- no body → nothing is serialized + +Responses: + +- `application/json` → parsed with `response.json()` +- other content types → returned as text +- `204 No Content` → `undefined` + +If you configure neither `serializeBody` nor `parseResponse`, this default behavior is preserved. + +## serializeBody + +`serializeBody` turns a request body into a `fetch`-compatible body. It is called only when a body is present. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + serializeBody(body) { + return { + body: new URLSearchParams(body as Record).toString(), + contentType: 'application/x-www-form-urlencoded', + }; + }, +}); + +await client.post('/form', { a: 1, b: 2 }); +``` + +The request is sent as: + +```http +content-type: application/x-www-form-urlencoded + +a=1&b=2 +``` + +### Content type precedence + +The returned `contentType` is optional, and it is applied only when no `content-type` header is already present. + +```ts +await client.post( + '/form', + { a: 1 }, + { + headers: { 'content-type': 'application/custom' }, + }, +); +``` + +Here the explicit header wins and the serializer's `contentType` is ignored. + +Because serialization runs after `beforeRequest` hooks, a `content-type` set by a hook also takes precedence over the serializer's `contentType`. + +## parseResponse + +`parseResponse` turns a `Response` into data. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + async parseResponse(response) { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }, +}); + +const user = await client.get('/users/1'); +``` + +### It runs for every response + +`parseResponse` runs for successful **and** failed responses, before the client classifies the result and before validation. That means a custom parser affects the whole pipeline: + +- `HttpError.data` contains the custom-parsed error body +- `validateResponse` and `responseSchema` receive the custom-parsed value + +```ts +import { HttpError } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + parseResponse: async (response) => { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }, +}); + +try { + await client.get('/users/999'); +} catch (error) { + if (error instanceof HttpError) { + console.log(error.data); + // { code: 'not_found' } parsed from `code=not_found` + } +} +``` + +### Custom parsers replace the default entirely + +When `parseResponse` is configured, the default content-type handling does not run. Your parser is responsible for every case it needs to support, including `204 No Content` and non-JSON payloads. + +## Serializer context + +Both options receive a lightweight context as the second argument: + +```ts +type SerializerContext = { + request: RequestConfig; + requestId: string; +}; +``` + +This is useful for logging or for branching on the request itself. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + parseResponse(response, { request, requestId }) { + console.log(requestId, request.path); + return response.text(); + }, +}); +``` + +## Request-level overrides + +Both options can be set on the client or per request. A request-level option takes precedence over the client-level one. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + parseResponse: () => 'client-level', +}); + +const result = await client.get('/thing', { + parseResponse: () => 'request-level', +}); +// 'request-level' +``` + +## Type reference + +```ts +type SerializerContext = { + request: RequestConfig; + requestId: string; +}; + +type SerializedBody = { + body: BodyInit; + contentType?: string; +}; + +type BodySerializer = ( + body: unknown, + ctx: SerializerContext, +) => SerializedBody | Promise; + +type ResponseParser = (response: Response, ctx: SerializerContext) => unknown | Promise; +``` + +## Error behavior + +If `parseResponse` throws, the failure is treated as a transport failure and normalized into `NetworkError`. Because network errors are retryable, a throwing parser can trigger retries when `retryOn` includes `network-error`. Keep parsers defensive, and prefer returning a value your validation layer can reject over throwing. + +## Related guides + +- See **Response Handling** for parsing defaults and response validation +- See **Create Client** for body and header behavior +- See **Errors** for error types and error metadata diff --git a/src/content/docsContent.ts b/src/content/docsContent.ts index 63b8bb8..56c0d4b 100644 --- a/src/content/docsContent.ts +++ b/src/content/docsContent.ts @@ -11,11 +11,13 @@ export const docsPackages = { { label: 'Installation', slug: 'installation' }, { label: 'Create Client', slug: 'create-client' }, { label: 'Response Handling', slug: 'response-handling' }, + { label: 'Serialization', slug: 'serialization' }, { label: 'Auth', slug: 'auth' }, { label: 'Hooks', slug: 'hooks' }, { label: 'Observability', slug: 'observability' }, { label: 'Retry', slug: 'retry' }, { label: 'Errors', slug: 'errors' }, + { label: 'Extensibility', slug: 'extensibility' }, { label: 'Examples', slug: 'examples' }, { label: 'API Reference', slug: 'api-reference' }, ], @@ -24,10 +26,12 @@ export const docsPackages = { installation: () => import('../../docs/client/v1/installation.md?raw'), 'create-client': () => import('../../docs/client/v1/create-client.md?raw'), 'response-handling': () => import('../../docs/client/v1/response-handling.md?raw'), + serialization: () => import('../../docs/client/v1/serialization.md?raw'), auth: () => import('../../docs/client/v1/auth.md?raw'), hooks: () => import('../../docs/client/v1/hooks.md?raw'), retry: () => import('../../docs/client/v1/retry.md?raw'), errors: () => import('../../docs/client/v1/errors.md?raw'), + extensibility: () => import('../../docs/client/v1/extensibility.md?raw'), examples: () => import('../../docs/client/v1/examples.md?raw'), observability: () => import('../../docs/client/v1/observability.md?raw'), 'api-reference': () => import('../../docs/client/v1/api-reference.md?raw'),