diff --git a/docs/v1/api-reference.md b/docs/v1/api-reference.md index e69de29..cb56230 100644 --- a/docs/v1/api-reference.md +++ b/docs/v1/api-reference.md @@ -0,0 +1,35 @@ +# API Reference + +## createClient + +Creates a configured HTTP client. + +## Client methods + +- `get` +- `post` +- `put` +- `patch` +- `delete` + +## Configuration + +- `baseUrl` +- `timeout` +- `retry` +- `auth` +- `hooks` + +## Hooks + +- `beforeRequest` +- `afterResponse` +- `onError` +- `onRetry` + +## Errors + +- `HttpError` +- `NetworkError` +- `TimeoutError` +- `RequestAbortedError` diff --git a/docs/v1/create-client.md b/docs/v1/create-client.md index 973e17a..0516cd1 100644 --- a/docs/v1/create-client.md +++ b/docs/v1/create-client.md @@ -2,13 +2,21 @@ Use `createClient` to create a reusable HTTP client instance. +It provides a consistent way to configure: + +- base URL and default headers +- timeouts and retries +- auth +- lifecycle hooks +- request observability metadata + ## Basic client ```ts import { createClient } from '@dfsync/client'; const client = createClient({ - baseURL: 'https://api.example.com', + baseUrl: 'https://api.example.com', }); ``` @@ -59,6 +67,20 @@ type ClientConfig = { }; ``` +Hook configuration supports: + +- `beforeRequest` +- `afterResponse` +- `onError` +- `onRetry` + +Retry configuration supports: + +- retry attempts +- retry conditions +- backoff strategy +- `Retry-After` handling + ## HTTP methods The client provides a predictable set of methods: @@ -146,6 +168,10 @@ type RequestOptions = { }; ``` +`requestId` can be provided explicitly when you want to correlate logs or trace a request across services. + +Request-level `retry` overrides client-level retry settings. + ## Low-level request ```ts @@ -155,13 +181,13 @@ const result = await client.request({ body: { type: 'user.created', }, - headers: { - 'x-request-id': 'req-123', - }, + requestId: 'req-123', timeout: 3000, }); ``` +If both `requestId` and `x-request-id` header are provided, `x-request-id` takes precedence. + ### Request Config ```ts @@ -180,14 +206,26 @@ type RequestConfig = { ## Request context -Each request is executed within a request context that contains: +Each request attempt is executed within a request context that contains: -- `requestId` — unique identifier for the request -- `attempt` — current retry attempt +- `requestId` — stable identifier for the full request lifecycle +- `attempt` — current retry attempt (zero-based) +- `maxAttempts` — total number of allowed attempts, including the initial request - `signal` — AbortSignal for cancellation - `startedAt` — request start timestamp -This context is available in all lifecycle hooks. +Completed attempts may also expose: + +- `endedAt` — request end timestamp +- `durationMs` — total duration for the current attempt + +Retry-specific contexts may also expose: + +- `retryDelayMs` +- `retryReason` +- `retrySource` + +This context is available through lifecycle hooks. ## Request ID @@ -196,9 +234,12 @@ Each request has a `requestId` that is: - automatically generated by default - can be overridden per request - propagated via the `x-request-id` header +- remains stable across retries This allows tracing requests across services. +It also makes retries easier to correlate in logs and monitoring systems. + ### Example ```ts @@ -275,3 +316,50 @@ If request body is a string, the client: - sends it as-is - does not force a `content-type` + +## Retry observability + +Retries can be observed using lifecycle hooks. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 2, + retryOn: ['5xx', '429'], + backoff: 'exponential', + baseDelayMs: 300, + }, + hooks: { + onRetry({ requestId, attempt, maxAttempts, retryDelayMs, retryReason, retrySource }) { + console.log({ + requestId, + attempt, + maxAttempts, + retryDelayMs, + retryReason, + retrySource, + }); + }, + }, +}); +``` + +This is useful for logging, monitoring, and debugging retry behavior. + +## Retry-After support + +When a retryable response includes a `Retry-After` header, `@dfsync/client` uses that value before falling back to the configured backoff strategy. + +Supported formats: + +- seconds +- HTTP-date + +If the header value is invalid, `@dfsync/client` falls back to normal retry backoff. + +## Related guides + +- See **Hooks** for lifecycle hooks and observability metadata +- See **Retry** for retry conditions, backoff, and `Retry-After` +- See **Errors** for failure behavior and error types diff --git a/docs/v1/getting-started.md b/docs/v1/getting-started.md index 6be5023..757ef4b 100644 --- a/docs/v1/getting-started.md +++ b/docs/v1/getting-started.md @@ -31,37 +31,27 @@ The client focuses on predictable behavior, extensibility, and a clean developer ```ts import { createClient } from '@dfsync/client'; -type User = { - id: string; - name: string; -}; - const client = createClient({ baseUrl: 'https://api.example.com', timeout: 5000, + retry: { + attempts: 2, + retryOn: ['5xx', 'network-error'], + }, + hooks: { + onRetry: ({ requestId, retryReason, retryDelayMs }) => { + console.log(`[${requestId}] retrying in ${retryDelayMs}ms`, retryReason); + }, + }, }); -const user = await client.get('/users/1'); +const data = await client.get('/health'); ``` -## How requests work - -A request in `@dfsync/client` follows a predictable lifecycle: - -1. create request context -2. build final URL from `baseUrl`, `path`, and query params -3. merge client and request headers -4. apply authentication -5. attach request metadata (e.g. `x-request-id`) -6. run `beforeRequest` hooks -7. send request with `fetch` -8. retry on failure (if configured) -9. parse response (JSON, text, or `undefined`) -10. run `afterResponse` or `onError` hooks - -## Runtime requirements - -- Node.js >= 20 -- a working fetch implementation +This gives you: -If you do not pass a custom `fetch`, the client uses `globalThis.fetch`. +- timeouts +- retries +- structured errors +- request lifecycle hooks +- built-in retry observability diff --git a/docs/v1/hooks.md b/docs/v1/hooks.md index 5cd8d7e..5f7726b 100644 --- a/docs/v1/hooks.md +++ b/docs/v1/hooks.md @@ -1,12 +1,13 @@ # Hooks -Hooks allow you to extend the behavior of the HTTP client during the request lifecycle. +Hooks allow you to extend and observe the request lifecycle. Supported hooks: - `beforeRequest` - `afterResponse` - `onError` +- `onRetry` Each hook can be: @@ -17,7 +18,22 @@ Hooks run sequentially in the order you provide them. ## Request metadata -Hooks receive a rich lifecycle context, including request metadata and execution details. +Hooks receive structured lifecycle metadata, including request details, retry information, and timing fields. + +Available metadata includes: + +- `requestId` — stable across retries +- `attempt` +- `maxAttempts` +- `startedAt` +- `endedAt` +- `durationMs` + +Retry-specific hooks also expose: + +- `retryDelayMs` +- `retryReason` +- `retrySource` ```ts const client = createClient({ @@ -26,9 +42,6 @@ const client = createClient({ beforeRequest: (ctx) => { console.log(ctx.requestId, ctx.attempt); }, - onError: (ctx) => { - console.error(ctx.requestId, ctx.error); - }, }, }); ``` @@ -97,6 +110,12 @@ const client = createClient({ If one `beforeRequest` hook throws, the request is not sent and the original error is rethrown. +### beforeRequest context + +```text +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt +``` + ## afterResponse Use `afterResponse` to inspect successful responses after parsing. @@ -133,6 +152,12 @@ const client = createClient({ If an `afterResponse` hook throws, that hook error is rethrown. +### afterResponse context + +```text +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, response, data +``` + ## onError Use `onError` to observe failed requests. @@ -160,36 +185,85 @@ If an `onError` hook itself throws, the original request error is preserved. This is intentional, so hook failures never hide the real request failure. -## Hook context - -### beforeRequest context +### onError context ```text -request, url, headers +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, error ``` -### afterResponse context +## onRetry + +Use `onRetry` to observe retry behavior before the next attempt is executed. + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 2, + retryOn: ['5xx', '429'], + }, + hooks: { + onRetry: ({ requestId, attempt, maxAttempts, retryDelayMs, retryReason, retrySource }) => { + console.log( + `[${requestId}] retry ${attempt + 1}/${maxAttempts} in ${retryDelayMs}ms`, + retryReason, + retrySource, + ); + }, + }, +}); +``` + +`onRetry` runs only when a retry will actually happen. + +### onRetry context ```text -request, url, headers, response, data +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt, endedAt, durationMs, error, retryDelayMs, retryReason, retrySource ``` -### onError context +## Hook context summary + +All hooks receive request lifecycle metadata. + +Common fields: ```text -request, url, headers, error +request, url, headers, signal, attempt, maxAttempts, requestId, startedAt ``` +Additional fields: + +- `afterResponse` → `endedAt`, `durationMs`, `response`,`data` +- `onError` → `endedAt`, `durationMs`, `error` +- `onRetry` → `endedAt`, `durationMs`, `error`, `retryDelayMs`, `retryReason`, `retrySource` + ## Hook order Request lifecycle order is: 1. auth 2. `beforeRequest` -3. fetch +3. fetch execution 4. response parsing 5. `afterResponse` on success -6. `onError` on failure + +Retry flow: + +1. auth +2. `beforeRequest` +3. fetch execution +4. retry decision +5. `onRetry` before the next attempt +6. next retry attempt + +Failure flow: + +1. auth +2. `beforeRequest` +3. fetch execution +4. retry loop (if enabled) +5. `onError` on final failure ## Hook config example diff --git a/docs/v1/observability.md b/docs/v1/observability.md new file mode 100644 index 0000000..cb78d63 --- /dev/null +++ b/docs/v1/observability.md @@ -0,0 +1,42 @@ +# Observability + +`@dfsync/client` provides built-in request lifecycle metadata for better visibility and debugging. + +Each request exposes: + +- **requestId** — stable identifier across retries +- **attempt / maxAttempts** — retry progress +- **startedAt / endedAt / durationMs** — timing information +- **retryReason** — why a retry happened (`network-error`, `5xx`, `429`) +- **retryDelayMs** — delay before the next retry +- **retrySource** — delay source (`backoff` or `retry-after`) + +### Example + +```ts +const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 2, + retryOn: ['5xx'], + }, + hooks: { + onRetry(ctx) { + console.log({ + requestId: ctx.requestId, + attempt: ctx.attempt, + maxAttempts: ctx.maxAttempts, + delay: ctx.retryDelayMs, + reason: ctx.retryReason, + source: ctx.retrySource, + }); + }, + }, +}); +``` + +This makes it easier to understand: + +- what happened during a request +- how retries behaved +- how long requests actually took diff --git a/docs/v1/retry.md b/docs/v1/retry.md index fd8b6ff..3273806 100644 --- a/docs/v1/retry.md +++ b/docs/v1/retry.md @@ -6,41 +6,65 @@ Retries are useful when communicating with external services that may temporaril The retry behavior can be configured globally for the client or overridden per request. ---- +Retries can be configured for: -## Basic retry configuration +- `network-error` +- `5xx` +- `429` ```ts -import { createClient } from '@dfsync/client'; - const client = createClient({ baseUrl: 'https://api.example.com', retry: { attempts: 2, + retryOn: ['network-error', '5xx', '429'], + backoff: 'exponential', + baseDelayMs: 300, }, }); ``` -If a retryable error occurs, the request will be retried up to the configured number of attempts. +## Retry-After support -## Retry conditions +When a retryable response includes a `Retry-After` header, `@dfsync/client` uses that value before falling back to the configured backoff strategy. -By default, retries happen for: +Supported formats: -- network errors -- HTTP 5xx responses +- seconds +- HTTP-date -Example: +If `Retry-After` is invalid, `@dfsync/client` falls back to normal retry backoff. + +## Observing retries + +Use `onRetry` to inspect retry behavior. ```ts const client = createClient({ baseUrl: 'https://api.example.com', retry: { - attempts: 3, + attempts: 2, + retryOn: ['5xx', '429'], + }, + hooks: { + onRetry: ({ requestId, retryReason, retryDelayMs, retrySource }) => { + console.log({ + requestId, + retryReason, + retryDelayMs, + retrySource, + }); + }, }, }); ``` +## Notes + +- `attempt` is zero-based +- `maxAttempts` is the total number of allowed attempts, including the initial request +- `requestId` remains stable across retries + ## Retry backoff Two retry strategies are supported: @@ -111,40 +135,6 @@ const client = createClient({ }); ``` -## Retry conditions configuration - -You can control which errors trigger retries. - -Supported conditions: - -- network-error -- 5xx -- 429 - -Example: - -```ts -const client = createClient({ - baseUrl: 'https://api.example.com', - retry: { - attempts: 2, - retryOn: ['network-error', '5xx', '429'], - }, -}); -``` - -## Per-request retry override - -Request-level configuration overrides the client configuration. - -```ts -await client.get('/users', { - retry: { - attempts: 1, - }, -}); -``` - ## Retry and hooks Hooks behave as follows when retries are enabled: @@ -168,12 +158,3 @@ const client = createClient({ }, }); ``` - -## Summary - -Retry is designed for **safe and predictable service-to-service communication** and works well for: - -- microservices -- external APIs -- background workers -- integration services diff --git a/src/components/Features/Features.tsx b/src/components/Features/Features.tsx index 6ffe418..7e380b7 100644 --- a/src/components/Features/Features.tsx +++ b/src/components/Features/Features.tsx @@ -3,6 +3,7 @@ import AutorenewIcon from '@mui/icons-material/Autorenew'; import LockIcon from '@mui/icons-material/Lock'; import ReplayIcon from '@mui/icons-material/Replay'; import DeviceHubIcon from '@mui/icons-material/DeviceHub'; +import InsightsIcon from '@mui/icons-material/Insights'; import BoltIcon from '@mui/icons-material/Bolt'; import { Card, CardContent, Container, Grid, Stack, Typography } from '@mui/material'; @@ -27,17 +28,23 @@ const items = [ { icon: , title: 'Retry support', - description: 'Built-in configurable retry policies for transient failures.', + description: 'Built-in retry policies with Retry-After support and full retry visibility..', }, { icon: , title: 'Lifecycle hooks', - description: 'Built-in request lifecycle hooks like beforeRequest, afterResponse, and onError.', + description: 'Lifecycle hooks including beforeRequest, afterResponse, onError, and onRetry.', + }, + { + icon: , + title: 'Observability', + description: 'Built-in request visibility with timing, retry metadata, and lifecycle insights.', }, { icon: , title: 'Predictable lifecycle', - description: 'Every request follows a clear and controllable lifecycle.', + description: + 'Every request follows a clear, controllable lifecycle with full visibility into execution and retries.', }, ]; @@ -49,9 +56,8 @@ export const Features = () => { Why @dfsync/client - A lightweight HTTP client with a predictable request lifecycle for service-to-service - communication with sensible defaults, authentication strategies, lifecycle hooks, and - retry support. + A lightweight HTTP client with a predictable request lifecycle, built-in retries, and + request observability for service-to-service communication. @@ -70,6 +76,9 @@ export const Features = () => { ))} + + Includes request timing, retry reasons, and stable request IDs across retries. + ); }; diff --git a/src/components/Hero/Hero.tsx b/src/components/Hero/Hero.tsx index a2fb07b..fc2fcdf 100644 --- a/src/components/Hero/Hero.tsx +++ b/src/components/Hero/Hero.tsx @@ -57,7 +57,7 @@ export const Hero = () => { }} > The first package, @dfsync/client, is a lightweight HTTP client built - around a predictable request lifecycle for service-to-service communication in + for reliable communication with retries, hooks, and built-in request observability in Node.js. @@ -133,12 +133,13 @@ export const Hero = () => { {`import { createClient } from '@dfsync/client'; const client = createClient({ - baseURL: 'https://api.example.com', - retry: { attempts: 3 }, -}); - -const users = await client.get('/users', { - requestId: 'req_123', + baseUrl: 'https://api.example.com', + retry: { attempts: 2, retryOn: ['5xx', '429'] }, + hooks: { + onRetry({ requestId, retryReason, retryDelayMs }) { + console.log(requestId, retryReason, retryDelayMs); + }, + }, }); `} diff --git a/src/components/Problem/Problem.tsx b/src/components/Problem/Problem.tsx index 71f65f6..b569ec2 100644 --- a/src/components/Problem/Problem.tsx +++ b/src/components/Problem/Problem.tsx @@ -23,11 +23,11 @@ export function Problem() { In most projects, HTTP clients are rebuilt again and again — with slightly different retry - logic, error handling, and request flows. + logic, error handling, and no visibility into what actually happened.

- @dfsync/client provides a predictable and reusable request lifecycle out of - the box for service-to-service communication. + @dfsync/client provides a predictable, observable, and reusable request + lifecycle out of the box for service-to-service communication.
diff --git a/src/content/docsContent.ts b/src/content/docsContent.ts index 94c667d..df76949 100644 --- a/src/content/docsContent.ts +++ b/src/content/docsContent.ts @@ -8,6 +8,7 @@ export const docsContent = { retry: () => import('../../docs/v1/retry.md?raw'), errors: () => import('../../docs/v1/errors.md?raw'), examples: () => import('../../docs/v1/examples.md?raw'), + observability: () => import('../../docs/v1/observability.md?raw'), }, } as const; diff --git a/src/content/docsNavigation.ts b/src/content/docsNavigation.ts index 594033d..7d38132 100644 --- a/src/content/docsNavigation.ts +++ b/src/content/docsNavigation.ts @@ -4,6 +4,7 @@ export const docsNavigation = [ { label: 'Create Client', slug: 'create-client' }, { label: 'Auth', slug: 'auth' }, { label: 'Hooks', slug: 'hooks' }, + { label: 'Observability', slug: 'observability' }, { label: 'Retry', slug: 'retry' }, { label: 'Errors', slug: 'errors' }, { label: 'Examples', slug: 'examples' },