Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/fast-ways-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@dfsync/client': minor
---

focusing on improving observability, refining retry behavior

- Improved request observability:
- better visibility into request lifecycle
- enhanced metadata available in hooks
- Retry behavior enhancements:
- improved handling of retry conditions
- better support for Retry-After scenarios
- more predictable retry flow

Notes:

- existing APIs remain stable
- backward compatibility is preserved
- safe to upgrade from 0.6.x
11 changes: 7 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ Delivered:

**Focus**: logging, monitoring, and request insights.

Planned features:
Status: completed

Delivered:

- request duration tracking (latency)
- retry metadata (attempt number, delay, etc.)
- request timing metadata (`startedAt`, `endedAt`, `durationMs`)
- retry metadata (`attempt`, `maxAttempts`, `retryDelayMs`, `retryReason`)
- support for `Retry-After` response header
- retry lifecycle hooks (`onRetry`)
- retry lifecycle hook (`onRetry`)
- observability fields exposed in existing hook contexts

---

Expand Down
54 changes: 48 additions & 6 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,11 @@ client.request(config)
- request ID propagation (`x-request-id`)
- request cancellation via `AbortSignal`
- built-in retry with configurable policies
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onError`
- lifecycle hooks: `beforeRequest`, `afterResponse`, `onRetry`, `onError`
- request timeout support

- typed responses
- automatic JSON parsing
- consistent error handling

- auth support: bearer, API key, custom
- support for `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`

Expand All @@ -87,9 +85,10 @@ A request in `@dfsync/client` follows a predictable lifecycle:
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` for `204`)
10. run `afterResponse` or `onError` hooks
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

## Request context

Expand Down Expand Up @@ -158,6 +157,49 @@ dfsync provides structured error types:

This allows you to handle failures more precisely.

## Observability

dfsync 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

## Roadmap

See the [project roadmap](https://github.com/dfsyncjs/dfsync/blob/main/ROADMAP.md)
15 changes: 7 additions & 8 deletions packages/client/src/core/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,30 @@ export type ExecutionContext = {
url: URL;
headers: HeadersMap;
attempt: number;

// future lifecycle fields
maxAttempts: number;
requestId: string;
startedAt: number;
endedAt?: number;
durationMs?: number;
};

type CreateExecutionContextParams = {
request: RequestConfig;
url: URL;
headers: HeadersMap;
attempt: number;
maxAttempts: number;
requestId: string;
};

function generateRequestId(): string {
return Math.random().toString(36).slice(2);
}

export function createExecutionContext(params: CreateExecutionContextParams): ExecutionContext {
return {
request: params.request,
url: params.url,
headers: params.headers,
attempt: params.attempt,

requestId: params.request.requestId ?? generateRequestId(),
maxAttempts: params.maxAttempts,
requestId: params.requestId,
startedAt: Date.now(),
};
}
46 changes: 46 additions & 0 deletions packages/client/src/core/get-retry-delay-from-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HttpError } from '../errors/http-error';
import type { RetryBackoff } from '../types/config';
import { getRetryDelay } from './get-retry-delay';
import { parseRetryAfter } from './parse-retry-after';

export type RetryDelaySource = 'backoff' | 'retry-after';

type GetRetryDelayFromErrorParams = {
error: Error;
attempt: number;
backoff: RetryBackoff;
baseDelayMs: number;
};

type RetryDelayResult = {
delayMs: number;
source: RetryDelaySource;
};

export function getRetryDelayFromError({
error,
attempt,
backoff,
baseDelayMs,
}: GetRetryDelayFromErrorParams): RetryDelayResult {
if (error instanceof HttpError) {
const retryAfter = error.response.headers.get('retry-after');
const retryAfterDelayMs = parseRetryAfter(retryAfter);

if (retryAfterDelayMs !== undefined) {
return {
delayMs: retryAfterDelayMs,
source: 'retry-after',
};
}
}

return {
delayMs: getRetryDelay({
attempt,
backoff,
baseDelayMs,
}),
source: 'backoff',
};
}
23 changes: 23 additions & 0 deletions packages/client/src/core/get-retry-reason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HttpError } from '../errors/http-error';
import { NetworkError } from '../errors/network-error';
import type { RetryCondition } from '../types/config';

export function getRetryReason(error: Error): RetryCondition | undefined {
if (error instanceof NetworkError) {
return 'network-error';
}

if (error instanceof HttpError) {
const status = error.status;

if (status === 429) {
return '429';
}

if (status >= 500) {
return '5xx';
}
}

return undefined;
}
44 changes: 39 additions & 5 deletions packages/client/src/core/hook-context.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks';
import type {
AfterResponseContext,
BeforeRequestContext,
ErrorContext,
RetryContext,
} from '../types/hooks';
import type { ExecutionContext } from './execution-context';

function createLifecycleContextBase(
execution: ExecutionContext,
): Omit<BeforeRequestContext, never> {
type LifecycleContextBase = Omit<BeforeRequestContext, 'signal'> & {
signal?: AbortSignal | undefined;
};

function createLifecycleContextBase(execution: ExecutionContext): LifecycleContextBase {
return {
request: execution.request,
url: execution.url,
headers: execution.headers,
attempt: execution.attempt,
maxAttempts: execution.maxAttempts,
requestId: execution.requestId,
startedAt: execution.startedAt,
signal: execution.request.signal,
...(execution.endedAt !== undefined ? { endedAt: execution.endedAt } : {}),
...(execution.durationMs !== undefined ? { durationMs: execution.durationMs } : {}),
...(execution.request.signal !== undefined ? { signal: execution.request.signal } : {}),
};
}

Expand All @@ -37,3 +47,27 @@ export function createErrorContext(execution: ExecutionContext, error: Error): E
error,
};
}

type CreateRetryContextParams = {
execution: ExecutionContext;
error: Error;
retryDelayMs: number;
retryReason: RetryContext['retryReason'];
retrySource: RetryContext['retrySource'];
};

export function createRetryContext({
execution,
error,
retryDelayMs,
retryReason,
retrySource,
}: CreateRetryContextParams): RetryContext {
return {
...createLifecycleContextBase(execution),
error,
retryDelayMs,
retryReason,
retrySource,
};
}
41 changes: 41 additions & 0 deletions packages/client/src/core/parse-retry-after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export function parseRetryAfter(value: string | null | undefined): number | undefined {
if (value == null) {
return undefined;
}

const normalized = value.trim();

if (normalized.length === 0) {
return undefined;
}

// seconds format
if (/^\d+$/.test(normalized)) {
const seconds = Number(normalized);

if (!Number.isFinite(seconds) || seconds < 0) {
return undefined;
}

return seconds * 1000;
}

// basic HTTP-date guard (avoid parsing random strings like "1.5", "+5")
if (!normalized.includes('GMT')) {
return undefined;
}

const timestamp = Date.parse(normalized);

if (Number.isNaN(timestamp)) {
return undefined;
}

const delayMs = timestamp - Date.now();

if (delayMs < 0) {
return 0;
}

return delayMs;
}
Loading
Loading