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, };