From d2036dbf274ed948a667d8b1618d17bfcd9b9026 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 08:09:55 +0100 Subject: [PATCH 01/11] normalize-error, resolve-runtime-config --- packages/client/src/core/normalize-error.ts | 15 ++++++ packages/client/src/core/request.ts | 46 ++----------------- .../client/src/core/resolve-runtime-config.ts | 39 ++++++++++++++++ 3 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 packages/client/src/core/normalize-error.ts create mode 100644 packages/client/src/core/resolve-runtime-config.ts diff --git a/packages/client/src/core/normalize-error.ts b/packages/client/src/core/normalize-error.ts new file mode 100644 index 0000000..6450414 --- /dev/null +++ b/packages/client/src/core/normalize-error.ts @@ -0,0 +1,15 @@ +import { HttpError } from '../errors/http-error'; +import { NetworkError } from '../errors/network-error'; +import { TimeoutError } from '../errors/timeout-error'; + +export function normalizeError(error: unknown, timeout: number): Error { + if (error instanceof HttpError) { + return error; + } + + if (error instanceof Error && error.name === 'AbortError') { + return new TimeoutError(timeout, error); + } + + return new NetworkError('Network request failed', error); +} diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 21b061a..2665e22 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -1,61 +1,23 @@ import { HttpError } from '../errors/http-error'; import { NetworkError } from '../errors/network-error'; -import { TimeoutError } from '../errors/timeout-error'; import type { HeadersMap } from '../types/common'; import type { ClientConfig, RetryConfig } from '../types/config'; import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; import { getRetryDelay } from './get-retry-delay'; +import { normalizeError } from './normalize-error'; import { parseResponse } from './parse-response'; +import { resolveRuntimeConfig } from './resolve-runtime-config'; import { runHooks, runHooksSafely } from './run-hooks'; import { shouldRetry } from './should-retry'; - -const DEFAULT_TIMEOUT = 5000; - -const DEFAULT_RETRY: Required = { - attempts: 0, - backoff: 'exponential', - baseDelayMs: 300, - retryOn: ['network-error', '5xx'], - retryMethods: ['GET', 'PUT', 'DELETE'], -}; - -function normalizeError(error: unknown, timeout: number): Error { - if (error instanceof HttpError) { - return error; - } - - if (error instanceof Error && error.name === 'AbortError') { - return new TimeoutError(timeout, error); - } - - return new NetworkError('Network request failed', error); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +import { sleep } from './sleep'; export async function request( clientConfig: ClientConfig, requestConfig: RequestConfig, ): Promise { - const fetchImpl = clientConfig.fetch ?? globalThis.fetch; - - if (!fetchImpl) { - throw new Error('No fetch implementation available'); - } - - const timeout = requestConfig.timeout ?? clientConfig.timeout ?? DEFAULT_TIMEOUT; - - const retry: Required = { - ...DEFAULT_RETRY, - ...(clientConfig.retry ?? {}), - ...(requestConfig.retry ?? {}), - }; + const { fetchImpl, timeout, retry } = resolveRuntimeConfig(clientConfig, requestConfig); const url = new URL(buildUrl(clientConfig.baseUrl, requestConfig.path, requestConfig.query)); diff --git a/packages/client/src/core/resolve-runtime-config.ts b/packages/client/src/core/resolve-runtime-config.ts new file mode 100644 index 0000000..d069805 --- /dev/null +++ b/packages/client/src/core/resolve-runtime-config.ts @@ -0,0 +1,39 @@ +import type { ClientConfig, RetryConfig } from '../types/config'; +import type { RequestConfig } from '../types/request'; + +const DEFAULT_TIMEOUT = 5000; + +const DEFAULT_RETRY: Required = { + attempts: 0, + backoff: 'exponential', + baseDelayMs: 300, + retryOn: ['network-error', '5xx'], + retryMethods: ['GET', 'PUT', 'DELETE'], +}; + +export type ResolvedRuntimeConfig = { + fetchImpl: typeof globalThis.fetch; + timeout: number; + retry: Required; +}; + +export function resolveRuntimeConfig( + clientConfig: ClientConfig, + requestConfig: RequestConfig, +): ResolvedRuntimeConfig { + const fetchImpl = clientConfig.fetch ?? globalThis.fetch; + + if (!fetchImpl) { + throw new Error('No fetch implementation available'); + } + + return { + fetchImpl, + timeout: requestConfig.timeout ?? clientConfig.timeout ?? DEFAULT_TIMEOUT, + retry: { + ...DEFAULT_RETRY, + ...(clientConfig.retry ?? {}), + ...(requestConfig.retry ?? {}), + }, + }; +} From cbc7dbc95fc4680a3ae3cf8565b8907dd804906d Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 08:33:50 +0100 Subject: [PATCH 02/11] add hook-context --- packages/client/src/core/hook-context.ts | 40 +++++++++++++++++ packages/client/src/core/request.ts | 56 ++++++++++++++++-------- 2 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 packages/client/src/core/hook-context.ts diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts new file mode 100644 index 0000000..56044d8 --- /dev/null +++ b/packages/client/src/core/hook-context.ts @@ -0,0 +1,40 @@ +import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks'; +import type { HeadersMap } from '../types/common'; +import type { RequestConfig } from '../types/request'; + +type HookContextBase = { + request: RequestConfig; + url: URL; + headers: HeadersMap; +}; + +export function createBeforeRequestContext(base: HookContextBase): BeforeRequestContext { + return { + request: base.request, + url: base.url, + headers: base.headers, + }; +} + +export function createAfterResponseContext( + base: HookContextBase, + response: Response, + data: T, +): AfterResponseContext { + return { + request: base.request, + url: base.url, + headers: base.headers, + response, + data, + }; +} + +export function createErrorContext(base: HookContextBase, error: Error): ErrorContext { + return { + request: base.request, + url: base.url, + headers: base.headers, + error, + }; +} diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 2665e22..625e6de 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -1,10 +1,15 @@ import { HttpError } from '../errors/http-error'; import { NetworkError } from '../errors/network-error'; import type { HeadersMap } from '../types/common'; -import type { ClientConfig, RetryConfig } from '../types/config'; +import type { ClientConfig } from '../types/config'; import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; +import { + createAfterResponseContext, + createBeforeRequestContext, + createErrorContext, +} from './hook-context'; import { getRetryDelay } from './get-retry-delay'; import { normalizeError } from './normalize-error'; import { parseResponse } from './parse-response'; @@ -37,11 +42,14 @@ export async function request( headers, }); - await runHooks(clientConfig.hooks?.beforeRequest, { - request: requestConfig, - url, - headers, - }); + await runHooks( + clientConfig.hooks?.beforeRequest, + createBeforeRequestContext({ + request: requestConfig, + url, + headers, + }), + ); let body: BodyInit | undefined; @@ -91,12 +99,17 @@ export async function request( }); if (!canRetry) { - await runHooksSafely(clientConfig.hooks?.onError, { - request: requestConfig, - url, - headers, - error, - }); + await runHooksSafely( + clientConfig.hooks?.onError, + createErrorContext( + { + request: requestConfig, + url, + headers, + }, + error, + ), + ); throw error; } @@ -113,13 +126,18 @@ export async function request( clearTimeout(timeoutId); } - await runHooks(clientConfig.hooks?.afterResponse, { - request: requestConfig, - url, - headers, - response, - data, - }); + await runHooks( + clientConfig.hooks?.afterResponse, + createAfterResponseContext( + { + request: requestConfig, + url, + headers, + }, + response, + data, + ), + ); return data as T; } From 11f257131285ed7cacabdbced25ecf60b248e679 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 08:56:47 +0100 Subject: [PATCH 03/11] execution-context --- packages/client/src/core/execution-context.ts | 25 ++++++++++++ packages/client/src/core/hook-context.ts | 33 +++++++--------- packages/client/src/core/request.ts | 39 ++++++------------- 3 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 packages/client/src/core/execution-context.ts diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts new file mode 100644 index 0000000..2e8b286 --- /dev/null +++ b/packages/client/src/core/execution-context.ts @@ -0,0 +1,25 @@ +import type { HeadersMap } from '../types/common'; +import type { RequestConfig } from '../types/request'; + +export type ExecutionContext = { + request: RequestConfig; + url: URL; + headers: HeadersMap; + attempt: number; +}; + +type CreateExecutionContextParams = { + request: RequestConfig; + url: URL; + headers: HeadersMap; + attempt: number; +}; + +export function createExecutionContext(params: CreateExecutionContextParams): ExecutionContext { + return { + request: params.request, + url: params.url, + headers: params.headers, + attempt: params.attempt, + }; +} diff --git a/packages/client/src/core/hook-context.ts b/packages/client/src/core/hook-context.ts index 56044d8..259b163 100644 --- a/packages/client/src/core/hook-context.ts +++ b/packages/client/src/core/hook-context.ts @@ -1,40 +1,33 @@ import type { AfterResponseContext, BeforeRequestContext, ErrorContext } from '../types/hooks'; -import type { HeadersMap } from '../types/common'; -import type { RequestConfig } from '../types/request'; +import type { ExecutionContext } from './execution-context'; -type HookContextBase = { - request: RequestConfig; - url: URL; - headers: HeadersMap; -}; - -export function createBeforeRequestContext(base: HookContextBase): BeforeRequestContext { +export function createBeforeRequestContext(execution: ExecutionContext): BeforeRequestContext { return { - request: base.request, - url: base.url, - headers: base.headers, + request: execution.request, + url: execution.url, + headers: execution.headers, }; } export function createAfterResponseContext( - base: HookContextBase, + execution: ExecutionContext, response: Response, data: T, ): AfterResponseContext { return { - request: base.request, - url: base.url, - headers: base.headers, + request: execution.request, + url: execution.url, + headers: execution.headers, response, data, }; } -export function createErrorContext(base: HookContextBase, error: Error): ErrorContext { +export function createErrorContext(execution: ExecutionContext, error: Error): ErrorContext { return { - request: base.request, - url: base.url, - headers: base.headers, + request: execution.request, + url: execution.url, + headers: execution.headers, error, }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 625e6de..ae4be4d 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -5,6 +5,7 @@ import type { ClientConfig } from '../types/config'; import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; +import { createExecutionContext } from './execution-context'; import { createAfterResponseContext, createBeforeRequestContext, @@ -35,6 +36,13 @@ export async function request( ...(requestConfig.headers ?? {}), }; + const execution = createExecutionContext({ + request: requestConfig, + url, + headers, + attempt, + }); + await applyAuth({ auth: clientConfig.auth, request: requestConfig, @@ -42,14 +50,7 @@ export async function request( headers, }); - await runHooks( - clientConfig.hooks?.beforeRequest, - createBeforeRequestContext({ - request: requestConfig, - url, - headers, - }), - ); + await runHooks(clientConfig.hooks?.beforeRequest, createBeforeRequestContext(execution)); let body: BodyInit | undefined; @@ -99,17 +100,7 @@ export async function request( }); if (!canRetry) { - await runHooksSafely( - clientConfig.hooks?.onError, - createErrorContext( - { - request: requestConfig, - url, - headers, - }, - error, - ), - ); + await runHooksSafely(clientConfig.hooks?.onError, createErrorContext(execution, error)); throw error; } @@ -128,15 +119,7 @@ export async function request( await runHooks( clientConfig.hooks?.afterResponse, - createAfterResponseContext( - { - request: requestConfig, - url, - headers, - }, - response, - data, - ), + createAfterResponseContext(execution, response, data), ); return data as T; From 4306b3470e91326fb4c6e348c619079a3491dad7 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 09:11:25 +0100 Subject: [PATCH 04/11] create-request-controller --- .../src/core/create-request-controller.ts | 19 +++++++++++++++++++ packages/client/src/core/request.ts | 10 ++++------ 2 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/core/create-request-controller.ts diff --git a/packages/client/src/core/create-request-controller.ts b/packages/client/src/core/create-request-controller.ts new file mode 100644 index 0000000..0136253 --- /dev/null +++ b/packages/client/src/core/create-request-controller.ts @@ -0,0 +1,19 @@ +export type RequestController = { + signal: AbortSignal; + cleanup: () => void; +}; + +export function createRequestController(timeout: number): RequestController { + const controller = new AbortController(); + + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeout); + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId); + }, + }; +} diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index ae4be4d..ec3f90a 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -6,6 +6,7 @@ import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; import { createExecutionContext } from './execution-context'; +import { createRequestController } from './create-request-controller'; import { createAfterResponseContext, createBeforeRequestContext, @@ -63,10 +64,7 @@ export async function request( } } - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, timeout); + const requestController = createRequestController(timeout); let response: Response; let data: unknown; @@ -75,7 +73,7 @@ export async function request( const init: RequestInit = { method: requestConfig.method, headers, - signal: controller.signal, + signal: requestController.signal, }; if (body !== undefined) { @@ -114,7 +112,7 @@ export async function request( await sleep(delay); continue; } finally { - clearTimeout(timeoutId); + requestController.cleanup(); } await runHooks( From 709bbaf11130f147f75a157ab7db2fc98cb9888b Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 09:14:16 +0100 Subject: [PATCH 05/11] use execution context in request --- packages/client/src/core/request.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index ec3f90a..921963d 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -46,21 +46,21 @@ export async function request( await applyAuth({ auth: clientConfig.auth, - request: requestConfig, - url, - headers, + request: execution.request, + url: execution.url, + headers: execution.headers, }); await runHooks(clientConfig.hooks?.beforeRequest, createBeforeRequestContext(execution)); let body: BodyInit | undefined; - if (requestConfig.body !== undefined) { - if (typeof requestConfig.body === 'string') { - body = requestConfig.body; + if (execution.request.body !== undefined) { + if (typeof execution.request.body === 'string') { + body = execution.request.body; } else { - headers['content-type'] = headers['content-type'] ?? 'application/json'; - body = JSON.stringify(requestConfig.body); + execution.headers['content-type'] = execution.headers['content-type'] ?? 'application/json'; + body = JSON.stringify(execution.request.body); } } @@ -71,8 +71,8 @@ export async function request( try { const init: RequestInit = { - method: requestConfig.method, - headers, + method: execution.request.method, + headers: execution.headers, signal: requestController.signal, }; @@ -80,7 +80,7 @@ export async function request( init.body = body; } - response = await fetchImpl(url.toString(), init); + response = await fetchImpl(execution.url.toString(), init); data = await parseResponse(response); if (!response.ok) { @@ -91,8 +91,8 @@ export async function request( lastError = error; const canRetry = shouldRetry({ - attempt, - method: requestConfig.method, + attempt: execution.attempt, + method: execution.request.method, retry, error, }); @@ -104,7 +104,7 @@ export async function request( } const delay = getRetryDelay({ - attempt: attempt + 1, + attempt: execution.attempt + 1, backoff: retry.backoff, baseDelayMs: retry.baseDelayMs, }); From 3d5b49a5568ee022321ef59a1c21e927bc134202 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 09:49:10 +0100 Subject: [PATCH 06/11] createRequestController --- .../src/core/create-request-controller.ts | 35 ++++++++++++++++--- packages/client/src/core/request.ts | 5 ++- packages/client/src/types/request.ts | 3 +- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/client/src/core/create-request-controller.ts b/packages/client/src/core/create-request-controller.ts index 0136253..e6cf1dd 100644 --- a/packages/client/src/core/create-request-controller.ts +++ b/packages/client/src/core/create-request-controller.ts @@ -3,17 +3,42 @@ export type RequestController = { cleanup: () => void; }; -export function createRequestController(timeout: number): RequestController { - const controller = new AbortController(); +type CreateRequestControllerParams = { + timeout: number; + signal?: AbortSignal | undefined; +}; + +export function createRequestController(params: CreateRequestControllerParams): RequestController { + const timeoutController = new AbortController(); const timeoutId = setTimeout(() => { - controller.abort(); - }, timeout); + timeoutController.abort(); + }, params.timeout); + + if (!params.signal) { + return { + signal: timeoutController.signal, + cleanup: () => { + clearTimeout(timeoutId); + }, + }; + } + + if (params.signal.aborted) { + timeoutController.abort(); + } + + const abortOnExternalSignal = () => { + timeoutController.abort(); + }; + + params.signal.addEventListener('abort', abortOnExternalSignal, { once: true }); return { - signal: controller.signal, + signal: timeoutController.signal, cleanup: () => { clearTimeout(timeoutId); + params.signal?.removeEventListener('abort', abortOnExternalSignal); }, }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index 921963d..f9be7d6 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -64,7 +64,10 @@ export async function request( } } - const requestController = createRequestController(timeout); + const requestController = createRequestController({ + timeout, + signal: execution.request.signal, + }); let response: Response; let data: unknown; diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index bfe4366..b07bc04 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -1,4 +1,4 @@ -import type { QueryParams, HeadersMap } from './common'; +import type { HeadersMap, QueryParams } from './common'; import type { RetryConfig } from './config'; export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -11,6 +11,7 @@ export type RequestConfig = { headers?: HeadersMap; timeout?: number; retry?: RetryConfig; + signal?: AbortSignal; }; export type RequestOptions = Omit; From de170cdcc18312aab5fa5d10f5f77a298526efb1 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 10:06:46 +0100 Subject: [PATCH 07/11] apply-request-metadata --- packages/client/src/core/apply-request-metadata.ts | 5 +++++ packages/client/src/core/execution-context.ts | 11 +++++++++++ packages/client/src/core/request.ts | 3 +++ 3 files changed, 19 insertions(+) create mode 100644 packages/client/src/core/apply-request-metadata.ts diff --git a/packages/client/src/core/apply-request-metadata.ts b/packages/client/src/core/apply-request-metadata.ts new file mode 100644 index 0000000..b7e33c1 --- /dev/null +++ b/packages/client/src/core/apply-request-metadata.ts @@ -0,0 +1,5 @@ +import type { ExecutionContext } from './execution-context'; + +export function applyRequestMetadata(execution: ExecutionContext): void { + execution.headers['x-request-id'] = execution.headers['x-request-id'] ?? execution.requestId; +} diff --git a/packages/client/src/core/execution-context.ts b/packages/client/src/core/execution-context.ts index 2e8b286..b93f427 100644 --- a/packages/client/src/core/execution-context.ts +++ b/packages/client/src/core/execution-context.ts @@ -6,6 +6,10 @@ export type ExecutionContext = { url: URL; headers: HeadersMap; attempt: number; + + // future lifecycle fields + requestId: string; + startedAt: number; }; type CreateExecutionContextParams = { @@ -15,11 +19,18 @@ type CreateExecutionContextParams = { attempt: number; }; +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: generateRequestId(), + startedAt: Date.now(), }; } diff --git a/packages/client/src/core/request.ts b/packages/client/src/core/request.ts index f9be7d6..bc665ea 100644 --- a/packages/client/src/core/request.ts +++ b/packages/client/src/core/request.ts @@ -5,6 +5,7 @@ import type { ClientConfig } from '../types/config'; import type { RequestConfig } from '../types/request'; import { applyAuth } from './apply-auth'; import { buildUrl } from './build-url'; +import { applyRequestMetadata } from './apply-request-metadata'; import { createExecutionContext } from './execution-context'; import { createRequestController } from './create-request-controller'; import { @@ -44,6 +45,8 @@ export async function request( attempt, }); + applyRequestMetadata(execution); + await applyAuth({ auth: clientConfig.auth, request: execution.request, From b37930db7bc6c257b3a8d07431dca913e08fc8b1 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 10:29:55 +0100 Subject: [PATCH 08/11] add tests --- .../tests/integration/abort-signal.test.ts | 72 +++++++++++++++++++ .../tests/unit/apply-request-metadata.test.ts | 47 ++++++++++++ .../unit/create-request-controller.test.ts | 70 ++++++++++++++++++ .../tests/unit/execution-context.test.ts | 40 +++++++++++ 4 files changed, 229 insertions(+) create mode 100644 packages/client/tests/integration/abort-signal.test.ts create mode 100644 packages/client/tests/unit/apply-request-metadata.test.ts create mode 100644 packages/client/tests/unit/create-request-controller.test.ts create mode 100644 packages/client/tests/unit/execution-context.test.ts diff --git a/packages/client/tests/integration/abort-signal.test.ts b/packages/client/tests/integration/abort-signal.test.ts new file mode 100644 index 0000000..80cf511 --- /dev/null +++ b/packages/client/tests/integration/abort-signal.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createClient } from '../../src/core/create-client'; +import { TimeoutError } from '../../src/errors/timeout-error'; + +describe('client abort signal', () => { + it('throws TimeoutError when request is aborted via external signal', async () => { + const fetchMock: typeof fetch = vi.fn((_input, init) => { + return new Promise((_resolve, reject) => { + const rejectWithAbortError = () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + reject(abortError); + }; + + if (init?.signal?.aborted) { + rejectWithAbortError(); + return; + } + + init?.signal?.addEventListener('abort', rejectWithAbortError, { + once: true, + }); + }); + }); + + const client = createClient({ + baseUrl: 'https://api.test.com', + timeout: 1000, + fetch: fetchMock, + }); + + const controller = new AbortController(); + + const promise = client.get('/slow', { + signal: controller.signal, + }); + + controller.abort(); + + await expect(promise).rejects.toBeInstanceOf(TimeoutError); + }); + + it('passes the external signal to fetch', async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + }, + ); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + }); + + const controller = new AbortController(); + + await client.get('/users', { + signal: controller.signal, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, init] = fetchMock.mock.calls[0]!; + expect(init?.signal).toBeDefined(); + }); +}); diff --git a/packages/client/tests/unit/apply-request-metadata.test.ts b/packages/client/tests/unit/apply-request-metadata.test.ts new file mode 100644 index 0000000..31909ba --- /dev/null +++ b/packages/client/tests/unit/apply-request-metadata.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { applyRequestMetadata } from '../../src/core/apply-request-metadata'; +import type { ExecutionContext } from '../../src/core/execution-context'; +import type { RequestConfig } from '../../src/types/request'; + +function createExecutionContext(overrides?: Partial): ExecutionContext { + const request: RequestConfig = { + method: 'GET', + path: '/users', + }; + + return { + request, + url: new URL('https://api.example.com/users'), + headers: { + accept: 'application/json', + }, + attempt: 0, + requestId: 'req-123', + startedAt: 1234567890, + ...overrides, + }; +} + +describe('applyRequestMetadata', () => { + it('adds x-request-id header when it is missing', () => { + const execution = createExecutionContext(); + + applyRequestMetadata(execution); + + expect(execution.headers['x-request-id']).toBe('req-123'); + }); + + it('does not overwrite an existing x-request-id header', () => { + const execution = createExecutionContext({ + headers: { + accept: 'application/json', + 'x-request-id': 'existing-request-id', + }, + }); + + applyRequestMetadata(execution); + + expect(execution.headers['x-request-id']).toBe('existing-request-id'); + }); +}); diff --git a/packages/client/tests/unit/create-request-controller.test.ts b/packages/client/tests/unit/create-request-controller.test.ts new file mode 100644 index 0000000..4736c52 --- /dev/null +++ b/packages/client/tests/unit/create-request-controller.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createRequestController } from '../../src/core/create-request-controller'; + +describe('createRequestController', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('aborts the signal after the timeout', () => { + vi.useFakeTimers(); + + const controller = createRequestController({ + timeout: 100, + }); + + expect(controller.signal.aborted).toBe(false); + + vi.advanceTimersByTime(100); + + expect(controller.signal.aborted).toBe(true); + + controller.cleanup(); + }); + + it('does not abort the signal after cleanup is called', () => { + vi.useFakeTimers(); + + const controller = createRequestController({ + timeout: 100, + }); + + controller.cleanup(); + + vi.advanceTimersByTime(100); + + expect(controller.signal.aborted).toBe(false); + }); + + it('aborts when the external signal is aborted', () => { + const externalController = new AbortController(); + + const controller = createRequestController({ + timeout: 1000, + signal: externalController.signal, + }); + + expect(controller.signal.aborted).toBe(false); + + externalController.abort(); + + expect(controller.signal.aborted).toBe(true); + + controller.cleanup(); + }); + + it('starts aborted when the external signal is already aborted', () => { + const externalController = new AbortController(); + externalController.abort(); + + const controller = createRequestController({ + timeout: 1000, + signal: externalController.signal, + }); + + expect(controller.signal.aborted).toBe(true); + + controller.cleanup(); + }); +}); diff --git a/packages/client/tests/unit/execution-context.test.ts b/packages/client/tests/unit/execution-context.test.ts new file mode 100644 index 0000000..e56c78c --- /dev/null +++ b/packages/client/tests/unit/execution-context.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createExecutionContext } from '../../src/core/execution-context'; +import type { HeadersMap } from '../../src/types/common'; +import type { RequestConfig } from '../../src/types/request'; + +describe('createExecutionContext', () => { + it('creates execution context with request lifecycle metadata', () => { + const request: RequestConfig = { + method: 'GET', + path: '/users', + }; + + const url = new URL('https://api.example.com/users'); + const headers: HeadersMap = { + accept: 'application/json', + }; + + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234567890); + + const execution = createExecutionContext({ + request, + url, + headers, + attempt: 2, + }); + + expect(execution.request).toBe(request); + expect(execution.url).toBe(url); + expect(execution.headers).toBe(headers); + expect(execution.attempt).toBe(2); + + expect(execution.requestId).toEqual(expect.any(String)); + expect(execution.requestId.length).toBeGreaterThan(0); + + expect(execution.startedAt).toBe(1234567890); + + dateNowSpy.mockRestore(); + }); +}); From 5bd92b8bbc99e11e9ad6acc8a0820ecbe126e2af Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 10:37:30 +0100 Subject: [PATCH 09/11] improve tests --- packages/client/tests/integration/abort-signal.test.ts | 7 ++++--- packages/client/tests/testUtils.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/client/tests/integration/abort-signal.test.ts b/packages/client/tests/integration/abort-signal.test.ts index 80cf511..d2ac1ab 100644 --- a/packages/client/tests/integration/abort-signal.test.ts +++ b/packages/client/tests/integration/abort-signal.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import { createClient } from '../../src/core/create-client'; import { TimeoutError } from '../../src/errors/timeout-error'; +import { getFirstFetchInit } from '../testUtils'; describe('client abort signal', () => { it('throws TimeoutError when request is aborted via external signal', async () => { - const fetchMock: typeof fetch = vi.fn((_input, init) => { - return new Promise((_resolve, reject) => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit): Promise => { + return new Promise((_resolve, reject) => { const rejectWithAbortError = () => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; @@ -66,7 +67,7 @@ describe('client abort signal', () => { expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = fetchMock.mock.calls[0]!; + const init = getFirstFetchInit(fetchMock); expect(init?.signal).toBeDefined(); }); }); diff --git a/packages/client/tests/testUtils.ts b/packages/client/tests/testUtils.ts index 1b1a43b..217f23a 100644 --- a/packages/client/tests/testUtils.ts +++ b/packages/client/tests/testUtils.ts @@ -14,3 +14,8 @@ export function getFirstMockCall( return firstCall; } + +export function getFirstFetchInit(mock: Mock): RequestInit { + const [, init] = getFirstMockCall<[RequestInfo | URL, RequestInit | undefined]>(mock); + return init ?? {}; +} From edd4c3df1e368d85543fa7ee6f7e053ae8f54861 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 10:45:22 +0100 Subject: [PATCH 10/11] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c36ffab..ede26f0 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,7 @@ vite.config.ts.timestamp-* .vitest .DS_Store +.idea/ .tmp/ smoke/**/node_modules/ From a07dc389469bf7c01dbae94b06e047081ffb3f06 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Wed, 18 Mar 2026 11:09:17 +0100 Subject: [PATCH 11/11] changeset minor --- .changeset/nine-wasps-fetch.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/nine-wasps-fetch.md diff --git a/.changeset/nine-wasps-fetch.md b/.changeset/nine-wasps-fetch.md new file mode 100644 index 0000000..46fc09f --- /dev/null +++ b/.changeset/nine-wasps-fetch.md @@ -0,0 +1,11 @@ +--- +'@dfsync/client': minor +--- + +Minor changes + +- refactor request lifecycle architecture +- introduce execution context +- add AbortSignal support (internal + partial public) +- extract request metadata and controller helpers +- improve testability of core modules