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/.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/.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/.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/.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/.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/.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/.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/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/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/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/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/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. diff --git a/packages/client/README.md b/packages/client/README.md index 5e3e198..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: @@ -98,6 +115,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 +148,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`: @@ -161,6 +204,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`, +`response`, and `issues` (adapter-specific validation details). + ## Response validation You can validate successful responses before they are returned to the caller. @@ -211,6 +279,146 @@ 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`. + +### 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 +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 +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. @@ -320,6 +528,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/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/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/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/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/core/request.ts b/packages/client/src/core/request.ts index b64b29a..8da0b4e 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -2,11 +2,12 @@ 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'; 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'; @@ -20,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'; @@ -32,6 +34,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, @@ -42,6 +56,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 = { @@ -70,10 +85,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'; @@ -101,24 +131,39 @@ 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); } - 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) { @@ -134,19 +179,28 @@ export async function request( }); if (!canRetry) { - finalizeExecution(execution); - - await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); - throw error; + return failWithError(clientConfig, execution, error); } const retryReason = getRetryReason(error); if (!retryReason) { - finalizeExecution(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({ @@ -154,8 +208,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-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/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/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/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/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 e77b1d1..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,14 +8,23 @@ 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, RetryConfig, RetryCondition, RetryBackoff, + RetryPredicate, + RetryPredicateContext, ResponseValidator, + ValidationAdapter, + ValidationResult, + BodySerializer, + ResponseParser, + SerializedBody, + SerializerContext, } from './types/config'; export type { AfterResponseContext, 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/src/types/config.ts b/packages/client/src/types/config.ts index b74af5d..6a54039 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -1,21 +1,100 @@ 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; +/** + * 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; + 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'; +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 = { @@ -27,4 +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/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..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 } from './config'; +import type { + RetryConfig, + ResponseValidator, + ValidationAdapter, + BodySerializer, + ResponseParser, +} from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -13,8 +19,12 @@ export type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + operationName?: string; idempotencyKey?: string; validateResponse?: ResponseValidator; + responseSchema?: ValidationAdapter; + serializeBody?: BodySerializer; + parseResponse?: ResponseParser; }; export type RequestOptionsWithoutBody = Omit; 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/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/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/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/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); + }); +}); 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/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/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'); + }); +}); 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/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, 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/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; }> = {}, 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({}); + }); +}); 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); }); }); 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: {}