From f379035df9dc88d0274f65594a58eee136588b4f Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 08:08:58 +0200 Subject: [PATCH 01/11] update roadmap --- ROADMAP.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f9129ed..7477178 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,10 +39,11 @@ Delivered: Planned features: -- 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 --- From c37ca4a2f30e6268695016d249d026738209b6b2 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 08:44:10 +0200 Subject: [PATCH 02/11] scaffolding for request --- packages/client/src/core/execution-context.ts | 8 ++-- .../src/core/get-retry-delay-from-error.ts | 46 +++++++++++++++++++ packages/client/src/core/get-retry-reason.ts | 23 ++++++++++ packages/client/src/core/hook-context.ts | 44 ++++++++++++++++-- packages/client/src/core/parse-retry-after.ts | 35 ++++++++++++++ packages/client/src/index.ts | 1 + packages/client/src/types/hooks.ts | 17 ++++++- 7 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 packages/client/src/core/get-retry-delay-from-error.ts create mode 100644 packages/client/src/core/get-retry-reason.ts create mode 100644 packages/client/src/core/parse-retry-after.ts diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts index 2f8bd24..315158b 100644 --- a/packages/client/src/core/execution-context.ts +++ b/packages/client/src/core/execution-context.ts @@ -6,10 +6,11 @@ export type ExecutionContext = { url: URL; headers: HeadersMap; attempt: number; - - // future lifecycle fields + maxAttempts: number; requestId: string; startedAt: number; + endedAt?: number; + durationMs?: number; }; type CreateExecutionContextParams = { @@ -17,6 +18,7 @@ type CreateExecutionContextParams = { url: URL; headers: HeadersMap; attempt: number; + maxAttempts: number; }; function generateRequestId(): string { @@ -29,7 +31,7 @@ export function createExecutionContext(params: CreateExecutionContextParams): Ex url: params.url, headers: params.headers, attempt: params.attempt, - + maxAttempts: params.maxAttempts, requestId: params.request.requestId ?? generateRequestId(), startedAt: Date.now(), }; diff --git a/packages/client/src/core/get-retry-delay-from-error.ts b/packages/client/src/core/get-retry-delay-from-error.ts new file mode 100644 index 0000000..d1d5340 --- /dev/null +++ b/packages/client/src/core/get-retry-delay-from-error.ts @@ -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', + }; +} diff --git a/packages/client/src/core/get-retry-reason.ts b/packages/client/src/core/get-retry-reason.ts new file mode 100644 index 0000000..e0f475f --- /dev/null +++ b/packages/client/src/core/get-retry-reason.ts @@ -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; +} diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts index 44db6af..de50c55 100644 --- a/packages/client/src/core/hook-context.ts +++ b/packages/client/src/core/hook-context.ts @@ -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 { +type LifecycleContextBase = Omit & { + 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 } : {}), }; } @@ -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, + }; +} diff --git a/packages/client/src/core/parse-retry-after.ts b/packages/client/src/core/parse-retry-after.ts new file mode 100644 index 0000000..b7e9955 --- /dev/null +++ b/packages/client/src/core/parse-retry-after.ts @@ -0,0 +1,35 @@ +export function parseRetryAfter(value: string | null | undefined): number | undefined { + if (value == null) { + return undefined; + } + + const normalized = value.trim(); + + if (normalized.length === 0) { + return undefined; + } + + if (/^\d+$/.test(normalized)) { + const seconds = Number(normalized); + + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined; + } + + return seconds * 1000; + } + + const timestamp = Date.parse(normalized); + + if (Number.isNaN(timestamp)) { + return undefined; + } + + const delayMs = timestamp - Date.now(); + + if (delayMs < 0) { + return 0; + } + + return delayMs; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8958d98..bc57dd2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ 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 { RequestAbortedError } from './errors/request-aborted-error'; export type { AuthConfig } from './types/auth'; export type { Client } from './types/client'; diff --git a/packages/client/src/types/hooks.ts b/packages/client/src/types/hooks.ts index 367708a..fc4603f 100644 --- a/packages/client/src/types/hooks.ts +++ b/packages/client/src/types/hooks.ts @@ -1,13 +1,17 @@ import type { HeadersMap } from './common'; import type { RequestConfig } from './request'; +import type { RetryCondition } from './config'; type LifecycleContextBase = { request: RequestConfig; url: URL; headers: HeadersMap; attempt: number; + maxAttempts: number; requestId: string; startedAt: number; + endedAt?: number; + durationMs?: number; signal?: AbortSignal | undefined; }; @@ -22,14 +26,23 @@ export type ErrorContext = LifecycleContextBase & { error: Error; }; -export type HookBeforeRequest = (ctx: BeforeRequestContext) => void | Promise; +export type RetrySource = 'backoff' | 'retry-after'; -export type HookAfterResponse = (ctx: AfterResponseContext) => void | Promise; +export type RetryContext = LifecycleContextBase & { + error: Error; + retryDelayMs: number; + retryReason: RetryCondition; + retrySource: RetrySource; +}; +export type HookBeforeRequest = (ctx: BeforeRequestContext) => void | Promise; +export type HookAfterResponse = (ctx: AfterResponseContext) => void | Promise; export type HookOnError = (ctx: ErrorContext) => void | Promise; +export type HookOnRetry = (ctx: RetryContext) => void | Promise; export type HooksConfig = { beforeRequest?: HookBeforeRequest | HookBeforeRequest[]; afterResponse?: HookAfterResponse | HookAfterResponse[]; onError?: HookOnError | HookOnError[]; + onRetry?: HookOnRetry | HookOnRetry[]; }; From e0eabbab6810c1af7e3dd08050f7518cfd98be10 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 09:21:09 +0200 Subject: [PATCH 03/11] update request --- packages/client/src/core/request.ts | 42 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index e98d1da..70fad85 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -6,14 +6,18 @@ import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; import { applyRequestMetadata } from './apply-request-metadata'; +import type { ExecutionContext } from './execution-context'; import { createExecutionContext } from './execution-context'; import { createRequestController } from './create-request-controller'; import { createAfterResponseContext, createBeforeRequestContext, createErrorContext, + createRetryContext, } from './hook-context'; -import { getRetryDelay } from './get-retry-delay'; +// import { getRetryDelay } from './get-retry-delay'; +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 { resolveRuntimeConfig } from './resolve-runtime-config'; @@ -21,6 +25,13 @@ import { runHooks, runHooksSafely } from './run-hooks'; import { shouldRetry } from './should-retry'; import { sleep } from './sleep'; +function finalizeExecution(execution: ExecutionContext): void { + const endedAt = Date.now(); + + execution.endedAt = endedAt; + execution.durationMs = endedAt - execution.startedAt; +} + export async function request( clientConfig: ClientConfig, requestConfig: RequestConfig, @@ -43,6 +54,7 @@ export async function request( url, headers, attempt, + maxAttempts: retry.attempts + 1, }); applyRequestMetadata(execution); @@ -104,23 +116,47 @@ export async function request( }); if (!canRetry) { + finalizeExecution(execution); + await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); + throw error; + } + const retryReason = getRetryReason(error); + + if (!retryReason) { + finalizeExecution(execution); + + await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); throw error; } - const delay = getRetryDelay({ + const { delayMs, source } = getRetryDelayFromError({ + error, attempt: execution.attempt + 1, backoff: retry.backoff, baseDelayMs: retry.baseDelayMs, }); - await sleep(delay); + await runHooksSafely( + clientConfig.hooks?.onRetry, + createRetryContext({ + execution, + error, + retryDelayMs: delayMs, + retryReason, + retrySource: source, + }), + ); + + await sleep(delayMs); continue; } finally { requestController.cleanup(); } + finalizeExecution(execution); + await runHooks( clientConfig.hooks?.afterResponse, createAfterResponseContext(execution, response, data), From df325cbebc74bbf4d359c9c84912e3c8b1a85e21 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 09:30:19 +0200 Subject: [PATCH 04/11] update exports from index --- packages/client/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bc57dd2..1e71b92 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,5 +17,8 @@ export type { HookAfterResponse, HookBeforeRequest, HookOnError, + HookOnRetry, + RetryContext, + RetrySource, } from './types/hooks'; export type { RequestConfig, RequestMethod, RequestOptions } from './types/request'; From 2f7876d61e5cd8a79f000acef889ef5c8465b491 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 09:46:40 +0200 Subject: [PATCH 05/11] update hook tests --- .../tests/unit/execution-context.test.ts | 6 ++ .../client/tests/unit/hook-context.test.ts | 56 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/client/tests/unit/execution-context.test.ts b/packages/client/tests/unit/execution-context.test.ts index 0e8b1a6..fae8764 100644 --- a/packages/client/tests/unit/execution-context.test.ts +++ b/packages/client/tests/unit/execution-context.test.ts @@ -15,6 +15,7 @@ describe('createExecutionContext', () => { url: new URL('https://api.example.com/users'), headers: {}, attempt: 0, + maxAttempts: 3, }); expect(execution.requestId).toBe('req_custom_123'); @@ -29,6 +30,7 @@ describe('createExecutionContext', () => { url: new URL('https://api.example.com/users'), headers: {}, attempt: 0, + maxAttempts: 3, }); expect(typeof execution.requestId).toBe('string'); @@ -53,9 +55,11 @@ describe('createExecutionContext', () => { url, headers, attempt: 2, + maxAttempts: 3, }); expect(execution.attempt).toBe(2); + expect(execution.maxAttempts).toBe(3); expect(execution.request.method).toBe('GET'); expect(execution.request.path).toBe('/users'); expect(execution.url.toString()).toBe('https://api.example.com/users'); @@ -65,6 +69,8 @@ describe('createExecutionContext', () => { expect(execution.requestId.length).toBeGreaterThan(0); expect(execution.startedAt).toBe(1234567890); + expect(execution.endedAt).toBeUndefined(); + expect(execution.durationMs).toBeUndefined(); dateNowSpy.mockRestore(); }); diff --git a/packages/client/tests/unit/hook-context.test.ts b/packages/client/tests/unit/hook-context.test.ts index 3a6d89f..53050d4 100644 --- a/packages/client/tests/unit/hook-context.test.ts +++ b/packages/client/tests/unit/hook-context.test.ts @@ -4,11 +4,12 @@ import { createAfterResponseContext, createBeforeRequestContext, createErrorContext, + createRetryContext, } from '../../src/core/hook-context'; import type { ExecutionContext } from '../../src/core/execution-context'; describe('hook-context', () => { - function createExecutionContextMock(): ExecutionContext { + function createExecutionContextMock(overrides: Partial = {}): ExecutionContext { const signal = new AbortController().signal; return { @@ -23,8 +24,10 @@ describe('hook-context', () => { 'x-request-id': 'req_123', }, attempt: 1, + maxAttempts: 3, requestId: 'req_123', startedAt: 1700000000000, + ...overrides, }; } @@ -37,13 +40,19 @@ describe('hook-context', () => { expect(ctx.url).toBe(execution.url); expect(ctx.headers).toBe(execution.headers); expect(ctx.attempt).toBe(1); + expect(ctx.maxAttempts).toBe(3); expect(ctx.requestId).toBe('req_123'); expect(ctx.startedAt).toBe(1700000000000); + expect(ctx.endedAt).toBeUndefined(); + expect(ctx.durationMs).toBeUndefined(); expect(ctx.signal).toBe(execution.request.signal); }); it('creates afterResponse context with lifecycle fields', () => { - const execution = createExecutionContextMock(); + const execution = createExecutionContextMock({ + endedAt: 1700000000123, + durationMs: 123, + }); const response = new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -54,21 +63,62 @@ describe('hook-context', () => { expect(ctx.response).toBe(response); expect(ctx.data).toEqual({ ok: true }); expect(ctx.attempt).toBe(1); + expect(ctx.maxAttempts).toBe(3); expect(ctx.requestId).toBe('req_123'); expect(ctx.startedAt).toBe(1700000000000); + expect(ctx.endedAt).toBe(1700000000123); + expect(ctx.durationMs).toBe(123); expect(ctx.signal).toBe(execution.request.signal); }); it('creates error context with lifecycle fields', () => { - const execution = createExecutionContextMock(); + const execution = createExecutionContextMock({ + endedAt: 1700000000200, + durationMs: 200, + }); const error = new Error('boom'); const ctx = createErrorContext(execution, error); expect(ctx.error).toBe(error); expect(ctx.attempt).toBe(1); + expect(ctx.maxAttempts).toBe(3); + expect(ctx.requestId).toBe('req_123'); + expect(ctx.startedAt).toBe(1700000000000); + expect(ctx.endedAt).toBe(1700000000200); + expect(ctx.durationMs).toBe(200); + expect(ctx.signal).toBe(execution.request.signal); + }); + + it('creates retry context with retry metadata', () => { + const execution = createExecutionContextMock({ + endedAt: 1700000000150, + durationMs: 150, + }); + + const error = new Error('temporary failure'); + + const ctx = createRetryContext({ + execution, + error, + retryDelayMs: 500, + retryReason: '5xx', + retrySource: 'backoff', + }); + + expect(ctx.error).toBe(error); + expect(ctx.request).toBe(execution.request); + expect(ctx.url).toBe(execution.url); + expect(ctx.headers).toBe(execution.headers); + expect(ctx.attempt).toBe(1); + expect(ctx.maxAttempts).toBe(3); expect(ctx.requestId).toBe('req_123'); expect(ctx.startedAt).toBe(1700000000000); + expect(ctx.endedAt).toBe(1700000000150); + expect(ctx.durationMs).toBe(150); expect(ctx.signal).toBe(execution.request.signal); + expect(ctx.retryDelayMs).toBe(500); + expect(ctx.retryReason).toBe('5xx'); + expect(ctx.retrySource).toBe('backoff'); }); }); From d4c9683769b9eaf57ed0ffa332fef2e0dfdaf2ab Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 10:22:35 +0200 Subject: [PATCH 06/11] update tests --- packages/client/src/core/parse-retry-after.ts | 6 + .../client/tests/integration/retry.test.ts | 218 ++++++++++++++++++ .../tests/unit/apply-request-metadata.test.ts | 1 + .../unit/get-retry-delay-from-error.test.ts | 118 ++++++++++ .../tests/unit/get-retry-reason.test.ts | 33 +++ .../tests/unit/parse-retry-after.test.ts | 52 +++++ 6 files changed, 428 insertions(+) create mode 100644 packages/client/tests/unit/get-retry-delay-from-error.test.ts create mode 100644 packages/client/tests/unit/get-retry-reason.test.ts create mode 100644 packages/client/tests/unit/parse-retry-after.test.ts diff --git a/packages/client/src/core/parse-retry-after.ts b/packages/client/src/core/parse-retry-after.ts index b7e9955..2c8f26a 100644 --- a/packages/client/src/core/parse-retry-after.ts +++ b/packages/client/src/core/parse-retry-after.ts @@ -9,6 +9,7 @@ export function parseRetryAfter(value: string | null | undefined): number | unde return undefined; } + // seconds format if (/^\d+$/.test(normalized)) { const seconds = Number(normalized); @@ -19,6 +20,11 @@ export function parseRetryAfter(value: string | null | undefined): number | unde 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)) { diff --git a/packages/client/tests/integration/retry.test.ts b/packages/client/tests/integration/retry.test.ts index 28e3903..e191a45 100644 --- a/packages/client/tests/integration/retry.test.ts +++ b/packages/client/tests/integration/retry.test.ts @@ -3,6 +3,7 @@ import { createClient } from '../../src/core/create-client'; import { HttpError } from '../../src/errors/http-error'; import { NetworkError } from '../../src/errors/network-error'; import { RequestAbortedError } from '../../src/errors/request-aborted-error'; +import { getFirstFetchInit, getFirstMockCall } from '../testUtils'; describe('client retry', () => { it('retries on 503 and succeeds on the next attempt', async () => { @@ -264,4 +265,221 @@ describe('client retry', () => { await expect(promise).rejects.toBeInstanceOf(RequestAbortedError); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('calls onRetry with retry metadata before retrying', async () => { + const onRetry = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 1, + retryOn: ['5xx'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + onRetry, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + expect(onRetry).toHaveBeenCalledTimes(1); + + const [ctx] = getFirstMockCall(onRetry); + + expect(ctx.attempt).toBe(0); + expect(ctx.maxAttempts).toBe(2); + expect(ctx.retryDelayMs).toBe(100); + expect(ctx.retryReason).toBe('5xx'); + expect(ctx.retrySource).toBe('backoff'); + expect(typeof ctx.requestId).toBe('string'); + expect(typeof ctx.startedAt).toBe('number'); + expect(ctx.error).toBeInstanceOf(Error); + }); + + it('does not call onRetry when retry is not allowed', async () => { + const onRetry = vi.fn(); + + const fetchMock = vi.fn().mockResolvedValue(new Response('Bad Request', { status: 400 })); + + const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 2, + retryOn: ['5xx'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + onRetry, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).rejects.toThrow(); + + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('uses Retry-After header value instead of backoff delay', async () => { + const onRetry = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('Too Many Requests', { + status: 429, + headers: { + 'retry-after': '2', + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 1, + retryOn: ['429'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + onRetry, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + expect(onRetry).toHaveBeenCalledTimes(1); + + const [ctx] = getFirstMockCall(onRetry); + + expect(ctx.retryDelayMs).toBe(2_000); + expect(ctx.retryReason).toBe('429'); + expect(ctx.retrySource).toBe('retry-after'); + }); + + it('falls back to backoff when Retry-After header is invalid', async () => { + const onRetry = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('Too Many Requests', { + status: 429, + headers: { + 'retry-after': 'invalid', + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 1, + retryOn: ['429'], + backoff: 'fixed', + baseDelayMs: 150, + }, + hooks: { + onRetry, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + const [ctx] = getFirstMockCall(onRetry); + + expect(ctx.retryDelayMs).toBe(150); + expect(ctx.retrySource).toBe('backoff'); + }); + + it('provides timing metadata to afterResponse hook', async () => { + 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', + hooks: { + afterResponse, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + expect(afterResponse).toHaveBeenCalledTimes(1); + + const [ctx] = getFirstMockCall(afterResponse); + + expect(typeof ctx.startedAt).toBe('number'); + expect(typeof ctx.endedAt).toBe('number'); + expect(typeof ctx.durationMs).toBe('number'); + expect(ctx.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('provides timing metadata to onError hook on final failure', async () => { + const onError = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('Service Unavailable', { status: 503 })); + + const client = createClient({ + baseUrl: 'https://api.example.com', + retry: { + attempts: 0, + retryOn: ['5xx'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + onError, + }, + fetch: fetchMock, + }); + + await expect(client.get('/health')).rejects.toThrow(); + + expect(onError).toHaveBeenCalledTimes(1); + + const [ctx] = getFirstMockCall(onError); + + expect(typeof ctx.startedAt).toBe('number'); + expect(typeof ctx.endedAt).toBe('number'); + expect(typeof ctx.durationMs).toBe('number'); + expect(ctx.durationMs).toBeGreaterThanOrEqual(0); + }); }); diff --git a/packages/client/tests/unit/apply-request-metadata.test.ts b/packages/client/tests/unit/apply-request-metadata.test.ts index 31909ba..065cc33 100644 --- a/packages/client/tests/unit/apply-request-metadata.test.ts +++ b/packages/client/tests/unit/apply-request-metadata.test.ts @@ -17,6 +17,7 @@ function createExecutionContext(overrides?: Partial): Executio accept: 'application/json', }, attempt: 0, + maxAttempts: 1, requestId: 'req-123', startedAt: 1234567890, ...overrides, 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 new file mode 100644 index 0000000..f5ec53c --- /dev/null +++ b/packages/client/tests/unit/get-retry-delay-from-error.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HttpError } from '../../src/errors/http-error'; +import { NetworkError } from '../../src/errors/network-error'; +import { getRetryDelayFromError } from '../../src/core/get-retry-delay-from-error'; + +describe('getRetryDelayFromError', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses Retry-After header when present and valid', () => { + 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: 'fixed', + baseDelayMs: 200, + }); + + expect(result).toEqual({ + delayMs: 5_000, + source: 'retry-after', + }); + }); + + it('falls back to backoff when Retry-After header is invalid', () => { + const response = new Response('Too Many Requests', { + status: 429, + headers: { + 'retry-after': 'invalid-value', + }, + }); + + const error = new HttpError(response); + + const result = getRetryDelayFromError({ + error, + attempt: 2, + backoff: 'fixed', + baseDelayMs: 300, + }); + + expect(result).toEqual({ + delayMs: 300, + source: 'backoff', + }); + }); + + it('falls back to backoff when Retry-After header is missing', () => { + const response = new Response('Service Unavailable', { + status: 503, + }); + + const error = new HttpError(response); + + const result = getRetryDelayFromError({ + error, + attempt: 3, + backoff: 'exponential', + baseDelayMs: 100, + }); + + expect(result).toEqual({ + delayMs: 400, + source: 'backoff', + }); + }); + + it('uses backoff for non-HttpError errors', () => { + const error = new NetworkError('Network request failed'); + + const result = getRetryDelayFromError({ + error, + attempt: 2, + backoff: 'exponential', + baseDelayMs: 250, + }); + + expect(result).toEqual({ + delayMs: 500, + source: 'backoff', + }); + }); + + it('uses Retry-After HTTP-date when present and valid', () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-03-30T10:00:00.000Z')); + + const response = new Response('Too Many Requests', { + status: 429, + headers: { + 'retry-after': 'Mon, 30 Mar 2026 10:00:05 GMT', + }, + }); + + const error = new HttpError(response); + + const result = getRetryDelayFromError({ + error, + attempt: 1, + backoff: 'fixed', + baseDelayMs: 200, + }); + + expect(result).toEqual({ + delayMs: 5_000, + source: 'retry-after', + }); + }); +}); diff --git a/packages/client/tests/unit/get-retry-reason.test.ts b/packages/client/tests/unit/get-retry-reason.test.ts new file mode 100644 index 0000000..55f6c7a --- /dev/null +++ b/packages/client/tests/unit/get-retry-reason.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpError } from '../../src/errors/http-error'; +import { NetworkError } from '../../src/errors/network-error'; +import { getRetryReason } from '../../src/core/get-retry-reason'; + +describe('getRetryReason', () => { + it('returns network-error for NetworkError', () => { + const error = new NetworkError('Network request failed'); + + expect(getRetryReason(error)).toBe('network-error'); + }); + + it('returns 429 for HttpError with status 429', () => { + const response = new Response('Too Many Requests', { status: 429 }); + const error = new HttpError(response); + + expect(getRetryReason(error)).toBe('429'); + }); + + it('returns 5xx for HttpError with 5xx status', () => { + const response = new Response('Service Unavailable', { status: 503 }); + const error = new HttpError(response); + + expect(getRetryReason(error)).toBe('5xx'); + }); + + it('returns undefined for non-retryable errors', () => { + const error = new Error('Unknown failure'); + + expect(getRetryReason(error)).toBeUndefined(); + }); +}); diff --git a/packages/client/tests/unit/parse-retry-after.test.ts b/packages/client/tests/unit/parse-retry-after.test.ts new file mode 100644 index 0000000..6835b05 --- /dev/null +++ b/packages/client/tests/unit/parse-retry-after.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { parseRetryAfter } from '../../src/core/parse-retry-after'; + +describe('parseRetryAfter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns undefined for null', () => { + expect(parseRetryAfter(null)).toBeUndefined(); + }); + + it('returns undefined for undefined', () => { + expect(parseRetryAfter(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(parseRetryAfter('')).toBeUndefined(); + expect(parseRetryAfter(' ')).toBeUndefined(); + }); + + it('parses seconds value to milliseconds', () => { + expect(parseRetryAfter('120')).toBe(120_000); + }); + + it('returns undefined for invalid numeric format', () => { + expect(parseRetryAfter('1.5')).toBeUndefined(); + expect(parseRetryAfter('+5')).toBeUndefined(); + expect(parseRetryAfter('1e3')).toBeUndefined(); + }); + + it('returns undefined for an invalid date value', () => { + expect(parseRetryAfter('not-a-date')).toBeUndefined(); + }); + + it('parses a future HTTP-date to milliseconds', () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-03-30T10:00:00.000Z')); + + const result = parseRetryAfter('Mon, 30 Mar 2026 10:00:05 GMT'); + + expect(result).toBe(5_000); + }); + + it('returns 0 for a past HTTP-date', () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-03-30T10:00:00.000Z')); + + const result = parseRetryAfter('Mon, 30 Mar 2026 09:59:55 GMT'); + + expect(result).toBe(0); + }); +}); From 5993c6d2adde8f320edfdec137c5fdbc732032c2 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 11:35:39 +0200 Subject: [PATCH 07/11] update execution context and tests --- packages/client/src/core/execution-context.ts | 7 +- packages/client/src/core/request.ts | 2 + .../client/tests/integration/retry.test.ts | 86 ++++++++++++++++++- packages/client/tests/testUtils.ts | 14 +++ .../tests/unit/execution-context.test.ts | 43 +++++----- 5 files changed, 125 insertions(+), 27 deletions(-) diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts index 315158b..a1abf41 100644 --- a/packages/client/src/core/execution-context.ts +++ b/packages/client/src/core/execution-context.ts @@ -19,12 +19,9 @@ type CreateExecutionContextParams = { 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, @@ -32,7 +29,7 @@ export function createExecutionContext(params: CreateExecutionContextParams): Ex headers: params.headers, attempt: params.attempt, maxAttempts: params.maxAttempts, - requestId: params.request.requestId ?? generateRequestId(), + requestId: params.requestId, startedAt: Date.now(), }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 70fad85..a8d9a3e 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -41,6 +41,7 @@ export async function request( const url = new URL(buildUrl(clientConfig.baseUrl, requestConfig.path, requestConfig.query)); let lastError: Error | undefined; + const requestId = requestConfig.requestId ?? Math.random().toString(36).slice(2); for (let attempt = 0; attempt <= retry.attempts; attempt++) { const headers: HeadersMap = { @@ -55,6 +56,7 @@ export async function request( headers, attempt, maxAttempts: retry.attempts + 1, + requestId, }); applyRequestMetadata(execution); diff --git a/packages/client/tests/integration/retry.test.ts b/packages/client/tests/integration/retry.test.ts index e191a45..333a628 100644 --- a/packages/client/tests/integration/retry.test.ts +++ b/packages/client/tests/integration/retry.test.ts @@ -3,7 +3,7 @@ import { createClient } from '../../src/core/create-client'; import { HttpError } from '../../src/errors/http-error'; import { NetworkError } from '../../src/errors/network-error'; import { RequestAbortedError } from '../../src/errors/request-aborted-error'; -import { getFirstFetchInit, getFirstMockCall } from '../testUtils'; +import { getFirstMockCall, getSecondMockCall } from '../testUtils'; describe('client retry', () => { it('retries on 503 and succeeds on the next attempt', async () => { @@ -482,4 +482,88 @@ describe('client retry', () => { expect(typeof ctx.durationMs).toBe('number'); expect(ctx.durationMs).toBeGreaterThanOrEqual(0); }); + + it('calls onRetry for each retry attempt', async () => { + const onRetry = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + .mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + .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, + retryOn: ['5xx'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + onRetry, + }, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + + const [firstCtx] = getFirstMockCall(onRetry); + const [secondCtx] = getSecondMockCall(onRetry); + + expect(firstCtx.attempt).toBe(0); + expect(secondCtx.attempt).toBe(1); + expect(firstCtx.maxAttempts).toBe(3); + expect(secondCtx.maxAttempts).toBe(3); + }); + + it('keeps the same requestId across retries', async () => { + const onRetry = vi.fn(); + const beforeRequest = vi.fn(); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + .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, + retryOn: ['5xx'], + backoff: 'fixed', + baseDelayMs: 100, + }, + hooks: { + beforeRequest, + onRetry, + }, + }); + + await expect(client.get('/health')).resolves.toEqual({ ok: true }); + + expect(beforeRequest).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + + const [beforeFirst] = getFirstMockCall(beforeRequest); + const [beforeSecond] = getSecondMockCall(beforeRequest); + const [retryCtx] = getFirstMockCall(onRetry); + + expect(beforeFirst.requestId).toBe(beforeSecond.requestId); + expect(beforeFirst.requestId).toBe(retryCtx.requestId); + }); }); diff --git a/packages/client/tests/testUtils.ts b/packages/client/tests/testUtils.ts index 217f23a..926319f 100644 --- a/packages/client/tests/testUtils.ts +++ b/packages/client/tests/testUtils.ts @@ -15,6 +15,20 @@ export function getFirstMockCall( return firstCall; } +export function getSecondMockCall( + mockFn: Mock<(...args: TArgs) => unknown>, +): TArgs { + const secondCall = mockFn.mock.calls[1]; + + expect(secondCall).toBeDefined(); + + if (!secondCall) { + throw new Error('Expected mock to have been called at least two times'); + } + + return secondCall; +} + export function getFirstFetchInit(mock: Mock): RequestInit { const [, init] = getFirstMockCall<[RequestInfo | URL, RequestInit | undefined]>(mock); return init ?? {}; diff --git a/packages/client/tests/unit/execution-context.test.ts b/packages/client/tests/unit/execution-context.test.ts index fae8764..1632248 100644 --- a/packages/client/tests/unit/execution-context.test.ts +++ b/packages/client/tests/unit/execution-context.test.ts @@ -5,7 +5,7 @@ import type { HeadersMap } from '../../src/types/common'; import type { RequestConfig } from '../../src/types/request'; describe('createExecutionContext', () => { - it('uses request.requestId when provided', () => { + it('uses provided requestId', () => { const execution = createExecutionContext({ request: { method: 'GET', @@ -16,27 +16,12 @@ describe('createExecutionContext', () => { headers: {}, attempt: 0, maxAttempts: 3, + requestId: 'req_custom_123', }); expect(execution.requestId).toBe('req_custom_123'); }); - it('generates requestId when request.requestId is not provided', () => { - const execution = createExecutionContext({ - request: { - method: 'GET', - path: '/users', - }, - url: new URL('https://api.example.com/users'), - headers: {}, - attempt: 0, - maxAttempts: 3, - }); - - expect(typeof execution.requestId).toBe('string'); - expect(execution.requestId.length).toBeGreaterThan(0); - }); - it('creates execution context with request lifecycle metadata', () => { const request: RequestConfig = { method: 'GET', @@ -56,6 +41,7 @@ describe('createExecutionContext', () => { headers, attempt: 2, maxAttempts: 3, + requestId: 'req_custom_123', }); expect(execution.attempt).toBe(2); @@ -64,14 +50,29 @@ describe('createExecutionContext', () => { expect(execution.request.path).toBe('/users'); expect(execution.url.toString()).toBe('https://api.example.com/users'); expect(execution.headers).toBe(headers); - - expect(execution.requestId).toEqual(expect.any(String)); - expect(execution.requestId.length).toBeGreaterThan(0); - + expect(execution.requestId).toBe('req_custom_123'); expect(execution.startedAt).toBe(1234567890); expect(execution.endedAt).toBeUndefined(); expect(execution.durationMs).toBeUndefined(); dateNowSpy.mockRestore(); }); + + it('stores request and execution requestId independently', () => { + const execution = createExecutionContext({ + request: { + method: 'GET', + path: '/users', + requestId: 'req_from_request', + }, + url: new URL('https://api.example.com/users'), + headers: {}, + attempt: 0, + maxAttempts: 1, + requestId: 'req_from_execution', + }); + + expect(execution.request.requestId).toBe('req_from_request'); + expect(execution.requestId).toBe('req_from_execution'); + }); }); From 668eb35cf4186e90bb352c968d6484bf58250177 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 14:46:10 +0200 Subject: [PATCH 08/11] remove commented lines --- packages/client/src/core/request.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index a8d9a3e..bc60020 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -15,7 +15,6 @@ import { createErrorContext, createRetryContext, } from './hook-context'; -// import { getRetryDelay } from './get-retry-delay'; import { getRetryDelayFromError } from './get-retry-delay-from-error'; import { getRetryReason } from './get-retry-reason'; import { normalizeError } from './normalize-error'; From 178555f0073ac6d3a74ff0b560d67adf8aae8f36 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 30 Mar 2026 16:09:42 +0200 Subject: [PATCH 09/11] update readme --- packages/client/README.md | 54 ++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index f79582a..ab7a27b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -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` @@ -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 @@ -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) From 0635c40931c7fa8e0f4a225da6383d07c8d781a9 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Fri, 3 Apr 2026 18:39:20 +0200 Subject: [PATCH 10/11] changeset: release 0.7.x --- .changeset/fast-ways-cough.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/fast-ways-cough.md diff --git a/.changeset/fast-ways-cough.md b/.changeset/fast-ways-cough.md new file mode 100644 index 0000000..13e21a0 --- /dev/null +++ b/.changeset/fast-ways-cough.md @@ -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 From 9dd26809de5c9046c19419f72cdd99dc32eebd07 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Fri, 3 Apr 2026 18:41:54 +0200 Subject: [PATCH 11/11] update ROADMAP --- ROADMAP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7477178..222cb7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,9 @@ Delivered: **Focus**: logging, monitoring, and request insights. -Planned features: +Status: completed + +Delivered: - request timing metadata (`startedAt`, `endedAt`, `durationMs`) - retry metadata (`attempt`, `maxAttempts`, `retryDelayMs`, `retryReason`)