From 717a32dbc5b63142b191af814aa36d1146f80a89 Mon Sep 17 00:00:00 2001 From: Roman Onishchenko Date: Mon, 27 Apr 2026 13:46:22 +0200 Subject: [PATCH] feat(client): add idempotency key support --- .../client/src/core/apply-request-metadata.ts | 5 ++ packages/client/src/types/request.ts | 1 + .../client/tests/integration/headers.test.ts | 67 +++++++++++++++++++ .../tests/unit/apply-request-metadata.test.ts | 40 +++++++++++ 4 files changed, 113 insertions(+) diff --git a/packages/client/src/core/apply-request-metadata.ts b/packages/client/src/core/apply-request-metadata.ts index b7e33c1..acf9c92 100644 --- a/packages/client/src/core/apply-request-metadata.ts +++ b/packages/client/src/core/apply-request-metadata.ts @@ -2,4 +2,9 @@ import type { ExecutionContext } from './execution-context'; export function applyRequestMetadata(execution: ExecutionContext): void { execution.headers['x-request-id'] = execution.headers['x-request-id'] ?? execution.requestId; + + if (execution.request.idempotencyKey) { + execution.headers['idempotency-key'] = + execution.headers['idempotency-key'] ?? execution.request.idempotencyKey; + } } diff --git a/packages/client/src/types/request.ts b/packages/client/src/types/request.ts index 9b709ea..1a93cd9 100644 --- a/packages/client/src/types/request.ts +++ b/packages/client/src/types/request.ts @@ -13,6 +13,7 @@ export type RequestConfig = { retry?: RetryConfig; signal?: AbortSignal; requestId?: string; + idempotencyKey?: string; validateResponse?: ResponseValidator; }; diff --git a/packages/client/tests/integration/headers.test.ts b/packages/client/tests/integration/headers.test.ts index 147c95e..4e99c71 100644 --- a/packages/client/tests/integration/headers.test.ts +++ b/packages/client/tests/integration/headers.test.ts @@ -187,4 +187,71 @@ describe('request headers', () => { expect(typeof requestId).toBe('string'); expect(requestId!.length).toBeGreaterThan(0); }); + + it('propagates idempotencyKey to idempotency-key header', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + }); + + await client.post( + '/payments', + { + amount: 100, + }, + { + idempotencyKey: 'idem_123', + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const init = getFirstFetchInit(fetchMock); + const headers = init.headers as Record; + + expect(headers['idempotency-key']).toBe('idem_123'); + }); + + it('prefers explicit idempotency-key header over idempotencyKey option', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); + + const client = createClient({ + baseUrl: 'https://api.test.com', + fetch: fetchMock, + }); + + await client.post( + '/payments', + { + amount: 100, + }, + { + idempotencyKey: 'idem_from_option', + headers: { + 'idempotency-key': 'idem_from_header', + }, + }, + ); + + const init = getFirstFetchInit(fetchMock); + const headers = init.headers as Record; + + expect(headers['idempotency-key']).toBe('idem_from_header'); + }); }); diff --git a/packages/client/tests/unit/apply-request-metadata.test.ts b/packages/client/tests/unit/apply-request-metadata.test.ts index 065cc33..d124a18 100644 --- a/packages/client/tests/unit/apply-request-metadata.test.ts +++ b/packages/client/tests/unit/apply-request-metadata.test.ts @@ -45,4 +45,44 @@ describe('applyRequestMetadata', () => { expect(execution.headers['x-request-id']).toBe('existing-request-id'); }); + + it('adds idempotency-key header when idempotencyKey is provided', () => { + const execution = createExecutionContext({ + request: { + method: 'POST', + path: '/payments', + idempotencyKey: 'idem-123', + }, + }); + + applyRequestMetadata(execution); + + expect(execution.headers['idempotency-key']).toBe('idem-123'); + }); + + it('does not overwrite an existing idempotency-key header', () => { + const execution = createExecutionContext({ + request: { + method: 'POST', + path: '/payments', + idempotencyKey: 'idem-from-option', + }, + headers: { + accept: 'application/json', + 'idempotency-key': 'idem-from-header', + }, + }); + + applyRequestMetadata(execution); + + expect(execution.headers['idempotency-key']).toBe('idem-from-header'); + }); + + it('does not add idempotency-key header when idempotencyKey is not provided', () => { + const execution = createExecutionContext(); + + applyRequestMetadata(execution); + + expect(execution.headers).not.toHaveProperty('idempotency-key'); + }); });