From 674e8e282d46e569e573ac5821f0cdba6dbebc6c Mon Sep 17 00:00:00 2001 From: nopp Date: Thu, 16 Jul 2026 16:29:01 +0700 Subject: [PATCH] fix(http): avoid retrying keyless writes --- src/lib/http.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/http.ts | 23 ++++++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts index a9c1193..ead4d89 100644 --- a/src/lib/http.test.ts +++ b/src/lib/http.test.ts @@ -260,6 +260,31 @@ describe('HttpClient error mapping', () => { expect(fetchImpl).toHaveBeenCalledTimes(4); }); + it('does not retry transport errors for keyless writes', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('ECONNRESET'); + }); + const client = makeClient(fetchImpl as unknown as typeof fetch); + await expect(client.post('/projects', { body: { name: 'Checkout' } })).rejects.toBeInstanceOf( + TransportError, + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('retries transport errors for writes with an idempotency key', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('ECONNRESET'); + }); + const client = makeClient(fetchImpl as unknown as typeof fetch); + await expect( + client.post('/projects', { + body: { name: 'Checkout' }, + headers: { 'Idempotency-Key': 'op_123' }, + }), + ).rejects.toBeInstanceOf(TransportError); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + it('does not retry AbortError', async () => { const fetchImpl = vi.fn(async () => { const err = new Error('aborted'); @@ -331,6 +356,27 @@ describe('HttpClient transport-edge statuses', () => { }, ); + it('does not retry bare transport-edge responses for keyless writes', async () => { + const fetchImpl = vi.fn(async () => new Response('proxy gateway html', { status: 502 })); + const client = makeClient(fetchImpl as unknown as typeof fetch); + await expect(client.post('/projects', { body: { name: 'Checkout' } })).rejects.toBeInstanceOf( + TransportError, + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('retries bare transport-edge responses for writes with an idempotency key', async () => { + const fetchImpl = vi.fn(async () => new Response('proxy gateway html', { status: 502 })); + const client = makeClient(fetchImpl as unknown as typeof fetch); + await expect( + client.post('/projects', { + body: { name: 'Checkout' }, + headers: { 'idempotency-key': 'op_123' }, + }), + ).rejects.toBeInstanceOf(TransportError); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + it('502 carrying our envelope still maps to its catalog code', async () => { const body = { error: { diff --git a/src/lib/http.ts b/src/lib/http.ts index 7a9051a..aab71bb 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -419,6 +419,7 @@ export class HttpClient { const url = buildUrl(this.baseUrl, path, options.query); const requestId = options.requestId ?? newRequestId(); + const allowTransportRetry = canRetryTransport(method, options); let attempt = 0; while (true) { @@ -472,7 +473,9 @@ export class HttpClient { errorCode: 'TRANSPORT', durationMs: Date.now() - startedAt, }); - const decision = transportRetryDecision(attempt, this.random); + const decision = allowTransportRetry + ? transportRetryDecision(attempt, this.random) + : { retry: false, delayMs: 0 }; if (!decision.retry) throw new TransportError(message, requestId); this.transition( `Network error on ${shortPath(path)} — retrying in ${Math.round(decision.delayMs / 1000)}s (attempt ${attempt})`, @@ -542,7 +545,9 @@ export class HttpClient { errorCode: 'TRANSPORT', durationMs, }); - const decision = transportRetryDecision(attempt, this.random); + const decision = allowTransportRetry + ? transportRetryDecision(attempt, this.random) + : { retry: false, delayMs: 0 }; if (!decision.retry) { throw new TransportError(`HTTP ${response.status} from ${url}`, requestId); } @@ -825,6 +830,20 @@ function transportRetryDecision(attempt: number, random: () => number): RetryDec return { retry: true, delayMs: backoffDelay(attempt, random) }; } +function canRetryTransport(method: string, options: RequestOptions): boolean { + return isIdempotentMethod(method) || hasIdempotencyKey(options.headers); +} + +function isIdempotentMethod(method: string): boolean { + const normalized = method.toUpperCase(); + return normalized === 'GET' || normalized === 'HEAD'; +} + +function hasIdempotencyKey(headers: Record | undefined): boolean { + if (!headers) return false; + return Object.keys(headers).some(name => name.toLowerCase() === 'idempotency-key'); +} + function apiRetryDecision( code: ErrorCode, attempt: number,