From 71490573e6ba2c8f88a8c5ba88d57d97fb4a8bcf Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:00:38 +0200 Subject: [PATCH 1/9] feat(client): add operationName request option Expose an optional, metadata-only operationName in the request context and all lifecycle hooks for logging, tracing, and telemetry. Field is optional and omitted from hook contexts when not provided, preserving backward compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/operation-name.md | 9 ++++ packages/client/README.md | 26 ++++++++++ packages/client/src/core/hook-context.ts | 3 ++ packages/client/src/types/hooks.ts | 1 + packages/client/src/types/request.ts | 1 + .../client/tests/integration/hooks.test.ts | 50 +++++++++++++++++++ .../client/tests/unit/hook-context.test.ts | 22 ++++++++ 7 files changed, 112 insertions(+) create mode 100644 .changeset/operation-name.md diff --git a/.changeset/operation-name.md b/.changeset/operation-name.md new file mode 100644 index 0000000..ad626e5 --- /dev/null +++ b/.changeset/operation-name.md @@ -0,0 +1,9 @@ +--- +'@dfsync/client': minor +--- + +Add optional `operationName` request option. It is part of the request context and is +exposed in all lifecycle hooks (`beforeRequest`, `afterResponse`, `onRetry`, `onError`), +making it useful for logging, tracing, and telemetry. It is metadata only and does not +affect request behavior. Backward compatible — the field is optional and omitted from +hook contexts when not provided. diff --git a/packages/client/README.md b/packages/client/README.md index 5e3e198..ef4c4bb 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -98,6 +98,7 @@ A request in `@dfsync/client` follows a predictable lifecycle: Each request is executed within a request context that contains: - `requestId` — unique identifier for the request +- `operationName` — optional human-readable operation label (when provided) - `attempt` — current retry attempt - `signal` — AbortSignal for cancellation - `startedAt` — request start timestamp @@ -130,6 +131,31 @@ await client.get('/users', { }); ``` +## Operation name + +You can attach an optional `operationName` to a request to label it with a stable, +human-readable name. It is part of the request context and is exposed in all lifecycle +hooks, which makes it useful for logging, tracing, and telemetry. + +```ts +await client.get('/users', { + operationName: 'listUsers', +}); +``` + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: { + afterResponse(ctx) { + console.log(ctx.operationName); // 'listUsers' + }, + }, +}); +``` + +`operationName` does not affect request behavior — it is metadata only. + ## Request cancellation Requests can be cancelled using `AbortSignal`: diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts index 5422cbb..1a8d3df 100644 --- a/packages/client/src/core/hook-context.ts +++ b/packages/client/src/core/hook-context.ts @@ -18,6 +18,9 @@ function createLifecycleContextBase(execution: ExecutionContext): LifecycleConte attempt: execution.attempt, maxAttempts: execution.maxAttempts, requestId: execution.requestId, + ...(execution.request.operationName !== undefined + ? { operationName: execution.request.operationName } + : {}), startedAt: execution.startedAt, ...(execution.endedAt !== undefined ? { endedAt: execution.endedAt } : {}), ...(execution.durationMs !== undefined ? { durationMs: execution.durationMs } : {}), diff --git a/packages/client/src/types/hooks.ts b/packages/client/src/types/hooks.ts index dfe2e37..b6a3c0b 100644 --- a/packages/client/src/types/hooks.ts +++ b/packages/client/src/types/hooks.ts @@ -14,6 +14,7 @@ type LifecycleContextBase = { attempt: number; maxAttempts: number; requestId: string; + operationName?: string; startedAt: number; endedAt?: number; durationMs?: number; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index 1a93cd9..ec01cd1 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -13,6 +13,7 @@ export type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; }; diff --git a/packages/client/tests/integration/hooks.test.ts b/packages/client/tests/integration/hooks.test.ts index 50b0fc4..e8bf3c3 100644 --- a/packages/client/tests/integration/hooks.test.ts +++ b/packages/client/tests/integration/hooks.test.ts @@ -367,4 +367,54 @@ describe('request hooks', () => { expect(firstCall).toBeDefined(); expect(firstCall![0].error).toBeInstanceOf(RequestAbortedError); }); + + it('exposes operationName in lifecycle hooks when provided', async () => { + const beforeRequest = vi.fn(); + const afterResponse = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: { beforeRequest, afterResponse }, + }); + + await client.get('/users', { + operationName: 'listUsers', + }); + + expect(getFirstMockCall(beforeRequest)[0]).toEqual( + expect.objectContaining({ operationName: 'listUsers' }), + ); + expect(getFirstMockCall(afterResponse)[0]).toEqual( + expect.objectContaining({ operationName: 'listUsers' }), + ); + }); + + it('does not expose operationName when not provided', async () => { + const beforeRequest = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: { beforeRequest }, + }); + + await client.get('/users'); + + expect(getFirstMockCall(beforeRequest)[0]).not.toHaveProperty('operationName'); + }); }); diff --git a/packages/client/tests/unit/hook-context.test.ts b/packages/client/tests/unit/hook-context.test.ts index 53050d4..0e56e25 100644 --- a/packages/client/tests/unit/hook-context.test.ts +++ b/packages/client/tests/unit/hook-context.test.ts @@ -90,6 +90,28 @@ describe('hook-context', () => { expect(ctx.signal).toBe(execution.request.signal); }); + it('exposes operationName when set on the request', () => { + const execution = createExecutionContextMock({ + request: { + method: 'GET', + path: '/users', + operationName: 'listUsers', + }, + }); + + const ctx = createBeforeRequestContext(execution); + + expect(ctx.operationName).toBe('listUsers'); + }); + + it('omits operationName when not set on the request', () => { + const execution = createExecutionContextMock(); + + const ctx = createBeforeRequestContext(execution); + + expect(ctx).not.toHaveProperty('operationName'); + }); + it('creates retry context with retry metadata', () => { const execution = createExecutionContextMock({ endedAt: 1700000000150, From 734863e7e40e233d6719cffb7ed2ffcc077686c1 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:08:57 +0200 Subject: [PATCH 2/9] feat(client): attach request context metadata to errors Enrich every DfsyncError thrown from the request lifecycle with optional requestId, attempt, and durationMs fields via a dedicated attachErrorContext helper, called after finalizeExecution and before onError hooks. Error constructors are left unchanged and the fields are optional, preserving backward compatibility. Response details remain available on HttpError and ValidationError. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/error-metadata.md | 11 +++ packages/client/README.md | 25 +++++++ .../client/src/core/attach-error-context.ts | 22 ++++++ packages/client/src/core/request.ts | 3 + packages/client/src/errors/base-error.ts | 9 +++ .../client/tests/integration/errors.test.ts | 57 ++++++++++++++++ .../tests/unit/attach-error-context.test.ts | 67 +++++++++++++++++++ 7 files changed, 194 insertions(+) create mode 100644 .changeset/error-metadata.md create mode 100644 packages/client/src/core/attach-error-context.ts create mode 100644 packages/client/tests/unit/attach-error-context.test.ts diff --git a/.changeset/error-metadata.md b/.changeset/error-metadata.md new file mode 100644 index 0000000..6696f4c --- /dev/null +++ b/.changeset/error-metadata.md @@ -0,0 +1,11 @@ +--- +'@dfsync/client': minor +--- + +Attach request context metadata to thrown errors. Every error that extends `DfsyncError` +(`HttpError`, `NetworkError`, `TimeoutError`, `ValidationError`, `RequestAbortedError`) now +carries optional `requestId`, `attempt`, and `durationMs` fields describing the request +that failed, so callers can correlate failures without lifecycle hooks. Response details +remain available on `HttpError` and `ValidationError` via their existing fields. Backward +compatible — the fields are optional, error constructors are unchanged, and errors created +outside a request lifecycle leave them undefined. diff --git a/packages/client/README.md b/packages/client/README.md index ef4c4bb..4f6cd98 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -187,6 +187,31 @@ Cancellation is treated differently from timeouts: This allows you to handle failures more precisely. +### Error metadata + +Every error thrown from the request lifecycle extends `DfsyncError` and carries request +context metadata, so you can 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); + } +} +``` + +Response details remain available on the errors that have them: `HttpError` exposes +`status`, `statusText`, `data`, and `response`, and `ValidationError` exposes `data` and +`response`. + ## Response validation You can validate successful responses before they are returned to the caller. diff --git a/packages/client/src/core/attach-error-context.ts b/packages/client/src/core/attach-error-context.ts new file mode 100644 index 0000000..a3c966e --- /dev/null +++ b/packages/client/src/core/attach-error-context.ts @@ -0,0 +1,22 @@ +import { DfsyncError } from '../errors/base-error'; +import type { ExecutionContext } from './execution-context'; + +/** + * Enriches a thrown error with request lifecycle metadata (`requestId`, + * `attempt`, `durationMs`) so callers can correlate failures without relying on + * lifecycle hooks. Only `DfsyncError` instances are enriched; other errors are + * left untouched. Response details are already exposed by `HttpError` and + * `ValidationError` via their `response` field. + */ +export function attachErrorContext(error: Error, execution: ExecutionContext): void { + if (!(error instanceof DfsyncError)) { + return; + } + + error.requestId = execution.requestId; + error.attempt = execution.attempt; + + if (execution.durationMs !== undefined) { + error.durationMs = execution.durationMs; + } +} diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index b64b29a..408cd55 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -7,6 +7,7 @@ import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; import { applyRequestMetadata } from './apply-request-metadata'; +import { attachErrorContext } from './attach-error-context'; import type { ExecutionContext } from './execution-context'; import { createExecutionContext } from './execution-context'; import { createRequestController } from './create-request-controller'; @@ -135,6 +136,7 @@ export async function request( if (!canRetry) { finalizeExecution(execution); + attachErrorContext(error, execution); await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); throw error; @@ -144,6 +146,7 @@ export async function request( if (!retryReason) { finalizeExecution(execution); + attachErrorContext(error, execution); await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); throw error; diff --git a/packages/client/src/errors/base-error.ts b/packages/client/src/errors/base-error.ts index f9a5bac..d2a87f0 100644 --- a/packages/client/src/errors/base-error.ts +++ b/packages/client/src/errors/base-error.ts @@ -2,6 +2,15 @@ export class DfsyncError extends Error { public readonly code: string; public override readonly cause?: unknown; + /** + * Request context metadata attached when the error is thrown from the + * request lifecycle. These fields are optional and may be undefined when the + * error is created outside of a request (e.g. constructed manually in tests). + */ + public requestId?: string; + public attempt?: number; + public durationMs?: number; + constructor(message: string, code: string, cause?: unknown) { super(message); diff --git a/packages/client/tests/integration/errors.test.ts b/packages/client/tests/integration/errors.test.ts index 1ccf2a3..3fbda0c 100644 --- a/packages/client/tests/integration/errors.test.ts +++ b/packages/client/tests/integration/errors.test.ts @@ -35,4 +35,61 @@ describe('client errors', () => { await expect(client.get('/users/1')).rejects.toBeInstanceOf(NetworkError); }); + + it('attaches request context metadata to thrown HttpError', async () => { + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ message: 'Not found' }), { + status: 404, + statusText: 'Not Found', + headers: { + 'content-type': 'application/json', + }, + }); + }); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + }); + + await expect(client.get('/users/999', { requestId: 'req_abc' })).rejects.toMatchObject({ + requestId: 'req_abc', + attempt: 0, + }); + + try { + await client.get('/users/999', { requestId: 'req_abc' }); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).durationMs).toBeTypeOf('number'); + // Response details remain available on HttpError. + expect((error as HttpError).status).toBe(404); + } + }); + + it('reports the final attempt number on the thrown error after retries', async () => { + const fetchMock = vi.fn(async () => { + throw new TypeError('fetch failed'); + }); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 2, + baseDelayMs: 0, + retryOn: ['network-error'], + retryMethods: ['GET'], + }, + }); + + try { + await client.get('/users/1', { requestId: 'req_retry' }); + expect.unreachable('expected NetworkError to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NetworkError); + expect((error as NetworkError).requestId).toBe('req_retry'); + expect((error as NetworkError).attempt).toBe(2); + } + }); }); diff --git a/packages/client/tests/unit/attach-error-context.test.ts b/packages/client/tests/unit/attach-error-context.test.ts new file mode 100644 index 0000000..c945ae6 --- /dev/null +++ b/packages/client/tests/unit/attach-error-context.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { attachErrorContext } from '../../src/core/attach-error-context'; +import { DfsyncError } from '../../src/errors/base-error'; +import { NetworkError } from '../../src/errors/network-error'; +import type { ExecutionContext } from '../../src/core/execution-context'; + +function createExecutionContextMock(overrides: Partial = {}): ExecutionContext { + return { + request: { + method: 'GET', + path: '/users', + }, + url: new URL('https://api.example.com/users'), + headers: {}, + attempt: 2, + maxAttempts: 3, + requestId: 'req_123', + startedAt: 1700000000000, + endedAt: 1700000000150, + durationMs: 150, + ...overrides, + }; +} + +describe('attachErrorContext', () => { + it('attaches requestId, attempt, and durationMs to DfsyncError instances', () => { + const error = new NetworkError('Network request failed'); + const execution = createExecutionContextMock(); + + attachErrorContext(error, execution); + + expect(error.requestId).toBe('req_123'); + expect(error.attempt).toBe(2); + expect(error.durationMs).toBe(150); + }); + + it('leaves durationMs undefined when execution has not finalized', () => { + const error = new DfsyncError('boom', 'TEST_ERROR'); + const execution: ExecutionContext = { + request: { method: 'GET', path: '/users' }, + url: new URL('https://api.example.com/users'), + headers: {}, + attempt: 2, + maxAttempts: 3, + requestId: 'req_123', + startedAt: 1700000000000, + }; + + attachErrorContext(error, execution); + + expect(error.requestId).toBe('req_123'); + expect(error.attempt).toBe(2); + expect(error.durationMs).toBeUndefined(); + }); + + it('does not modify non-DfsyncError errors', () => { + const error = new Error('plain'); + const execution = createExecutionContextMock(); + + attachErrorContext(error, execution); + + expect(error).not.toHaveProperty('requestId'); + expect(error).not.toHaveProperty('attempt'); + expect(error).not.toHaveProperty('durationMs'); + }); +}); From 7bbbf474cf8fef187e5bdee2b3c18e8a52981f42 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:28:02 +0200 Subject: [PATCH 3/9] feat(client): extend retry model with budget, jitter, and custom conditions Add three opt-in retry controls, all backward compatible: - retry.maxElapsedMs: overall retry budget; stops retrying when the next delay would exceed it instead of waiting. - retry.jitter: full jitter on backoff delays (never on Retry-After). - retry.shouldRetry: predicate consulted only after built-in rules allow a retry, so it can restrict but never widen retries. Introduces ResolvedRetryConfig for the merged runtime shape and a local failWithError helper to centralize the terminal error path. Defaults and existing Retry-After semantics are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/retry-model.md | 16 ++ examples/node-basic/retry.ts | 4 + packages/client/README.md | 53 ++++++ .../src/core/get-retry-delay-from-error.ts | 4 + packages/client/src/core/get-retry-delay.ts | 17 +- packages/client/src/core/request.ts | 47 ++++- .../client/src/core/resolve-runtime-config.ts | 24 ++- packages/client/src/core/should-retry.ts | 6 +- packages/client/src/index.ts | 2 + packages/client/src/types/config.ts | 25 +++ .../client/tests/integration/retry.test.ts | 168 ++++++++++++++++++ .../unit/get-retry-delay-from-error.test.ts | 46 +++++ .../client/tests/unit/get-retry-delay.test.ts | 55 +++++- .../client/tests/unit/should-retry.test.ts | 7 +- 14 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 .changeset/retry-model.md diff --git a/.changeset/retry-model.md b/.changeset/retry-model.md new file mode 100644 index 0000000..13832b9 --- /dev/null +++ b/.changeset/retry-model.md @@ -0,0 +1,16 @@ +--- +'@dfsync/client': minor +--- + +Extend the retry model with an overall retry budget, jitter, and custom retry conditions: + +- `retry.maxElapsedMs` — caps total time spent retrying (measured from the first attempt); + when the next delay would exceed the budget, the last error is thrown instead of waiting. +- `retry.jitter` — applies full jitter to backoff delays to spread retries and avoid retry + storms. Disabled by default and never applied to server-directed `Retry-After` delays. +- `retry.shouldRetry` — an optional predicate consulted only after the built-in rules + already allow a retry, so it can further restrict retries but never make non-retryable + errors (validation failures, `4xx`) retryable. + +Backward compatible — all new options are optional, defaults are unchanged, and existing +`Retry-After` handling and retry semantics are preserved. diff --git a/examples/node-basic/retry.ts b/examples/node-basic/retry.ts index c8263e2..5038313 100644 --- a/examples/node-basic/retry.ts +++ b/examples/node-basic/retry.ts @@ -6,6 +6,10 @@ const client = createClient({ attempts: 2, backoff: 'exponential', baseDelayMs: 300, + // Spread retries with full jitter to avoid retry storms. + jitter: true, + // Cap the total time spent retrying, measured from the first attempt. + maxElapsedMs: 5_000, }, }); diff --git a/packages/client/README.md b/packages/client/README.md index 4f6cd98..9f2ec4e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -262,6 +262,59 @@ try { Validation failures are not retried by default. +## Retry + +Retries are configured via the `retry` option on the client or per request. Requests are +retried only for transient failures: network errors, `5xx`, and `429` responses. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 3, // number of retries after the first attempt + backoff: 'exponential', // 'exponential' (default) or 'fixed' + baseDelayMs: 300, + retryOn: ['network-error', '5xx'], + retryMethods: ['GET', 'PUT', 'DELETE'], + jitter: true, // spread retries with full jitter (default: false) + maxElapsedMs: 10_000, // overall retry budget + shouldRetry(ctx) { + // additional gate; can only restrict retries, never widen them + return ctx.attempt < 2; + }, + }, +}); +``` + +### Jitter + +Setting `jitter: true` applies full jitter to backoff delays — each delay is chosen +uniformly between `0` and the computed backoff. This helps avoid retry storms when many +clients fail at the same time. Jitter is disabled by default and is never applied to +server-directed `Retry-After` delays. + +### Retry budget + +`maxElapsedMs` caps the total time spent retrying, measured from the first attempt. When +waiting the next delay would push the elapsed time past the budget, the client stops +retrying and throws the last error instead of waiting. This keeps worst-case latency +bounded and predictable. + +### Custom retry conditions + +`shouldRetry` is an optional predicate that runs only after the built-in rules already +decided a request is retryable. It can further restrict retries (return `false` to stop), +but it can never make non-retryable errors — such as validation failures or `4xx` +responses — retryable. + +```ts +retry: { + shouldRetry({ error, attempt, maxAttempts, requestId, method }) { + return attempt < 1; // only ever retry once + }, +} +``` + ## Idempotency keys For operations that may be retried safely, you can attach an idempotency key per request. diff --git a/packages/client/src/core/get-retry-delay-from-error.ts b/packages/client/src/core/get-retry-delay-from-error.ts index d1d5340..bfb9279 100644 --- a/packages/client/src/core/get-retry-delay-from-error.ts +++ b/packages/client/src/core/get-retry-delay-from-error.ts @@ -10,6 +10,7 @@ type GetRetryDelayFromErrorParams = { attempt: number; backoff: RetryBackoff; baseDelayMs: number; + jitter?: boolean; }; type RetryDelayResult = { @@ -22,12 +23,14 @@ export function getRetryDelayFromError({ attempt, backoff, baseDelayMs, + jitter, }: GetRetryDelayFromErrorParams): RetryDelayResult { if (error instanceof HttpError) { const retryAfter = error.response.headers.get('retry-after'); const retryAfterDelayMs = parseRetryAfter(retryAfter); if (retryAfterDelayMs !== undefined) { + // Retry-After is server-directed and must not be jittered. return { delayMs: retryAfterDelayMs, source: 'retry-after', @@ -40,6 +43,7 @@ export function getRetryDelayFromError({ attempt, backoff, baseDelayMs, + jitter, }), source: 'backoff', }; diff --git a/packages/client/src/core/get-retry-delay.ts b/packages/client/src/core/get-retry-delay.ts index 61ad12d..c4ef302 100644 --- a/packages/client/src/core/get-retry-delay.ts +++ b/packages/client/src/core/get-retry-delay.ts @@ -4,12 +4,21 @@ type GetRetryDelayParams = { attempt: number; backoff: RetryBackoff; baseDelayMs: number; + jitter?: boolean | undefined; }; -export function getRetryDelay({ attempt, backoff, baseDelayMs }: GetRetryDelayParams): number { - if (backoff === 'fixed') { - return baseDelayMs; +export function getRetryDelay({ + attempt, + backoff, + baseDelayMs, + jitter = false, +}: GetRetryDelayParams): number { + const baseDelay = backoff === 'fixed' ? baseDelayMs : baseDelayMs * 2 ** (attempt - 1); + + if (jitter) { + // Full jitter: uniformly pick a delay in [0, baseDelay] to spread retries. + return Math.random() * baseDelay; } - return baseDelayMs * 2 ** (attempt - 1); + return baseDelay; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 408cd55..e9d95d9 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -33,6 +33,18 @@ function finalizeExecution(execution: ExecutionContext): void { execution.durationMs = endedAt - execution.startedAt; } +async function failWithError( + clientConfig: ClientConfig, + execution: ExecutionContext, + error: Error, +): Promise { + finalizeExecution(execution); + attachErrorContext(error, execution); + + await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); + throw error; +} + export async function request( clientConfig: ClientConfig, requestConfig: RequestConfig, @@ -43,6 +55,7 @@ export async function request( let lastError: Error | undefined; const requestId = requestConfig.requestId ?? Math.random().toString(36).slice(2); + const overallStartedAt = Date.now(); for (let attempt = 0; attempt <= retry.attempts; attempt++) { const headers: HeadersMap = { @@ -135,21 +148,28 @@ export async function request( }); if (!canRetry) { - finalizeExecution(execution); - attachErrorContext(error, execution); - - await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); - throw error; + return failWithError(clientConfig, execution, error); } const retryReason = getRetryReason(error); if (!retryReason) { - finalizeExecution(execution); - attachErrorContext(error, execution); + return failWithError(clientConfig, execution, error); + } - await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); - throw error; + // Custom retry gate runs only after built-in rules already allow a retry, + // so it can restrict retries but never widen them. + if ( + retry.shouldRetry && + !retry.shouldRetry({ + error, + attempt: execution.attempt, + maxAttempts: execution.maxAttempts, + requestId: execution.requestId, + method: execution.request.method, + }) + ) { + return failWithError(clientConfig, execution, error); } const { delayMs, source } = getRetryDelayFromError({ @@ -157,8 +177,17 @@ export async function request( attempt: execution.attempt + 1, backoff: retry.backoff, baseDelayMs: retry.baseDelayMs, + jitter: retry.jitter, }); + // Stop retrying when waiting the next delay would exceed the retry budget. + if ( + retry.maxElapsedMs !== undefined && + Date.now() - overallStartedAt + delayMs > retry.maxElapsedMs + ) { + return failWithError(clientConfig, execution, error); + } + await runHooksSafely( clientConfig.hooks?.onRetry, createRetryContext({ diff --git a/packages/client/src/core/resolve-runtime-config.ts b/packages/client/src/core/resolve-runtime-config.ts index d069805..f7801d3 100644 --- a/packages/client/src/core/resolve-runtime-config.ts +++ b/packages/client/src/core/resolve-runtime-config.ts @@ -1,20 +1,36 @@ -import type { ClientConfig, RetryConfig } from '../types/config'; -import type { RequestConfig } from '../types/request'; +import type { ClientConfig, RetryBackoff, RetryCondition, RetryPredicate } from '../types/config'; +import type { RequestConfig, RequestMethod } from '../types/request'; const DEFAULT_TIMEOUT = 5000; -const DEFAULT_RETRY: Required = { +/** + * Retry config after client/request defaults are applied. `maxElapsedMs` and + * `shouldRetry` stay optional because they have no meaningful default value. + */ +export type ResolvedRetryConfig = { + attempts: number; + backoff: RetryBackoff; + baseDelayMs: number; + retryOn: RetryCondition[]; + retryMethods: RequestMethod[]; + jitter: boolean; + maxElapsedMs?: number; + shouldRetry?: RetryPredicate; +}; + +const DEFAULT_RETRY: ResolvedRetryConfig = { attempts: 0, backoff: 'exponential', baseDelayMs: 300, retryOn: ['network-error', '5xx'], retryMethods: ['GET', 'PUT', 'DELETE'], + jitter: false, }; export type ResolvedRuntimeConfig = { fetchImpl: typeof globalThis.fetch; timeout: number; - retry: Required; + retry: ResolvedRetryConfig; }; export function resolveRuntimeConfig( diff --git a/packages/client/src/core/should-retry.ts b/packages/client/src/core/should-retry.ts index ab45656..d0e133c 100644 --- a/packages/client/src/core/should-retry.ts +++ b/packages/client/src/core/should-retry.ts @@ -1,14 +1,12 @@ import { HttpError } from '../errors/http-error'; import { NetworkError } from '../errors/network-error'; -import type { RetryConfig } from '../types/config'; +import type { ResolvedRetryConfig } from './resolve-runtime-config'; import type { RequestMethod } from '../types/request'; -type NormalizedRetryConfig = Required; - type ShouldRetryParams = { attempt: number; method: RequestMethod; - retry: NormalizedRetryConfig; + retry: ResolvedRetryConfig; idempotencyKey?: string | undefined; error: unknown; }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e77b1d1..a633bdc 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -14,6 +14,8 @@ export type { RetryConfig, RetryCondition, RetryBackoff, + RetryPredicate, + RetryPredicateContext, ResponseValidator, } from './types/config'; export type { diff --git a/packages/client/src/types/config.ts b/packages/client/src/types/config.ts index b74af5d..abfb44f 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -10,12 +10,37 @@ export type ResponseValidator = ( export type RetryCondition = 'network-error' | '5xx' | '429'; export type RetryBackoff = 'fixed' | 'exponential'; +export type RetryPredicateContext = { + error: Error; + attempt: number; + maxAttempts: number; + requestId: string; + method: RequestMethod; +}; + +/** + * Additional, user-defined retry gate. It is consulted only after the built-in + * rules already decided a request is retryable, so it can further restrict + * retries but can never make non-retryable errors (e.g. validation failures or + * 4xx responses) retryable. + */ +export type RetryPredicate = (ctx: RetryPredicateContext) => boolean; + export type RetryConfig = { attempts?: number; backoff?: RetryBackoff; baseDelayMs?: number; retryOn?: RetryCondition[]; retryMethods?: RequestMethod[]; + /** + * Overall retry budget in milliseconds, measured from the first attempt. When + * the next delay would push the elapsed time past this budget, retrying stops + * and the last error is thrown instead of waiting. + */ + maxElapsedMs?: number; + /** Applies full jitter to backoff delays. Does not affect `Retry-After`. */ + jitter?: boolean; + shouldRetry?: RetryPredicate; }; export type ClientConfig = { diff --git a/packages/client/tests/integration/retry.test.ts b/packages/client/tests/integration/retry.test.ts index 9d3ae00..f646497 100644 --- a/packages/client/tests/integration/retry.test.ts +++ b/packages/client/tests/integration/retry.test.ts @@ -634,4 +634,172 @@ describe('client retry', () => { expect(beforeFirst.requestId).toBe(beforeSecond.requestId); expect(beforeFirst.requestId).toBe(retryCtx.requestId); }); + + it('stops retrying when the next delay would exceed the retry budget', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Service Unavailable' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }), + ); + + const onRetry = vi.fn(); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 5, + backoff: 'fixed', + baseDelayMs: 1000, + maxElapsedMs: 10, + retryOn: ['5xx'], + retryMethods: ['GET'], + }, + hooks: { onRetry }, + }); + + await expect(client.get('/users')).rejects.toBeInstanceOf(HttpError); + + // The first delay (1000ms) already exceeds the 10ms budget, so no retry runs. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('honors the retry budget across attempts and still succeeds within it', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Service Unavailable' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 3, + backoff: 'fixed', + baseDelayMs: 0, + maxElapsedMs: 10_000, + retryOn: ['5xx'], + retryMethods: ['GET'], + }, + }); + + await expect(client.get<{ ok: boolean }>('/users')).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry when a custom shouldRetry predicate returns false', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Service Unavailable' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }), + ); + + const shouldRetry = vi.fn().mockReturnValue(false); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 3, + baseDelayMs: 0, + retryOn: ['5xx'], + retryMethods: ['GET'], + shouldRetry, + }, + }); + + await expect(client.get('/users')).rejects.toBeInstanceOf(HttpError); + + // Built-in rules allow the retry, but the predicate vetoes it. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(shouldRetry).toHaveBeenCalledTimes(1); + expect(getFirstMockCall(shouldRetry)[0]).toEqual( + expect.objectContaining({ + attempt: 0, + maxAttempts: 4, + method: 'GET', + }), + ); + }); + + it('retries when a custom shouldRetry predicate returns true', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Service Unavailable' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const shouldRetry = vi.fn().mockReturnValue(true); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 3, + baseDelayMs: 0, + retryOn: ['5xx'], + retryMethods: ['GET'], + shouldRetry, + }, + }); + + await expect(client.get<{ ok: boolean }>('/users')).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not consult a custom shouldRetry predicate for non-retryable errors', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Bad Request' }), { + status: 400, + statusText: 'Bad Request', + headers: { 'content-type': 'application/json' }, + }), + ); + + const shouldRetry = vi.fn().mockReturnValue(true); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + retry: { + attempts: 3, + baseDelayMs: 0, + retryOn: ['5xx'], + retryMethods: ['GET'], + shouldRetry, + }, + }); + + await expect(client.get('/users')).rejects.toBeInstanceOf(HttpError); + + // 4xx is never retryable; the predicate cannot widen that. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(shouldRetry).not.toHaveBeenCalled(); + }); }); diff --git a/packages/client/tests/unit/get-retry-delay-from-error.test.ts b/packages/client/tests/unit/get-retry-delay-from-error.test.ts index f5ec53c..5b8f3fc 100644 --- a/packages/client/tests/unit/get-retry-delay-from-error.test.ts +++ b/packages/client/tests/unit/get-retry-delay-from-error.test.ts @@ -75,6 +75,52 @@ describe('getRetryDelayFromError', () => { }); }); + it('applies jitter to backoff delays', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + const error = new NetworkError('Network request failed'); + + const result = getRetryDelayFromError({ + error, + attempt: 2, + backoff: 'exponential', + baseDelayMs: 250, + jitter: true, + }); + + expect(result).toEqual({ + delayMs: 250, // 0.5 * (250 * 2^1) + source: 'backoff', + }); + }); + + it('does not jitter Retry-After delays', () => { + const randomSpy = vi.spyOn(Math, 'random'); + + const response = new Response('Too Many Requests', { + status: 429, + headers: { + 'retry-after': '5', + }, + }); + + const error = new HttpError(response); + + const result = getRetryDelayFromError({ + error, + attempt: 1, + backoff: 'exponential', + baseDelayMs: 200, + jitter: true, + }); + + expect(result).toEqual({ + delayMs: 5_000, + source: 'retry-after', + }); + expect(randomSpy).not.toHaveBeenCalled(); + }); + it('uses backoff for non-HttpError errors', () => { const error = new NetworkError('Network request failed'); diff --git a/packages/client/tests/unit/get-retry-delay.test.ts b/packages/client/tests/unit/get-retry-delay.test.ts index cce5fc5..dc713b9 100644 --- a/packages/client/tests/unit/get-retry-delay.test.ts +++ b/packages/client/tests/unit/get-retry-delay.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getRetryDelay } from '../../src/core/get-retry-delay'; describe('getRetryDelay', () => { @@ -45,4 +45,57 @@ describe('getRetryDelay', () => { }), ).toBe(1200); }); + + describe('jitter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('applies full jitter as a fraction of the computed backoff', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + expect( + getRetryDelay({ + attempt: 2, + backoff: 'exponential', + baseDelayMs: 300, + jitter: true, + }), + ).toBe(300); // 0.5 * (300 * 2^1) + }); + + it('keeps jittered delay within [0, computed backoff]', () => { + const computed = 1200; // attempt 3, base 300, exponential + + for (const random of [0, 0.25, 0.75, 0.999]) { + vi.spyOn(Math, 'random').mockReturnValue(random); + + const delay = getRetryDelay({ + attempt: 3, + backoff: 'exponential', + baseDelayMs: 300, + jitter: true, + }); + + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(computed); + vi.restoreAllMocks(); + } + }); + + it('does not apply jitter when disabled', () => { + const spy = vi.spyOn(Math, 'random'); + + expect( + getRetryDelay({ + attempt: 2, + backoff: 'exponential', + baseDelayMs: 300, + jitter: false, + }), + ).toBe(600); + + expect(spy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/client/tests/unit/should-retry.test.ts b/packages/client/tests/unit/should-retry.test.ts index fe0f7bc..518ed08 100644 --- a/packages/client/tests/unit/should-retry.test.ts +++ b/packages/client/tests/unit/should-retry.test.ts @@ -4,15 +4,16 @@ import { NetworkError } from '../../src/errors/network-error'; import { ValidationError } from '../../src/errors/validation-error'; import { RequestAbortedError } from '../../src/errors/request-aborted-error'; import { shouldRetry } from '../../src/core/should-retry'; -import type { RetryConfig } from '../../src/types/config'; +import type { ResolvedRetryConfig } from '../../src/core/resolve-runtime-config'; import type { RequestMethod } from '../../src/types/request'; -const defaultRetry: Required = { +const defaultRetry: ResolvedRetryConfig = { attempts: 2, backoff: 'exponential', baseDelayMs: 300, retryOn: ['network-error', '5xx'], retryMethods: ['GET', 'PUT', 'DELETE'], + jitter: false, }; function createHttpError(status: number, statusText = 'Error'): HttpError { @@ -40,7 +41,7 @@ function createParams( overrides: Partial<{ attempt: number; method: RequestMethod; - retry: Required; + retry: ResolvedRetryConfig; idempotencyKey?: string; error: unknown; }> = {}, From 5fb87984b329d6f323595f49805e468fff6f1a13 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:37:47 +0200 Subject: [PATCH 4/9] feat(client): add serializer and parser extensibility Add optional serializeBody(body, ctx) and parseResponse(response, ctx) hooks at client and request level. serializeBody produces a fetch body and an optional content-type (applied only when the caller has not set one). parseResponse runs for every response before classification and validation, so HttpError.data and validateResponse both see the parsed value. Default JSON behavior is preserved when neither is configured. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/serializer-parser.md | 17 ++ examples/node-basic/README.md | 1 + examples/node-basic/package.json | 3 +- examples/node-basic/serializer.ts | 34 ++++ packages/client/README.md | 40 ++++ packages/client/src/core/request.ts | 23 ++- packages/client/src/index.ts | 4 + packages/client/src/types/config.ts | 37 +++- packages/client/src/types/request.ts | 4 +- .../tests/integration/serializer.test.ts | 174 ++++++++++++++++++ 10 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 .changeset/serializer-parser.md create mode 100644 examples/node-basic/serializer.ts create mode 100644 packages/client/tests/integration/serializer.test.ts diff --git a/.changeset/serializer-parser.md b/.changeset/serializer-parser.md new file mode 100644 index 0000000..29629fa --- /dev/null +++ b/.changeset/serializer-parser.md @@ -0,0 +1,17 @@ +--- +'@dfsync/client': minor +--- + +Add serializer and parser extensibility. Two optional hooks let you override the default +JSON pipeline at the client or request level: + +- `serializeBody(body, ctx)` — turns a request body into a `fetch`-compatible body and may + suggest a `contentType` (applied only when the caller has not set one). Called only when + a body is present. +- `parseResponse(response, ctx)` — turns a response into data. It runs for every response + (successful and error) before classification and validation, so `HttpError.data` and + `validateResponse` both operate on the parsed value. + +Backward compatible — both options are optional and the default behavior (JSON for object +bodies, content-type-based parsing, `204` → `undefined`) is preserved when they are not +configured. Error handling and validation stages are unchanged. diff --git a/examples/node-basic/README.md b/examples/node-basic/README.md index 99c054b..bf07d16 100644 --- a/examples/node-basic/README.md +++ b/examples/node-basic/README.md @@ -16,4 +16,5 @@ pnpm example:bearer-auth pnpm example:hooks pnpm example:custom-auth pnpm example:retry +pnpm example:serializer ``` diff --git a/examples/node-basic/package.json b/examples/node-basic/package.json index 1890904..1c911f7 100644 --- a/examples/node-basic/package.json +++ b/examples/node-basic/package.json @@ -7,7 +7,8 @@ "example:bearer-auth": "tsx bearer-auth.ts", "example:hooks": "tsx hooks.ts", "example:custom-auth": "tsx custom-auth.ts", - "example:retry": "tsx retry.ts" + "example:retry": "tsx retry.ts", + "example:serializer": "tsx serializer.ts" }, "dependencies": { "@dfsync/client": "workspace:*" diff --git a/examples/node-basic/serializer.ts b/examples/node-basic/serializer.ts new file mode 100644 index 0000000..67599ab --- /dev/null +++ b/examples/node-basic/serializer.ts @@ -0,0 +1,34 @@ +import { createClient } from '@dfsync/client'; + +// A client that sends form-encoded bodies and parses form-encoded responses, +// instead of the default JSON behavior. +const client = createClient({ + baseUrl: 'https://httpbin.org', + serializeBody(body) { + return { + body: new URLSearchParams(body as Record).toString(), + contentType: 'application/x-www-form-urlencoded', + }; + }, + async parseResponse(response) { + const text = await response.text(); + + try { + return JSON.parse(text); + } catch { + return text; + } + }, +}); + +async function main() { + try { + const result = await client.post('/post', { name: 'Roman', role: 'backend' }); + + console.log('Response:', result); + } catch (error) { + console.error('Request failed:', error); + } +} + +main(); diff --git a/packages/client/README.md b/packages/client/README.md index 9f2ec4e..5d1b18e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -262,6 +262,46 @@ try { Validation failures are not retried by default. +## Custom serialization and parsing + +By default, `@dfsync/client` serializes object bodies as JSON and parses responses based on +their `content-type` (JSON or text, with `204` yielding `undefined`). You can override both +sides of the pipeline when you need a different format. + +`serializeBody` turns a request body into a `fetch`-compatible body. It is called only when +a body is present, and its optional `contentType` is applied only when the caller has not +already set one. + +```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', + }; + }, +}); +``` + +`parseResponse` turns a response into data. It runs for every response — successful and +error — before classification and validation, so `HttpError.data` and any configured +`validateResponse` both operate on the parsed value. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + async parseResponse(response) { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }, +}); +``` + +Both options can be set at the client level or overridden per request, and both receive a +lightweight context (`{ request, requestId }`). When not configured, the default JSON +behavior is preserved. + ## Retry Retries are configured via the `retry` option on the client or per request. Requests are diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index e9d95d9..e696646 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -2,7 +2,7 @@ import { HttpError } from '../errors/http-error'; import { NetworkError } from '../errors/network-error'; import { ValidationError } from '../errors/validation-error'; import type { HeadersMap } from '../types/common'; -import type { ClientConfig } from '../types/config'; +import type { ClientConfig, SerializerContext } from '../types/config'; import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; @@ -84,10 +84,25 @@ export async function request( await runHooks(clientConfig.hooks?.beforeRequest, createBeforeRequestContext(execution)); + const serializerContext: SerializerContext = { + request: execution.request, + requestId: execution.requestId, + }; + let body: BodyInit | undefined; if (execution.request.body !== undefined) { - if (typeof execution.request.body === 'string') { + const serializeBody = execution.request.serializeBody ?? clientConfig.serializeBody; + + if (serializeBody) { + const serialized = await serializeBody(execution.request.body, serializerContext); + body = serialized.body; + + if (serialized.contentType) { + execution.headers['content-type'] = + execution.headers['content-type'] ?? serialized.contentType; + } + } else if (typeof execution.request.body === 'string') { body = execution.request.body; } else { execution.headers['content-type'] = execution.headers['content-type'] ?? 'application/json'; @@ -115,7 +130,9 @@ export async function request( } response = await fetchImpl(execution.url.toString(), init); - data = await parseResponse(response); + + const parse = execution.request.parseResponse ?? clientConfig.parseResponse; + data = parse ? await parse(response, serializerContext) : await parseResponse(response); if (!response.ok) { throw new HttpError(response, data); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a633bdc..4bd9252 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,6 +17,10 @@ export type { RetryPredicate, RetryPredicateContext, ResponseValidator, + BodySerializer, + ResponseParser, + SerializedBody, + SerializerContext, } from './types/config'; export type { AfterResponseContext, diff --git a/packages/client/src/types/config.ts b/packages/client/src/types/config.ts index abfb44f..fc986cd 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -1,12 +1,45 @@ import type { HeadersMap } from './common'; import type { AuthConfig } from './auth'; import type { HooksConfig } from './hooks'; -import type { RequestMethod } from './request'; +import type { RequestConfig, RequestMethod } from './request'; export type ResponseValidator = ( data: TData, ) => boolean | void | Promise; +/** Lightweight context passed to custom serializers and parsers. */ +export type SerializerContext = { + request: RequestConfig; + requestId: string; +}; + +export type SerializedBody = { + body: BodyInit; + /** Content type to set when the caller has not provided one. */ + contentType?: string; +}; + +/** + * Serializes a request body into a `fetch`-compatible body. Called only when a + * body is present. When not configured, bodies are serialized as JSON (objects) + * or sent as-is (strings). + */ +export type BodySerializer = ( + body: unknown, + ctx: SerializerContext, +) => SerializedBody | Promise; + +/** + * Parses a response into data. Runs for every response (successful and error) + * before classification and validation, preserving the default pipeline stages. + * When not configured, JSON responses are parsed as JSON and everything else as + * text (with `204` yielding `undefined`). + */ +export type ResponseParser = ( + response: Response, + ctx: SerializerContext, +) => unknown | Promise; + export type RetryCondition = 'network-error' | '5xx' | '429'; export type RetryBackoff = 'fixed' | 'exponential'; @@ -52,4 +85,6 @@ export type ClientConfig = { retry?: RetryConfig; fetch?: typeof globalThis.fetch; validateResponse?: ResponseValidator; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index ec01cd1..c8b778f 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -1,5 +1,5 @@ import type { HeadersMap, QueryParams } from './common'; -import type { RetryConfig, ResponseValidator } from './config'; +import type { RetryConfig, ResponseValidator, BodySerializer, ResponseParser } from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -16,6 +16,8 @@ export type RequestConfig = { operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; export type RequestOptionsWithoutBody = Omit; diff --git a/packages/client/tests/integration/serializer.test.ts b/packages/client/tests/integration/serializer.test.ts new file mode 100644 index 0000000..89b7cc7 --- /dev/null +++ b/packages/client/tests/integration/serializer.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClient, HttpError, ValidationError } from '../../src'; +import { getFirstFetchInit, getFirstMockCall } from '../testUtils'; + +describe('serializer / parser extensibility', () => { + it('uses a custom body serializer and sets its content type', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const serializeBody = vi.fn().mockReturnValue({ + body: 'a=1&b=2', + contentType: 'application/x-www-form-urlencoded', + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + serializeBody, + }); + + await client.post('/form', { a: 1, b: 2 }); + + const init = getFirstFetchInit(fetchMock); + expect(init.body).toBe('a=1&b=2'); + expect((init.headers as Record)['content-type']).toBe( + 'application/x-www-form-urlencoded', + ); + + const [body, ctx] = getFirstMockCall(serializeBody); + expect(body).toEqual({ a: 1, b: 2 }); + expect(ctx).toEqual(expect.objectContaining({ requestId: expect.any(String) })); + }); + + it('does not overwrite a user-provided content-type', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + serializeBody: () => ({ body: 'raw', contentType: 'text/plain' }), + }); + + await client.post('/form', { a: 1 }, { headers: { 'content-type': 'application/custom' } }); + + const init = getFirstFetchInit(fetchMock); + expect((init.headers as Record)['content-type']).toBe('application/custom'); + }); + + it('keeps default JSON serialization when no serializer is configured', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + }); + + await client.post('/users', { name: 'Roman' }); + + const init = getFirstFetchInit(fetchMock); + expect(init.body).toBe(JSON.stringify({ name: 'Roman' })); + expect((init.headers as Record)['content-type']).toBe('application/json'); + }); + + it('uses a custom response parser for successful responses', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('id=user-1', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + + const parseResponse = vi.fn(async (response: Response, _ctx: unknown) => { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + parseResponse, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + expect(parseResponse).toHaveBeenCalledTimes(1); + const [, ctx] = getFirstMockCall(parseResponse); + expect(ctx).toEqual(expect.objectContaining({ requestId: expect.any(String) })); + }); + + it('uses the custom parser for error response bodies too', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('code=not_found', { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'text/plain' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + parseResponse: async (response) => { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }, + }); + + try { + await client.get('/users/999'); + expect.unreachable('expected HttpError'); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).data).toEqual({ code: 'not_found' }); + } + }); + + it('prefers request-level parser over client-level parser', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('value', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + parseResponse: () => 'client-level', + }); + + await expect( + client.get('/thing', { + parseResponse: () => 'request-level', + }), + ).resolves.toBe('request-level'); + }); + + it('runs validation against custom-parsed data', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('name=Roman', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + parseResponse: async (response) => { + const text = await response.text(); + return Object.fromEntries(new URLSearchParams(text)); + }, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).rejects.toBeInstanceOf(ValidationError); + }); +}); From 2c8582205120e2729acafb9cb043d4c57c773095 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:46:22 +0200 Subject: [PATCH 5/9] feat(client): add stable validation adapter API (responseSchema) Introduce a safeParse-style ValidationAdapter and a responseSchema option at client and request level. On failure the client throws ValidationError and surfaces adapter details on the new optional ValidationError.issues field. Precedence is explicit via resolveResponseValidator: request-level beats client-level, and within a level responseSchema beats validateResponse. Validation still runs only after successful responses and is never retried; the original parsed data is returned (no transformation in this version). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/validation-adapter-api.md | 17 ++ packages/client/README.md | 28 +++- packages/client/src/core/request.ts | 28 +++- .../src/core/resolve-response-validator.ts | 35 +++++ .../client/src/errors/validation-error.ts | 5 +- packages/client/src/index.ts | 2 + packages/client/src/types/config.ts | 22 +++ packages/client/src/types/request.ts | 9 +- .../integration/response-validation.test.ts | 146 ++++++++++++++++++ .../unit/resolve-response-validator.test.ts | 55 +++++++ .../tests/unit/validation-error.test.ts | 10 ++ 11 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 .changeset/validation-adapter-api.md create mode 100644 packages/client/src/core/resolve-response-validator.ts create mode 100644 packages/client/tests/unit/resolve-response-validator.test.ts diff --git a/.changeset/validation-adapter-api.md b/.changeset/validation-adapter-api.md new file mode 100644 index 0000000..81f768a --- /dev/null +++ b/.changeset/validation-adapter-api.md @@ -0,0 +1,17 @@ +--- +'@dfsync/client': minor +--- + +Add a stable validation adapter API via `responseSchema`. It accepts a `safeParse`-style +`ValidationAdapter` returning `{ success, data?, error? }`; when `success` is `false` the +client throws a `ValidationError` and surfaces the adapter's `error` on the new +`ValidationError.issues` field. Validation still runs only after successful responses, +after parsing, and never triggers retries. + +Precedence between `responseSchema` and the existing `validateResponse` predicate is +explicit: a request-level option beats a client-level one, and within the same level +`responseSchema` beats `validateResponse`. In this version validation does not replace the +response — the original parsed data is returned. + +Backward compatible — `responseSchema` and `ValidationError.issues` are optional and the +existing `validateResponse` behavior is unchanged. diff --git a/packages/client/README.md b/packages/client/README.md index 5d1b18e..48b139f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -209,8 +209,8 @@ try { ``` Response details remain available on the errors that have them: `HttpError` exposes -`status`, `statusText`, `data`, and `response`, and `ValidationError` exposes `data` and -`response`. +`status`, `statusText`, `data`, and `response`, and `ValidationError` exposes `data`, +`response`, and `issues` (adapter-specific validation details). ## Response validation @@ -262,6 +262,30 @@ try { Validation failures are not retried by default. +### Schema adapters (`responseSchema`) + +For richer validation, `responseSchema` accepts a `safeParse`-style adapter that returns a +`ValidationResult` (`{ success, data?, error? }`). When `success` is `false`, the client +throws a `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 schema-library adapters build on (see the zod adapter below). In this +version, validation does not replace the response — the original parsed data is returned +even when the adapter reports transformed `data`. + +When both `responseSchema` and `validateResponse` are configured, precedence is: +request-level beats client-level, and within the same level `responseSchema` beats +`validateResponse`. + ## Custom serialization and parsing By default, `@dfsync/client` serializes object bodies as JSON and parses responses based on diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index e696646..8da0b4e 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -21,6 +21,7 @@ import { getRetryDelayFromError } from './get-retry-delay-from-error'; import { getRetryReason } from './get-retry-reason'; import { normalizeError } from './normalize-error'; import { parseResponse } from './parse-response'; +import { resolveResponseValidator } from './resolve-response-validator'; import { resolveRuntimeConfig } from './resolve-runtime-config'; import { runHooks, runHooksSafely } from './run-hooks'; import { shouldRetry } from './should-retry'; @@ -138,18 +139,31 @@ export async function request( throw new HttpError(response, data); } - const validateResponse = execution.request.validateResponse ?? clientConfig.validateResponse; - - if (validateResponse) { - const validationResult = await validateResponse(data); + // Effective validator: a request-level option beats a client-level one, + // and within the same level a `responseSchema` adapter beats a + // `validateResponse` predicate. + const validator = resolveResponseValidator(clientConfig, execution.request); + + if (validator) { + let passed: boolean; + let issues: unknown; + + if ('schema' in validator) { + const result = await validator.schema(data); + passed = result.success; + issues = result.error; + } else { + const result = await validator.predicate(data); + passed = result !== false; + } execution.validation = { enabled: true, - passed: validationResult !== false, + passed, }; - if (validationResult === false) { - throw new ValidationError(response, data); + if (!passed) { + throw new ValidationError(response, data, issues); } } } catch (rawError) { diff --git a/packages/client/src/core/resolve-response-validator.ts b/packages/client/src/core/resolve-response-validator.ts new file mode 100644 index 0000000..b9115a5 --- /dev/null +++ b/packages/client/src/core/resolve-response-validator.ts @@ -0,0 +1,35 @@ +import type { ClientConfig, ResponseValidator, ValidationAdapter } from '../types/config'; +import type { RequestConfig } from '../types/request'; + +export type ResolvedResponseValidator = + | { schema: ValidationAdapter } + | { predicate: ResponseValidator }; + +/** + * Picks the effective response validator. A request-level option takes + * precedence over a client-level one, and within the same level a + * `responseSchema` adapter takes precedence over a `validateResponse` predicate. + * Returns `undefined` when no validation is configured. + */ +export function resolveResponseValidator( + clientConfig: ClientConfig, + request: RequestConfig, +): ResolvedResponseValidator | undefined { + if (request.responseSchema) { + return { schema: request.responseSchema }; + } + + if (request.validateResponse) { + return { predicate: request.validateResponse }; + } + + if (clientConfig.responseSchema) { + return { schema: clientConfig.responseSchema }; + } + + if (clientConfig.validateResponse) { + return { predicate: clientConfig.validateResponse }; + } + + return undefined; +} diff --git a/packages/client/src/errors/validation-error.ts b/packages/client/src/errors/validation-error.ts index 517c88a..e5a9d1b 100644 --- a/packages/client/src/errors/validation-error.ts +++ b/packages/client/src/errors/validation-error.ts @@ -3,12 +3,15 @@ import { DfsyncError } from './base-error'; export class ValidationError extends DfsyncError { public readonly data: unknown; public readonly response: Response; + /** Adapter-specific validation details (e.g. schema issues), when available. */ + public readonly issues?: unknown; - constructor(response: Response, data: unknown) { + constructor(response: Response, data: unknown, issues?: unknown) { super('Response validation failed', 'VALIDATION_ERROR'); this.name = 'ValidationError'; this.response = response; this.data = data; + this.issues = issues; } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4bd9252..c775b68 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,6 +17,8 @@ export type { RetryPredicate, RetryPredicateContext, ResponseValidator, + ValidationAdapter, + ValidationResult, BodySerializer, ResponseParser, SerializedBody, diff --git a/packages/client/src/types/config.ts b/packages/client/src/types/config.ts index fc986cd..6a54039 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -7,6 +7,27 @@ export type ResponseValidator = ( data: TData, ) => boolean | void | Promise; +/** + * Result returned by a validation adapter. `success` decides whether validation + * passes. `data` is reserved for future response transformation and is not used + * to replace the response in this version. `error` carries adapter-specific + * details (e.g. schema issues) and is surfaced on the thrown `ValidationError`. + */ +export type ValidationResult = { + success: boolean; + data?: TData; + error?: unknown; +}; + +/** + * A `safeParse`-style validation adapter. It receives the parsed response data + * and returns a `ValidationResult`. Adapters are lightweight wrappers around a + * schema library and must not introduce a runtime dependency into the core. + */ +export type ValidationAdapter = ( + data: unknown, +) => ValidationResult | Promise>; + /** Lightweight context passed to custom serializers and parsers. */ export type SerializerContext = { request: RequestConfig; @@ -85,6 +106,7 @@ export type ClientConfig = { retry?: RetryConfig; fetch?: typeof globalThis.fetch; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; serializeBody?: BodySerializer; parseResponse?: ResponseParser; }; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index c8b778f..2b167b4 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -1,5 +1,11 @@ import type { HeadersMap, QueryParams } from './common'; -import type { RetryConfig, ResponseValidator, BodySerializer, ResponseParser } from './config'; +import type { + RetryConfig, + ResponseValidator, + ValidationAdapter, + BodySerializer, + ResponseParser, +} from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -16,6 +22,7 @@ export type RequestConfig = { operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; serializeBody?: BodySerializer; parseResponse?: ResponseParser; }; diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts index 7dd142f..0fd14ca 100644 --- a/packages/client/tests/integration/response-validation.test.ts +++ b/packages/client/tests/integration/response-validation.test.ts @@ -184,4 +184,150 @@ describe('response validation', () => { const ctx = getFirstMockCall(afterResponse); expect(ctx).not.toHaveProperty('validation'); }); + + it('returns data when a responseSchema adapter succeeds', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + responseSchema(data) { + const ok = typeof data === 'object' && data !== null && 'id' in data; + return ok ? { success: true, data } : { success: false }; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); + + it('throws ValidationError with issues when a responseSchema adapter fails', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'Roman' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + responseSchema() { + return { success: false, error: [{ path: 'id', message: 'Required' }] }; + }, + }); + + try { + await client.get('/users/1'); + expect.unreachable('expected ValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).issues).toEqual([{ path: 'id', message: 'Required' }]); + } + }); + + it('does not retry when a responseSchema adapter fails', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'Roman' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + retry: { + attempts: 3, + retryOn: ['network-error', '5xx', '429'], + retryMethods: ['GET'], + }, + responseSchema() { + return { success: false }; + }, + }); + + await expect(client.get('/users/1')).rejects.toBeInstanceOf(ValidationError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('prefers responseSchema over validateResponse at the client level', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const validateResponse = vi.fn().mockReturnValue(false); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + validateResponse, + responseSchema() { + return { success: true }; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + expect(validateResponse).not.toHaveBeenCalled(); + }); + + it('lets a request-level validateResponse override a client-level responseSchema', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + responseSchema() { + return { success: false }; + }, + }); + + await expect( + client.get('/users/1', { + validateResponse() { + return true; + }, + }), + ).resolves.toEqual({ id: 'user-1' }); + }); + + it('exposes validation metadata in afterResponse for a passing responseSchema', async () => { + const afterResponse = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'user-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: { afterResponse }, + responseSchema() { + return { success: true }; + }, + }); + + await client.get('/users/1'); + + expect(afterResponse).toHaveBeenCalledWith( + expect.objectContaining({ + validation: { enabled: true, passed: true }, + }), + ); + }); }); diff --git a/packages/client/tests/unit/resolve-response-validator.test.ts b/packages/client/tests/unit/resolve-response-validator.test.ts new file mode 100644 index 0000000..560c710 --- /dev/null +++ b/packages/client/tests/unit/resolve-response-validator.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveResponseValidator } from '../../src/core/resolve-response-validator'; +import type { ClientConfig, ValidationAdapter, ResponseValidator } from '../../src/types/config'; +import type { RequestConfig } from '../../src/types/request'; + +const baseClient: ClientConfig = { baseUrl: 'https://api.example.com' }; +const baseRequest: RequestConfig = { method: 'GET', path: '/users' }; + +const schemaA: ValidationAdapter = () => ({ success: true }); +const schemaB: ValidationAdapter = () => ({ success: true }); +const predicateA: ResponseValidator = () => true; +const predicateB: ResponseValidator = () => true; + +describe('resolveResponseValidator', () => { + it('returns undefined when nothing is configured', () => { + expect(resolveResponseValidator(baseClient, baseRequest)).toBeUndefined(); + }); + + it('prefers request-level schema over everything', () => { + const result = resolveResponseValidator( + { ...baseClient, responseSchema: schemaB, validateResponse: predicateB }, + { ...baseRequest, responseSchema: schemaA, validateResponse: predicateA }, + ); + + expect(result).toEqual({ schema: schemaA }); + }); + + it('prefers request-level predicate over client-level options', () => { + const result = resolveResponseValidator( + { ...baseClient, responseSchema: schemaB, validateResponse: predicateB }, + { ...baseRequest, validateResponse: predicateA }, + ); + + expect(result).toEqual({ predicate: predicateA }); + }); + + it('prefers client-level schema over client-level predicate', () => { + const result = resolveResponseValidator( + { ...baseClient, responseSchema: schemaB, validateResponse: predicateB }, + baseRequest, + ); + + expect(result).toEqual({ schema: schemaB }); + }); + + it('falls back to client-level predicate', () => { + const result = resolveResponseValidator( + { ...baseClient, validateResponse: predicateB }, + baseRequest, + ); + + expect(result).toEqual({ predicate: predicateB }); + }); +}); diff --git a/packages/client/tests/unit/validation-error.test.ts b/packages/client/tests/unit/validation-error.test.ts index cf58db0..085063d 100644 --- a/packages/client/tests/unit/validation-error.test.ts +++ b/packages/client/tests/unit/validation-error.test.ts @@ -18,5 +18,15 @@ describe('ValidationError', () => { expect(error.message).toBe('Response validation failed'); expect(error.response).toBe(response); expect(error.data).toBe(data); + expect(error.issues).toBeUndefined(); + }); + + it('stores adapter issues when provided', () => { + const response = new Response(null, { status: 200, statusText: 'OK' }); + const issues = [{ path: 'id', message: 'Required' }]; + + const error = new ValidationError(response, { name: 'Roman' }, issues); + + expect(error.issues).toBe(issues); }); }); From fe4a05bf7995e7a24c5267ef39a66793f7c2b357 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 13:54:24 +0200 Subject: [PATCH 6/9] feat(client): add zod validation adapter as a subpath export Ship zodAdapter(schema) from @dfsync/client/adapters/zod as a lightweight responseSchema wrapper. zod is an optional peer dependency; the adapter is typed structurally against safeParse so it adds no runtime dependency to the core and works across zod versions. Adds a second tsup entry and the exports map entry for the subpath. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/zod-adapter.md | 9 +++ packages/client/README.md | 23 +++++++ packages/client/package.json | 16 ++++- packages/client/src/adapters/zod.ts | 36 +++++++++++ .../tests/integration/zod-adapter.test.ts | 60 +++++++++++++++++++ packages/client/tsup.config.ts | 2 +- pnpm-lock.yaml | 8 +++ 7 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 .changeset/zod-adapter.md create mode 100644 packages/client/src/adapters/zod.ts create mode 100644 packages/client/tests/integration/zod-adapter.test.ts diff --git a/.changeset/zod-adapter.md b/.changeset/zod-adapter.md new file mode 100644 index 0000000..ff39733 --- /dev/null +++ b/.changeset/zod-adapter.md @@ -0,0 +1,9 @@ +--- +'@dfsync/client': minor +--- + +Add a zod validation adapter available from the `@dfsync/client/adapters/zod` subpath. +`zodAdapter(schema)` wraps a zod schema into a `responseSchema` `ValidationAdapter`; on +failure the zod error is surfaced on `ValidationError.issues`. `zod` is an optional peer +dependency and the adapter adds no runtime dependency to the core package — it is typed +structurally against `safeParse`, so it works across zod versions. diff --git a/packages/client/README.md b/packages/client/README.md index 48b139f..66d5d4f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -286,6 +286,29 @@ When both `responseSchema` and `validateResponse` are configured, precedence is: request-level beats client-level, and within the same level `responseSchema` beats `validateResponse`. +### Zod adapter + +A ready-made adapter for [zod](https://zod.dev) is available from a dedicated subpath. +`zod` is an optional peer dependency — it is only required 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 client = createClient({ + baseUrl: 'https://api.example.com', + responseSchema: zodAdapter(z.object({ id: z.string() })), +}); + +// On validation failure, the zod error is available on `error.issues`. +``` + ## Custom serialization and parsing By default, `@dfsync/client` serializes object bodies as JSON and parses responses based on diff --git a/packages/client/package.json b/packages/client/package.json index 21ca556..fcd9bea 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,6 +11,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./adapters/zod": { + "types": "./dist/adapters/zod.d.ts", + "import": "./dist/adapters/zod.js", + "require": "./dist/adapters/zod.cjs" } }, "files": [ @@ -56,11 +61,20 @@ "lint:fix": "eslint . --fix", "prepublishOnly": "pnpm run typecheck && pnpm run test && npm pack --dry-run" }, + "peerDependencies": { + "zod": "^3.23.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, "devDependencies": { "@types/node": "^22.13.10", "@vitest/coverage-v8": "^3.0.8", "tsup": "^8.3.6", "typescript": "^5.7.3", - "vitest": "^3.0.8" + "vitest": "^3.0.8", + "zod": "^3.23.0" } } diff --git a/packages/client/src/adapters/zod.ts b/packages/client/src/adapters/zod.ts new file mode 100644 index 0000000..d1138c2 --- /dev/null +++ b/packages/client/src/adapters/zod.ts @@ -0,0 +1,36 @@ +import type { ValidationAdapter } from '../types/config'; + +/** + * Minimal structural shape of a zod schema's `safeParse`. Typing it structurally + * keeps the adapter free of a build-time dependency on zod and compatible across + * zod versions (and any other `safeParse`-compatible schema). + */ +export type ZodSafeParseSchema = { + safeParse(data: unknown): { success: true; data: T } | { success: false; error: unknown }; +}; + +/** + * Wraps a zod schema into a `@dfsync/client` `ValidationAdapter` for use as + * `responseSchema`. On failure, the zod error is surfaced on + * `ValidationError.issues`. + * + * @example + * 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() })), + * }); + */ +export function zodAdapter(schema: ZodSafeParseSchema): ValidationAdapter { + return (data) => { + const result = schema.safeParse(data); + + if (result.success) { + return { success: true, data: result.data }; + } + + return { success: false, error: result.error }; + }; +} diff --git a/packages/client/tests/integration/zod-adapter.test.ts b/packages/client/tests/integration/zod-adapter.test.ts new file mode 100644 index 0000000..d5a94da --- /dev/null +++ b/packages/client/tests/integration/zod-adapter.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { createClient, ValidationError } from '../../src'; +import { zodAdapter } from '../../src/adapters/zod'; + +const userSchema = z.object({ id: z.string() }); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('zodAdapter', () => { + it('produces a passing ValidationResult for valid data', async () => { + const adapter = zodAdapter(userSchema); + + expect(await adapter({ id: 'user-1' })).toEqual({ success: true, data: { id: 'user-1' } }); + }); + + it('produces a failing ValidationResult with the zod error', async () => { + const adapter = zodAdapter(userSchema); + const result = await adapter({ name: 'Roman' }); + + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(z.ZodError); + }); + + it('works as a responseSchema and returns data on success', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 'user-1' })); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + responseSchema: zodAdapter(userSchema), + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); + + it('throws ValidationError with zod issues on failure', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ name: 'Roman' })); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + responseSchema: zodAdapter(userSchema), + }); + + try { + await client.get('/users/1'); + expect.unreachable('expected ValidationError'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).issues).toBeInstanceOf(z.ZodError); + } + }); +}); diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts index 5a70966..48d1404 100644 --- a/packages/client/tsup.config.ts +++ b/packages/client/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/adapters/zod.ts'], format: ['esm', 'cjs'], dts: true, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d6103..dd9fba5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: vitest: specifier: ^3.0.8 version: 3.2.4(@types/node@22.19.15)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^3.23.0 + version: 3.25.76 packages: @@ -1749,6 +1752,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@ampproject/remapping@2.3.0': @@ -3417,3 +3423,5 @@ snapshots: yaml@2.8.2: {} yocto-queue@0.1.0: {} + + zod@3.25.76: {} From c56a237600d8bd8ac327883efb251a3f18bf109d Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 14:02:00 +0200 Subject: [PATCH 7/9] feat(client): add stable public extension interfaces Formalize the auth provider contract as AuthProvider and export AuthContext. Add a TelemetryExporter interface plus createTelemetryHooks(exporter), which maps an exporter onto a standard HooksConfig so telemetry reuses the existing hook mechanism without a new runtime path. Combined with the already-exported ValidationAdapter and RetryPredicate, this is the minimal, version-stable extension surface. Additive and backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/extension-interfaces.md | 10 ++ packages/client/README.md | 43 ++++++++ packages/client/src/core/telemetry.ts | 51 ++++++++++ packages/client/src/index.ts | 4 +- packages/client/src/types/auth.ts | 9 +- .../tests/integration/telemetry.test.ts | 97 +++++++++++++++++++ packages/client/tests/unit/telemetry.test.ts | 36 +++++++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 .changeset/extension-interfaces.md create mode 100644 packages/client/src/core/telemetry.ts create mode 100644 packages/client/tests/integration/telemetry.test.ts create mode 100644 packages/client/tests/unit/telemetry.test.ts diff --git a/.changeset/extension-interfaces.md b/.changeset/extension-interfaces.md new file mode 100644 index 0000000..cd72995 --- /dev/null +++ b/.changeset/extension-interfaces.md @@ -0,0 +1,10 @@ +--- +'@dfsync/client': minor +--- + +Add stable public extension interfaces. Formalize the auth provider contract as +`AuthProvider` (and export `AuthContext`), and add a `TelemetryExporter` interface with a +`createTelemetryHooks(exporter)` helper that maps an exporter onto a standard `HooksConfig` +— no new runtime path in the core. Together with the already-exported `ValidationAdapter` +(validation adapter interface) and `RetryPredicate` (retry policy interface), these form +the minimal, version-stable extension surface. All additive and backward compatible. diff --git a/packages/client/README.md b/packages/client/README.md index 66d5d4f..ddfe2dc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -511,6 +511,49 @@ This makes it easier to understand: - how retries behaved - how long requests actually took +## Extension interfaces + +`@dfsync/client` exposes a small, stable set of interfaces for extending the client. They +are intentionally minimal and built on the existing request lifecycle. + +- **Auth provider** — `AuthProvider` (`(ctx: AuthContext) => void | Promise`) is the + contract behind `{ type: 'custom' }` auth. It applies authentication by mutating the + request headers or URL. +- **Validation adapter** — `ValidationAdapter` is the `safeParse`-style contract used by + `responseSchema` (see [Schema adapters](#schema-adapters-responseschema)). +- **Retry policy** — `RetryPredicate` (`retry.shouldRetry`) is the contract for custom + retry conditions (see [Custom retry conditions](#custom-retry-conditions)). +- **Telemetry exporter** — `TelemetryExporter` is a telemetry sink that observes the + request lifecycle. Map it 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.durationMs); + }, + onRequestError(ctx) { + console.error('error', ctx.error); + }, + onRequestRetry(ctx) { + console.warn('retry', ctx.retryReason); + }, +}; + +const client = createClient({ + baseUrl: 'https://api.example.com', + hooks: createTelemetryHooks(exporter), +}); +``` + +`createTelemetryHooks` returns a standard `HooksConfig`, so telemetry uses the same +lifecycle mechanism as regular hooks — no separate runtime path. + ## Roadmap See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md) diff --git a/packages/client/src/core/telemetry.ts b/packages/client/src/core/telemetry.ts new file mode 100644 index 0000000..da56c68 --- /dev/null +++ b/packages/client/src/core/telemetry.ts @@ -0,0 +1,51 @@ +import type { + AfterResponseContext, + BeforeRequestContext, + ErrorContext, + HooksConfig, + RetryContext, +} from '../types/hooks'; + +/** + * Stable telemetry exporter interface. An exporter is a telemetry sink that + * observes the request lifecycle. Each method receives the same context objects + * as the corresponding lifecycle hooks, so no internal state is exposed. + * + * Exporters do not run themselves — map an exporter onto the client's `hooks` + * with `createTelemetryHooks`. This keeps telemetry on the existing hook + * mechanism instead of introducing a separate runtime path. + */ +export type TelemetryExporter = { + onRequestStart?(ctx: BeforeRequestContext): void | Promise; + onRequestSuccess?(ctx: AfterResponseContext): void | Promise; + onRequestError?(ctx: ErrorContext): void | Promise; + onRequestRetry?(ctx: RetryContext): void | Promise; +}; + +/** + * Maps a `TelemetryExporter` onto a `HooksConfig` so it can be passed to + * `createClient({ hooks })`. Only the methods the exporter defines are wired. + */ +export function createTelemetryHooks(exporter: TelemetryExporter): HooksConfig { + const { onRequestStart, onRequestSuccess, onRequestError, onRequestRetry } = exporter; + + const hooks: HooksConfig = {}; + + if (onRequestStart) { + hooks.beforeRequest = onRequestStart; + } + + if (onRequestSuccess) { + hooks.afterResponse = onRequestSuccess; + } + + if (onRequestError) { + hooks.onError = onRequestError; + } + + if (onRequestRetry) { + hooks.onRetry = onRequestRetry; + } + + return hooks; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c775b68..4ec67c4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,5 @@ export { createClient } from './core/create-client'; +export { createTelemetryHooks } from './core/telemetry'; export { DfsyncError } from './errors/base-error'; export { HttpError } from './errors/http-error'; @@ -7,7 +8,8 @@ export { TimeoutError } from './errors/timeout-error'; export { ValidationError } from './errors/validation-error'; export { RequestAbortedError } from './errors/request-aborted-error'; -export type { AuthConfig } from './types/auth'; +export type { AuthConfig, AuthContext, AuthProvider } from './types/auth'; +export type { TelemetryExporter } from './core/telemetry'; export type { Client } from './types/client'; export type { ClientConfig, diff --git a/packages/client/src/types/auth.ts b/packages/client/src/types/auth.ts index 1f83b7e..8377684 100644 --- a/packages/client/src/types/auth.ts +++ b/packages/client/src/types/auth.ts @@ -7,6 +7,13 @@ export type AuthContext = { headers: HeadersMap; }; +/** + * Stable auth provider interface. An auth provider applies authentication to a + * request by mutating its headers or URL through the provided `AuthContext`. + * This is the contract behind `{ type: 'custom' }` auth. + */ +export type AuthProvider = (ctx: AuthContext) => void | Promise; + export type AuthValueResolver = string | (() => string | Promise); export type BearerAuthConfig = { @@ -24,7 +31,7 @@ export type ApiKeyAuthConfig = { export type CustomAuthConfig = { type: 'custom'; - apply: (ctx: AuthContext) => void | Promise; + apply: AuthProvider; }; export type AuthConfig = BearerAuthConfig | ApiKeyAuthConfig | CustomAuthConfig; diff --git a/packages/client/tests/integration/telemetry.test.ts b/packages/client/tests/integration/telemetry.test.ts new file mode 100644 index 0000000..2581f5b --- /dev/null +++ b/packages/client/tests/integration/telemetry.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClient, createTelemetryHooks } from '../../src'; +import type { TelemetryExporter } from '../../src'; +import { getFirstMockCall } from '../testUtils'; + +describe('telemetry exporter integration', () => { + it('reports start and success through a telemetry exporter', async () => { + const exporter: TelemetryExporter = { + onRequestStart: vi.fn(), + onRequestSuccess: vi.fn(), + onRequestError: vi.fn(), + }; + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: createTelemetryHooks(exporter), + }); + + await client.get('/users', { operationName: 'listUsers' }); + + expect(exporter.onRequestStart).toHaveBeenCalledTimes(1); + expect(exporter.onRequestSuccess).toHaveBeenCalledTimes(1); + expect(exporter.onRequestError).not.toHaveBeenCalled(); + + expect(getFirstMockCall(exporter.onRequestStart as ReturnType)[0]).toEqual( + expect.objectContaining({ operationName: 'listUsers' }), + ); + }); + + it('reports errors through a telemetry exporter', async () => { + const exporter: TelemetryExporter = { + onRequestError: vi.fn(), + }; + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Not found' }), { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + hooks: createTelemetryHooks(exporter), + }); + + await expect(client.get('/users/999')).rejects.toBeTruthy(); + + expect(exporter.onRequestError).toHaveBeenCalledTimes(1); + }); + + it('reports retries through a telemetry exporter', async () => { + const exporter: TelemetryExporter = { + onRequestRetry: vi.fn(), + onRequestSuccess: vi.fn(), + }; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Service Unavailable' }), { + status: 503, + statusText: 'Service Unavailable', + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + retry: { attempts: 1, baseDelayMs: 0, retryOn: ['5xx'], retryMethods: ['GET'] }, + hooks: createTelemetryHooks(exporter), + }); + + await client.get('/users'); + + expect(exporter.onRequestRetry).toHaveBeenCalledTimes(1); + expect(exporter.onRequestSuccess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/tests/unit/telemetry.test.ts b/packages/client/tests/unit/telemetry.test.ts new file mode 100644 index 0000000..53d1089 --- /dev/null +++ b/packages/client/tests/unit/telemetry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTelemetryHooks } from '../../src/core/telemetry'; + +describe('createTelemetryHooks', () => { + it('maps exporter methods onto the matching hooks', () => { + const exporter = { + onRequestStart: vi.fn(), + onRequestSuccess: vi.fn(), + onRequestError: vi.fn(), + onRequestRetry: vi.fn(), + }; + + const hooks = createTelemetryHooks(exporter); + + expect(hooks.beforeRequest).toBe(exporter.onRequestStart); + expect(hooks.afterResponse).toBe(exporter.onRequestSuccess); + expect(hooks.onError).toBe(exporter.onRequestError); + expect(hooks.onRetry).toBe(exporter.onRequestRetry); + }); + + it('only wires the methods the exporter defines', () => { + const hooks = createTelemetryHooks({ + onRequestStart: vi.fn(), + }); + + expect(hooks).toHaveProperty('beforeRequest'); + expect(hooks).not.toHaveProperty('afterResponse'); + expect(hooks).not.toHaveProperty('onError'); + expect(hooks).not.toHaveProperty('onRetry'); + }); + + it('returns an empty hooks config for an empty exporter', () => { + expect(createTelemetryHooks({})).toEqual({}); + }); +}); From 153c911bc3b72948079445e649fb49bebc3a9362 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 14:06:24 +0200 Subject: [PATCH 8/9] docs(client): add use-case oriented navigation to README Add a concise Documentation section grouping guides by use case (reliable requests, validation & data shape, observability & tracing, extensibility) and a prominent pointer to the full documentation site. Existing content is unchanged; the full in-depth docs live on the site. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/docs-use-case-nav.md | 8 ++++++++ packages/client/README.md | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 .changeset/docs-use-case-nav.md diff --git a/.changeset/docs-use-case-nav.md b/.changeset/docs-use-case-nav.md new file mode 100644 index 0000000..c44b6a9 --- /dev/null +++ b/.changeset/docs-use-case-nav.md @@ -0,0 +1,8 @@ +--- +'@dfsync/client': patch +--- + +Restructure the README around use cases: add a concise "Documentation" section with +guides grouped by use case (reliable requests, validation & data shape, observability & +tracing, extensibility) and a prominent pointer to the full documentation site. Content is +unchanged; only navigation was added. diff --git a/packages/client/README.md b/packages/client/README.md index ddfe2dc..f77c83f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -39,6 +39,23 @@ const updatedUser = await client.patch('/users/1', { }); ``` +## Documentation + +This README covers the essentials. For the full, in-depth documentation see the +[documentation site](https://dfsyncjs.github.io/#/docs/client). + +Guides by use case: + +- **Reliable requests** — [Retry](#retry), [Idempotency keys](#idempotency-keys), + [Request cancellation](#request-cancellation) +- **Validation & data shape** — [Response validation](#response-validation), + [Schema adapters](#schema-adapters-responseschema), + [Custom serialization and parsing](#custom-serialization-and-parsing) +- **Observability & tracing** — [Request ID](#request-id), [Operation name](#operation-name), + [Observability](#observability), [Errors](#errors) +- **Extensibility** — [Extension interfaces](#extension-interfaces) (auth provider, + validation adapter, retry policy, telemetry exporter) + ## HTTP methods `@dfsync/client` provides a small and predictable method surface: From dce5ee3bc15b862d272ede19b4f3ea8794764aef Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 29 Jul 2026 14:11:07 +0200 Subject: [PATCH 9/9] docs: mark 0.9.x roadmap delivered and advance package focus to 1.0.x Update ROADMAP.md 0.9.x section to completed/Delivered (reflecting the shipped RequestAbortedError) and update packages/client/CLAUDE.md current focus to 1.0.x with a summary of delivered 0.9.x work. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 6 ++++-- packages/client/CLAUDE.md | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 881368e..353998e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,7 +67,9 @@ Delivered: Focus: stabilizing core APIs, improving extensibility, and preparing `@dfsync/client` for production-scale usage and future SaaS integration. -Planned features: +Status: completed + +Delivered: - stable validation API (finalized `responseSchema` contract) - validation must run after response parsing @@ -91,7 +93,7 @@ Planned features: - `HttpError` - `NetworkError` - `TimeoutError` - - `AbortError` + - `RequestAbortedError` - `ValidationError` - must preserve backward compatibility - must not change existing error inheritance unexpectedly diff --git a/packages/client/CLAUDE.md b/packages/client/CLAUDE.md index 16c5223..ceecf29 100644 --- a/packages/client/CLAUDE.md +++ b/packages/client/CLAUDE.md @@ -132,6 +132,12 @@ Do NOT: ## Current focus -Release: 0.9.x — Platform readiness & API stabilization +Release: 1.0.x — Stable core & production readiness + +0.9.x (Platform readiness & API stabilization) is complete: `operationName`, extended error +metadata, improved retry model (`maxElapsedMs`, jitter, custom `shouldRetry`), serializer / +parser extensibility, the `responseSchema` validation adapter API with a zod adapter, and +the stable public extension interfaces (auth provider, validation adapter, retry policy, +telemetry exporter). Always check the root `ROADMAP.md` before making release-related changes.