From 469853b662eaa5e615cc9ad97f3cd7aaebfbb143 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 12:29:24 +0200 Subject: [PATCH 1/8] feat(client): add response validation support (#61) --- packages/client/src/core/normalize-error.ts | 4 +- packages/client/src/core/request.ts | 11 +++ .../client/src/errors/validation-error.ts | 14 +++ packages/client/src/index.ts | 9 +- packages/client/src/types/config.ts | 5 + packages/client/src/types/request.ts | 3 +- .../integration/response-validation.test.ts | 95 +++++++++++++++++++ .../tests/unit/validation-error.test.ts | 22 +++++ 8 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/errors/validation-error.ts create mode 100644 packages/client/tests/integration/response-validation.test.ts create mode 100644 packages/client/tests/unit/validation-error.test.ts diff --git a/packages/client/src/core/normalize-error.ts b/packages/client/src/core/normalize-error.ts index 61aec65..a5beb13 100644 --- a/packages/client/src/core/normalize-error.ts +++ b/packages/client/src/core/normalize-error.ts @@ -1,4 +1,4 @@ -import { HttpError } from '../errors/http-error'; +import { DfsyncError } from '../errors/base-error'; import { NetworkError } from '../errors/network-error'; import { TimeoutError } from '../errors/timeout-error'; import { RequestAbortedError } from '../errors/request-aborted-error'; @@ -9,7 +9,7 @@ export function normalizeError( timeout: number, abortReason?: RequestAbortReason, ): Error { - if (error instanceof HttpError) { + if (error instanceof DfsyncError) { return error; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index bc60020..6efae5c 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -1,5 +1,6 @@ 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 { RequestConfig } from '../types/request'; @@ -105,6 +106,16 @@ export async function request( if (!response.ok) { throw new HttpError(response, data); } + + const validateResponse = execution.request.validateResponse ?? clientConfig.validateResponse; + + if (validateResponse) { + const validationResult = await validateResponse(data); + + if (validationResult === false) { + throw new ValidationError(response, data); + } + } } catch (rawError) { const error = normalizeError(rawError, timeout, requestController.getAbortReason()); lastError = error; diff --git a/packages/client/src/errors/validation-error.ts b/packages/client/src/errors/validation-error.ts new file mode 100644 index 0000000..517c88a --- /dev/null +++ b/packages/client/src/errors/validation-error.ts @@ -0,0 +1,14 @@ +import { DfsyncError } from './base-error'; + +export class ValidationError extends DfsyncError { + public readonly data: unknown; + public readonly response: Response; + + constructor(response: Response, data: unknown) { + super('Response validation failed', 'VALIDATION_ERROR'); + + this.name = 'ValidationError'; + this.response = response; + this.data = data; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1e71b92..e77b1d1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,11 +4,18 @@ export { DfsyncError } from './errors/base-error'; export { HttpError } from './errors/http-error'; export { NetworkError } from './errors/network-error'; 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 { Client } from './types/client'; -export type { ClientConfig, RetryConfig, RetryCondition, RetryBackoff } from './types/config'; +export type { + ClientConfig, + RetryConfig, + RetryCondition, + RetryBackoff, + ResponseValidator, +} from './types/config'; export type { AfterResponseContext, BeforeRequestContext, diff --git a/packages/client/src/types/config.ts b/packages/client/src/types/config.ts index 812489a..b74af5d 100644 --- a/packages/client/src/types/config.ts +++ b/packages/client/src/types/config.ts @@ -3,6 +3,10 @@ import type { AuthConfig } from './auth'; import type { HooksConfig } from './hooks'; import type { RequestMethod } from './request'; +export type ResponseValidator = ( + data: TData, +) => boolean | void | Promise; + export type RetryCondition = 'network-error' | '5xx' | '429'; export type RetryBackoff = 'fixed' | 'exponential'; @@ -22,4 +26,5 @@ export type ClientConfig = { hooks?: HooksConfig; retry?: RetryConfig; fetch?: typeof globalThis.fetch; + validateResponse?: ResponseValidator; }; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index 60d175a..9b709ea 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 } from './config'; +import type { RetryConfig, ResponseValidator } from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -13,6 +13,7 @@ export type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + validateResponse?: ResponseValidator; }; export type RequestOptionsWithoutBody = Omit; diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts new file mode 100644 index 0000000..e0b2fb5 --- /dev/null +++ b/packages/client/tests/integration/response-validation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClient, ValidationError } from '../../src'; + +describe('response validation', () => { + it('returns data when client-level validation passes', 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, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); + + it('throws ValidationError when client-level validation 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, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).rejects.toBeInstanceOf(ValidationError); + }); + + it('uses request-level validation over client-level validation', 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, + validateResponse() { + return false; + }, + }); + + await expect( + client.get('/users/1', { + validateResponse(data) { + return typeof data === 'object' && data !== null && 'name' in data; + }, + }), + ).resolves.toEqual({ name: 'Roman' }); + }); + + it('supports async validation', 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, + async validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); + }); +}); diff --git a/packages/client/tests/unit/validation-error.test.ts b/packages/client/tests/unit/validation-error.test.ts new file mode 100644 index 0000000..cf58db0 --- /dev/null +++ b/packages/client/tests/unit/validation-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { ValidationError } from '../../src/errors/validation-error'; + +describe('ValidationError', () => { + it('stores response and data', () => { + const data = { message: 'invalid payload' }; + const response = new Response(JSON.stringify(data), { + status: 200, + statusText: 'OK', + }); + + const error = new ValidationError(response, data); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ValidationError'); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.message).toBe('Response validation failed'); + expect(error.response).toBe(response); + expect(error.data).toBe(data); + }); +}); From 152bbbfe9905b9acbcb76398d32c39add79c7ccb Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 12:48:24 +0200 Subject: [PATCH 2/8] test(client): define validation retry behavior (#62) --- packages/client/src/core/should-retry.ts | 1 + .../integration/response-validation.test.ts | 28 +++++++++++++++++++ .../client/tests/unit/should-retry.test.ts | 23 +++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/client/src/core/should-retry.ts b/packages/client/src/core/should-retry.ts index 428e06c..c0b4192 100644 --- a/packages/client/src/core/should-retry.ts +++ b/packages/client/src/core/should-retry.ts @@ -37,5 +37,6 @@ export function shouldRetry({ attempt, method, retry, error }: ShouldRetryParams return false; } + // Non-transient errors (e.g. validation failures) are not retried. return false; } diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts index e0b2fb5..9a660cb 100644 --- a/packages/client/tests/integration/response-validation.test.ts +++ b/packages/client/tests/integration/response-validation.test.ts @@ -92,4 +92,32 @@ describe('response validation', () => { await expect(client.get('/users/1')).resolves.toEqual({ id: 'user-1' }); }); + + it('does not retry when response validation 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'], + }, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await expect(client.get('/users/1')).rejects.toBeInstanceOf(ValidationError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/client/tests/unit/should-retry.test.ts b/packages/client/tests/unit/should-retry.test.ts index 0ca4709..1239cfe 100644 --- a/packages/client/tests/unit/should-retry.test.ts +++ b/packages/client/tests/unit/should-retry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { HttpError } from '../../src/errors/http-error'; 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'; @@ -23,6 +24,18 @@ function createHttpError(status: number, statusText = 'Error'): HttpError { return new HttpError(response); } +function createValidationError(): ValidationError { + const response = new Response(JSON.stringify({ name: 'Roman' }), { + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + }, + }); + + return new ValidationError(response, { name: 'Roman' }); +} + function createParams( overrides: Partial<{ attempt: number; @@ -131,4 +144,14 @@ describe('shouldRetry', () => { ), ).toBe(false); }); + + it('does not retry validation errors', () => { + expect( + shouldRetry( + createParams({ + error: createValidationError(), + }), + ), + ).toBe(false); + }); }); From de9bbee719a78d770624869378c5d8ce69a4baee Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 13:12:11 +0200 Subject: [PATCH 3/8] feat(client): expose validation result in hooks (#63) --- packages/client/src/core/execution-context.ts | 2 + packages/client/src/core/hook-context.ts | 1 + packages/client/src/core/request.ts | 5 ++ packages/client/src/types/hooks.ts | 6 ++ .../integration/response-validation.test.ts | 64 +++++++++++++++++++ 5 files changed, 78 insertions(+) diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts index a1abf41..752425a 100644 --- a/packages/client/src/core/execution-context.ts +++ b/packages/client/src/core/execution-context.ts @@ -1,4 +1,5 @@ import type { HeadersMap } from '../types/common'; +import type { ResponseValidationResult } from '../types/hooks'; import type { RequestConfig } from '../types/request'; export type ExecutionContext = { @@ -11,6 +12,7 @@ export type ExecutionContext = { startedAt: number; endedAt?: number; durationMs?: number; + validation?: ResponseValidationResult; }; type CreateExecutionContextParams = { diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts index de50c55..5422cbb 100644 --- a/packages/client/src/core/hook-context.ts +++ b/packages/client/src/core/hook-context.ts @@ -38,6 +38,7 @@ export function createAfterResponseContext( ...createLifecycleContextBase(execution), response, data, + ...(execution.validation !== undefined ? { validation: execution.validation } : {}), }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 6efae5c..5ad74a0 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -112,6 +112,11 @@ export async function request( if (validateResponse) { const validationResult = await validateResponse(data); + execution.validation = { + enabled: true, + passed: validationResult !== false, + }; + if (validationResult === false) { throw new ValidationError(response, data); } diff --git a/packages/client/src/types/hooks.ts b/packages/client/src/types/hooks.ts index fc4603f..dfe2e37 100644 --- a/packages/client/src/types/hooks.ts +++ b/packages/client/src/types/hooks.ts @@ -2,6 +2,11 @@ import type { HeadersMap } from './common'; import type { RequestConfig } from './request'; import type { RetryCondition } from './config'; +export type ResponseValidationResult = { + enabled: boolean; + passed: boolean; +}; + type LifecycleContextBase = { request: RequestConfig; url: URL; @@ -20,6 +25,7 @@ export type BeforeRequestContext = LifecycleContextBase; export type AfterResponseContext = LifecycleContextBase & { response: Response; data: T; + validation?: ResponseValidationResult; }; export type ErrorContext = LifecycleContextBase & { diff --git a/packages/client/tests/integration/response-validation.test.ts b/packages/client/tests/integration/response-validation.test.ts index 9a660cb..7dd142f 100644 --- a/packages/client/tests/integration/response-validation.test.ts +++ b/packages/client/tests/integration/response-validation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createClient, ValidationError } from '../../src'; +import { getFirstMockCall } from '../testUtils'; describe('response validation', () => { it('returns data when client-level validation passes', async () => { @@ -120,4 +121,67 @@ describe('response validation', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('exposes validation result in afterResponse hook when validation passes', 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, + }, + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + }); + + await client.get('/users/1'); + + expect(afterResponse).toHaveBeenCalledTimes(1); + expect(afterResponse).toHaveBeenCalledWith( + expect.objectContaining({ + validation: { + enabled: true, + passed: true, + }, + }), + ); + }); + + it('does not expose validation result when validation is not configured', 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, + }, + }); + + await client.get('/users/1'); + + expect(afterResponse).toHaveBeenCalledTimes(1); + const ctx = getFirstMockCall(afterResponse); + expect(ctx).not.toHaveProperty('validation'); + }); }); From c3530865bb0fc4f45f735fd0b9c029f6d8a9f481 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 13:27:03 +0200 Subject: [PATCH 4/8] add Claude files --- CLAUDE.md | 40 +++++++++++++++++++ packages/client/CLAUDE.md | 83 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 CLAUDE.md create mode 100644 packages/client/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6dbda84 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# dfsync — Monorepo Guidelines + +This repository is a monorepo using pnpm workspaces. + +## Structure + +- packages/client — HTTP client for service-to-service communication +- (future packages will be added here) + +## Rules + +- Always detect which package you are working in +- NEVER apply changes across packages unless explicitly asked +- Keep changes scoped to a single package + +## Package-specific context + +When working in a package, you MUST read its local CLAUDE.md file: + +- packages/client/CLAUDE.md + +If a package does not have CLAUDE.md: + +- follow root rules only +- do NOT invent architecture + +## Development principles + +- Do NOT introduce breaking changes +- Do NOT refactor unrelated code +- Prefer minimal, incremental changes +- Follow existing patterns + +## When unsure + +- Ask instead of guessing + +## Important + +When editing files inside a package, ALWAYS prefer local CLAUDE.md over this file. diff --git a/packages/client/CLAUDE.md b/packages/client/CLAUDE.md new file mode 100644 index 0000000..73e1873 --- /dev/null +++ b/packages/client/CLAUDE.md @@ -0,0 +1,83 @@ +# @dfsync/client — Development Context + +This package provides a reliable HTTP client for service-to-service communication. + +## Scope + +- Node.js / TypeScript HTTP client +- Focus on predictable request lifecycle +- Used in microservices and integrations + +## Architecture + +Core flow: + +1. createExecutionContext +2. applyRequestMetadata (headers, requestId) +3. fetch +4. parseResponse +5. error handling (HttpError / NetworkError / etc.) +6. response validation (if configured) +7. hooks execution + +## Key principles + +### Error handling + +- All internal errors must extend `DfsyncError` +- NEVER wrap `DfsyncError` into `NetworkError` +- Preserve original error types + +### Validation + +- Runs only after successful HTTP responses +- Throws `ValidationError` +- MUST NOT trigger retries + +### Retry + +- Applies only to: + - network errors + - 5xx responses + - 429 responses +- Must remain predictable + +### Hooks + +- Do NOT change existing hook signatures +- New fields must be optional + +### Tests + +- Integration tests for request lifecycle +- Unit tests for isolated logic +- Use `getFirstMockCall` from testUtils + +## Code style + +- Follow existing structure in src/core +- Do NOT introduce new layers +- Prefer extending existing flow + +## Important + +Do NOT: + +- refactor request pipeline +- change retry behavior unless task explicitly requires it +- introduce new abstractions + +## When implementing features + +- Keep PRs small and focused +- One concern per PR + +## Current focus + +Release: 0.8.x — Integration safety + +Areas: + +- response validation +- idempotency +- safe retries From afe0de441cd61f45458e0940b9bb63d519197741 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 13:48:23 +0200 Subject: [PATCH 5/8] feat(client): add idempotency key support (#64) --- .../client/src/core/apply-request-metadata.ts | 5 ++ packages/client/src/types/request.ts | 1 + .../client/tests/integration/headers.test.ts | 67 +++++++++++++++++++ .../tests/unit/apply-request-metadata.test.ts | 40 +++++++++++ 4 files changed, 113 insertions(+) diff --git a/packages/client/src/core/apply-request-metadata.ts b/packages/client/src/core/apply-request-metadata.ts index b7e33c1..acf9c92 100644 --- a/packages/client/src/core/apply-request-metadata.ts +++ b/packages/client/src/core/apply-request-metadata.ts @@ -2,4 +2,9 @@ import type { ExecutionContext } from './execution-context'; export function applyRequestMetadata(execution: ExecutionContext): void { execution.headers['x-request-id'] = execution.headers['x-request-id'] ?? execution.requestId; + + if (execution.request.idempotencyKey) { + execution.headers['idempotency-key'] = + execution.headers['idempotency-key'] ?? execution.request.idempotencyKey; + } } diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index 9b709ea..1a93cd9 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; + idempotencyKey?: string; validateResponse?: ResponseValidator; }; diff --git a/packages/client/tests/integration/headers.test.ts b/packages/client/tests/integration/headers.test.ts index 147c95e..4e99c71 100644 --- a/packages/client/tests/integration/headers.test.ts +++ b/packages/client/tests/integration/headers.test.ts @@ -187,4 +187,71 @@ describe('request headers', () => { expect(typeof requestId).toBe('string'); expect(requestId!.length).toBeGreaterThan(0); }); + + it('propagates idempotencyKey to idempotency-key header', 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.test.com', + fetch: fetchMock, + }); + + await client.post( + '/payments', + { + amount: 100, + }, + { + idempotencyKey: 'idem_123', + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const init = getFirstFetchInit(fetchMock); + const headers = init.headers as Record; + + expect(headers['idempotency-key']).toBe('idem_123'); + }); + + it('prefers explicit idempotency-key header over idempotencyKey option', 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.test.com', + fetch: fetchMock, + }); + + await client.post( + '/payments', + { + amount: 100, + }, + { + idempotencyKey: 'idem_from_option', + headers: { + 'idempotency-key': 'idem_from_header', + }, + }, + ); + + const init = getFirstFetchInit(fetchMock); + const headers = init.headers as Record; + + expect(headers['idempotency-key']).toBe('idem_from_header'); + }); }); diff --git a/packages/client/tests/unit/apply-request-metadata.test.ts b/packages/client/tests/unit/apply-request-metadata.test.ts index 065cc33..d124a18 100644 --- a/packages/client/tests/unit/apply-request-metadata.test.ts +++ b/packages/client/tests/unit/apply-request-metadata.test.ts @@ -45,4 +45,44 @@ describe('applyRequestMetadata', () => { expect(execution.headers['x-request-id']).toBe('existing-request-id'); }); + + it('adds idempotency-key header when idempotencyKey is provided', () => { + const execution = createExecutionContext({ + request: { + method: 'POST', + path: '/payments', + idempotencyKey: 'idem-123', + }, + }); + + applyRequestMetadata(execution); + + expect(execution.headers['idempotency-key']).toBe('idem-123'); + }); + + it('does not overwrite an existing idempotency-key header', () => { + const execution = createExecutionContext({ + request: { + method: 'POST', + path: '/payments', + idempotencyKey: 'idem-from-option', + }, + headers: { + accept: 'application/json', + 'idempotency-key': 'idem-from-header', + }, + }); + + applyRequestMetadata(execution); + + expect(execution.headers['idempotency-key']).toBe('idem-from-header'); + }); + + it('does not add idempotency-key header when idempotencyKey is not provided', () => { + const execution = createExecutionContext(); + + applyRequestMetadata(execution); + + expect(execution.headers).not.toHaveProperty('idempotency-key'); + }); }); From 431aeb2db418fba64f93914763c1b072b293c448 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 14:20:23 +0200 Subject: [PATCH 6/8] feat(client): improve retry safety for non-idempotent requests (#65) --- packages/client/src/core/request.ts | 1 + packages/client/src/core/should-retry.ts | 15 ++- .../client/tests/integration/retry.test.ts | 76 ++++++++++++++- .../client/tests/unit/should-retry.test.ts | 97 ++++++++++++++++++- 4 files changed, 181 insertions(+), 8 deletions(-) diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 5ad74a0..b64b29a 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -129,6 +129,7 @@ export async function request( attempt: execution.attempt, method: execution.request.method, retry, + idempotencyKey: execution.request.idempotencyKey, error, }); diff --git a/packages/client/src/core/should-retry.ts b/packages/client/src/core/should-retry.ts index c0b4192..ab45656 100644 --- a/packages/client/src/core/should-retry.ts +++ b/packages/client/src/core/should-retry.ts @@ -9,10 +9,17 @@ type ShouldRetryParams = { attempt: number; method: RequestMethod; retry: NormalizedRetryConfig; + idempotencyKey?: string | undefined; error: unknown; }; -export function shouldRetry({ attempt, method, retry, error }: ShouldRetryParams): boolean { +export function shouldRetry({ + attempt, + method, + retry, + idempotencyKey, + error, +}: ShouldRetryParams): boolean { if (attempt >= retry.attempts) { return false; } @@ -21,6 +28,12 @@ export function shouldRetry({ attempt, method, retry, error }: ShouldRetryParams return false; } + const isNonIdempotentMethod = method === 'POST' || method === 'PATCH'; + + if (isNonIdempotentMethod && !idempotencyKey) { + return false; + } + if (error instanceof NetworkError) { return retry.retryOn.includes('network-error'); } diff --git a/packages/client/tests/integration/retry.test.ts b/packages/client/tests/integration/retry.test.ts index 333a628..9d3ae00 100644 --- a/packages/client/tests/integration/retry.test.ts +++ b/packages/client/tests/integration/retry.test.ts @@ -106,7 +106,7 @@ describe('client retry', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('retries POST when explicitly allowed', async () => { + it('retries POST when explicitly allowed and idempotencyKey is provided', async () => { const fetchMock = vi .fn() .mockRejectedValueOnce(new Error('socket hang up')) @@ -128,14 +128,82 @@ describe('client retry', () => { }, }); - const result = await client.post<{ ok: boolean }>('/users', { - name: 'John', - }); + const result = await client.post<{ ok: boolean }>( + '/users', + { + name: 'John', + }, + { idempotencyKey: 'idem-123' }, + ); expect(result).toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('does not retry POST requests without idempotencyKey even when POST is allowed', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'server error' }), { + status: 500, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: fetchMock, + retry: { + attempts: 3, + retryMethods: ['POST'], + retryOn: ['5xx'], + baseDelayMs: 0, + }, + }); + + await expect(client.post('/payments', { amount: 100 })).rejects.toThrow(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries POST requests with idempotencyKey when POST is allowed', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'server error' }), { + status: 500, + 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: 2, + retryMethods: ['POST'], + retryOn: ['5xx'], + baseDelayMs: 0, + }, + }); + + await expect( + client.post('/payments', { amount: 100 }, { idempotencyKey: 'idem-123' }), + ).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('request retry config overrides client retry config', async () => { const fetchMock = vi.fn(async () => { return new Response(JSON.stringify({ message: 'Service Unavailable' }), { diff --git a/packages/client/tests/unit/should-retry.test.ts b/packages/client/tests/unit/should-retry.test.ts index 1239cfe..fe0f7bc 100644 --- a/packages/client/tests/unit/should-retry.test.ts +++ b/packages/client/tests/unit/should-retry.test.ts @@ -41,6 +41,7 @@ function createParams( attempt: number; method: RequestMethod; retry: Required; + idempotencyKey?: string; error: unknown; }> = {}, ) { @@ -120,21 +121,41 @@ describe('shouldRetry', () => { ).toBe(false); }); - it('retries POST when explicitly allowed', () => { + it('retries POST when explicitly allowed and idempotencyKey is provided', () => { expect( shouldRetry( createParams({ method: 'POST', + idempotencyKey: 'idem-123', retry: { ...defaultRetry, - retryMethods: ['GET', 'PUT', 'DELETE', 'POST'], + attempts: 3, + retryMethods: ['POST'], + retryOn: ['network-error'], }, - error: new NetworkError(), + error: new NetworkError('Network request failed', new Error('socket hang up')), }), ), ).toBe(true); }); + it('does not retry POST without idempotencyKey even when explicitly allowed', () => { + expect( + shouldRetry( + createParams({ + method: 'POST', + retry: { + ...defaultRetry, + attempts: 3, + retryMethods: ['POST'], + retryOn: ['network-error'], + }, + error: new NetworkError('Network request failed', new Error('socket hang up')), + }), + ), + ).toBe(false); + }); + it('does not retry on externally aborted requests', () => { expect( shouldRetry( @@ -154,4 +175,74 @@ describe('shouldRetry', () => { ), ).toBe(false); }); + + it('does not retry POST requests without idempotencyKey', () => { + expect( + shouldRetry( + createParams({ + method: 'POST', + retry: { + ...defaultRetry, + attempts: 3, + retryMethods: ['POST'], + retryOn: ['5xx'], + }, + error: createHttpError(500), + }), + ), + ).toBe(false); + }); + + it('retries POST requests with idempotencyKey when method and condition are allowed', () => { + expect( + shouldRetry( + createParams({ + method: 'POST', + idempotencyKey: 'idem-123', + retry: { + ...defaultRetry, + attempts: 3, + retryMethods: ['POST'], + retryOn: ['5xx'], + }, + error: createHttpError(500), + }), + ), + ).toBe(true); + }); + + it('does not retry PATCH requests without idempotencyKey', () => { + expect( + shouldRetry( + createParams({ + method: 'PATCH', + retry: { + ...defaultRetry, + attempts: 3, + retryMethods: ['PATCH'], + retryOn: ['5xx'], + }, + error: createHttpError(500), + }), + ), + ).toBe(false); + }); + + it('retries PATCH requests with idempotencyKey when method and condition are allowed', () => { + expect( + shouldRetry( + createParams({ + method: 'PATCH', + idempotencyKey: 'idem-123', + retry: { + ...defaultRetry, + attempts: 3, + retryMethods: ['PATCH'], + retryOn: ['5xx'], + }, + error: createHttpError(500), + }), + ), + ).toBe(true); + }); }); From a8836f171a81ab76b04807496c2c66a897044e2e Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 15:22:32 +0200 Subject: [PATCH 7/8] Docs/release 0 8 x (#66) --- .changeset/lovely-lands-matter.md | 9 ++ README.md | 2 + ROADMAP.md | 11 +- packages/client/README.md | 126 +++++++++++++++++++++- prettier.config.js => prettier.config.mjs | 4 +- 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 .changeset/lovely-lands-matter.md rename prettier.config.js => prettier.config.mjs (75%) diff --git a/.changeset/lovely-lands-matter.md b/.changeset/lovely-lands-matter.md new file mode 100644 index 0000000..5ac42bd --- /dev/null +++ b/.changeset/lovely-lands-matter.md @@ -0,0 +1,9 @@ +--- +'@dfsync/client': minor +--- + +- add integration safety features +- add response validation with `ValidationError` +- expose validation result in lifecycle hooks +- add idempotency key support +- improve retry safety for non-idempotent requests diff --git a/README.md b/README.md index 1c94917..187e2a2 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Full documentation: - auth support: bearer, API key, custom - support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` +- response validation +- idempotency key support for safer retries **@dfsync/client** provides a predictable and controllable HTTP request lifecycle for service-to-service communication. diff --git a/ROADMAP.md b/ROADMAP.md index df1c1a9..15fb559 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,12 +53,15 @@ Delivered: Focus: safer and more predictable integrations. -Status: in progress +Status: completed -Planned features: +Delivered: -- response validation (schema-based or custom) -- idempotency key support for safe retries +- response validation with client-level defaults and request-level overrides +- `ValidationError` for failed response validation +- validation result metadata in lifecycle hooks +- idempotency key support via the `idempotency-key` header +- safer retry behavior for non-idempotent requests ### 0.9.x — Platform readiness & API stabilization diff --git a/packages/client/README.md b/packages/client/README.md index ab7a27b..ff33eb5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,6 +71,8 @@ client.request(config) - consistent error handling - auth support: bearer, API key, custom - support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` +- response validation with `ValidationError` +- idempotency key support for safer retries It provides a predictable and controllable HTTP request lifecycle for service-to-service communication. @@ -88,7 +90,8 @@ A request in `@dfsync/client` follows a predictable lifecycle: 8. run `onRetry` before a retry attempt 9. retry on failure (if configured) 10. parse response (JSON, text, or `undefined` for `204`) -11. run `afterResponse` or `onError` hooks +11. validate response data (if configured) +12. run `afterResponse` or `onError` hooks ## Request context @@ -148,18 +151,118 @@ Cancellation is treated differently from timeouts: ## Errors -dfsync provides structured error types: +`@dfsync/client` provides structured error types: - `HttpError` — non-2xx responses - `NetworkError` — network failures - `TimeoutError` — request timed out +- `ValidationError` — response validation failed - `RequestAbortedError` — request was cancelled This allows you to handle failures more precisely. +## Response validation + +You can validate successful responses before they are returned to the caller. + +This is useful when your service depends on another API and needs to fail fast when the response shape changes unexpectedly. +Instead of passing malformed data deeper into your application, validation turns the mismatch into a structured `ValidationError`. + +Validation runs only after a successful HTTP response. Non-2xx responses still throw `HttpError`. + +```ts +import { createClient } from '@dfsync/client'; + +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, +}); + +const user = await client.get('/users/1'); +``` + +Return `false` to fail validation. Returning `true` or nothing means validation passed. + +You can also override validation per request: + +```ts +await client.get('/users/1', { + validateResponse(data) { + return typeof data === 'object' && data !== null && 'email' in data; + }, +}); +``` + +When validation fails, `@dfsync/client` throws `ValidationError`: + +```ts +import { ValidationError } from '@dfsync/client'; + +try { + await client.get('/users/1'); +} catch (error) { + if (error instanceof ValidationError) { + console.log(error.data); + } +} +``` + +Validation failures are not retried by default. + +## Idempotency keys + +For operations that may be retried safely, you can attach an idempotency key per request. + +This helps protect non-idempotent operations, such as payments or job creation, from being applied more than once when a request is retried after a transient failure. +The receiving service should treat repeated requests with the same idempotency key as the same logical operation. + +```ts +await client.post( + '/payments', + { amount: 100 }, + { + idempotencyKey: 'payment-123', + }, +); +``` + +This adds the following header: + +```text +idempotency-key: payment-123 +``` + +`POST` and `PATCH` requests are not retried unless both conditions are true: + +- the method is explicitly included in `retry.retryMethods` +- the request provides `idempotencyKey` + +By default, `POST` and `PATCH` are not retried. This keeps unsafe retries opt-in and makes the retry behavior explicit at the call site. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 3, + retryMethods: ['POST'], + retryOn: ['5xx'], + }, +}); + +await client.post( + '/payments', + { amount: 100 }, + { + idempotencyKey: 'payment-123', + }, +); +``` + ## Observability -dfsync provides built-in request lifecycle metadata for better visibility and debugging. +`@dfsync/client` provides built-in request lifecycle metadata for better visibility and debugging. Each request exposes: @@ -194,6 +297,23 @@ const client = createClient({ }); ``` +When response validation is configured and passes, `afterResponse` also receives validation metadata. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + validateResponse(data) { + return typeof data === 'object' && data !== null && 'id' in data; + }, + hooks: { + afterResponse(ctx) { + console.log(ctx.validation); + // { enabled: true, passed: true } + }, + }, +}); +``` + This makes it easier to understand: - what happened during a request diff --git a/prettier.config.js b/prettier.config.mjs similarity index 75% rename from prettier.config.js rename to prettier.config.mjs index 86517aa..ddfd4c5 100644 --- a/prettier.config.js +++ b/prettier.config.mjs @@ -1,8 +1,8 @@ /** @type {import("prettier").Config} */ -module.exports = { +export default { semi: true, singleQuote: false, trailingComma: 'all', - printWidth: 100, + printWidth: 120, tabWidth: 2, }; From b90544328af143f4f090b4dc6f1dd463bf7de612 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 18:25:32 +0200 Subject: [PATCH 8/8] docs: update docs client url --- README.md | 2 +- packages/client/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 187e2a2..264a879 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Home page: [https://dfsyncjs.github.io](https://dfsyncjs.github.io) Full documentation: -[https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs) +[https://dfsyncjs.github.io/#/docs/client](https://dfsyncjs.github.io/#/docs/client) #### Main features diff --git a/packages/client/README.md b/packages/client/README.md index ff33eb5..5e3e198 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -10,7 +10,7 @@ Designed for backend services, microservices and internal APIs where consistent Home page: [https://dfsyncjs.github.io](https://dfsyncjs.github.io) -Full documentation: [https://dfsyncjs.github.io/#/docs](https://dfsyncjs.github.io/#/docs) +Full documentation: [https://dfsyncjs.github.io/#/docs/client](https://dfsyncjs.github.io/#/docs/client) ## Install