diff --git a/src/client/http.ts b/src/client/http.ts index c77ba79..d4edbc2 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -18,6 +18,50 @@ export interface RequestOpts { authStyle?: 'bearer' | 'x-api-key'; } +const MAX_ATTEMPTS = 3; +const BASE_RETRY_DELAY_MS = 250; +const MAX_RETRY_DELAY_MS = 30_000; +const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]); + +function parseRetryAfter(value: string | null, now: number): number | undefined { + if (value === null) return undefined; + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const date = Date.parse(value); + if (Number.isNaN(date)) return undefined; + return Math.max(0, date - now); +} + +export function retryDelayMs( + retryNumber: number, + retryAfter: string | null, + now = Date.now(), + random = Math.random(), +): number { + const serverDelay = parseRetryAfter(retryAfter, now); + if (serverDelay !== undefined) { + return Math.min(serverDelay, MAX_RETRY_DELAY_MS); + } + + const exponentialDelay = BASE_RETRY_DELAY_MS * 2 ** (retryNumber - 1); + return Math.min(exponentialDelay * Math.max(0, Math.min(random, 1)), MAX_RETRY_DELAY_MS); +} + +function isRetryableNetworkError(error: unknown): boolean { + return error instanceof TypeError || + (error instanceof DOMException && error.name === 'TimeoutError'); +} + +async function waitBeforeRetry(retryNumber: number, retryAfter: string | null): Promise { + const delay = retryDelayMs(retryNumber, retryAfter); + if (delay <= 0) return; + await new Promise(resolve => setTimeout(resolve, delay)); +} + export async function request(config: Config, opts: RequestOpts): Promise { const isFormData = typeof FormData !== 'undefined' && opts.body instanceof FormData; @@ -53,17 +97,37 @@ export async function request(config: Config, opts: RequestOpts): Promise { let server: MockServer; + const originalFetch = globalThis.fetch; afterEach(() => { server?.close(); + globalThis.fetch = originalFetch; }); it('makes authenticated GET request', async () => { @@ -93,4 +95,183 @@ describe('HTTP client', () => { requestJson(config, { url: `${server.url}/v1/test` }), ).rejects.toThrow('Rate limit'); }); + + it.each([408, 429, 500, 502, 503, 504])('retries HTTP %d and then succeeds', async (status) => { + let attempts = 0; + server = createMockServer({ + routes: { + '/v1/test': () => { + attempts++; + if (attempts < 3) { + return new Response('transient', { + status, + headers: { 'Retry-After': '0' }, + }); + } + return jsonResponse({ result: 'ok' }); + }, + }, + }); + + const result = await requestJson<{ result: string }>(makeConfig(server.url), { + url: `${server.url}/v1/test`, + }); + + expect(result.result).toBe('ok'); + expect(attempts).toBe(3); + }); + + it('stops after the bounded number of attempts', async () => { + let attempts = 0; + server = createMockServer({ + routes: { + '/v1/test': () => { + attempts++; + return new Response('unavailable', { + status: 503, + headers: { 'Retry-After': '0' }, + }); + }, + }, + }); + + await expect( + requestJson(makeConfig(server.url), { url: `${server.url}/v1/test` }), + ).rejects.toThrow('HTTP 503'); + expect(attempts).toBe(3); + }); + + it.each([400, 401, 403, 404, 409, 422])('does not retry permanent HTTP %d errors', async (status) => { + let attempts = 0; + server = createMockServer({ + routes: { + '/v1/test': () => { + attempts++; + return jsonResponse({ error: { message: 'permanent' } }, status); + }, + }, + }); + + await expect( + requestJson(makeConfig(server.url), { url: `${server.url}/v1/test` }), + ).rejects.toBeInstanceOf(Error); + expect(attempts).toBe(1); + }); + + it('retries transient network failures', async () => { + let attempts = 0; + globalThis.fetch = Object.assign( + async (...args: Parameters) => { + attempts++; + if (attempts === 1) throw new TypeError('connection reset'); + return originalFetch(...args); + }, + { preconnect: originalFetch.preconnect }, + ); + server = createMockServer({ + routes: { '/v1/test': () => jsonResponse({ result: 'ok' }) }, + }); + + const result = await requestJson<{ result: string }>(makeConfig(server.url), { + url: `${server.url}/v1/test`, + }); + + expect(result.result).toBe('ok'); + expect(attempts).toBe(2); + }); + + it('retries per-attempt timeout failures', async () => { + let attempts = 0; + globalThis.fetch = Object.assign( + async (...args: Parameters) => { + attempts++; + if (attempts === 1) throw new DOMException('timed out', 'TimeoutError'); + return originalFetch(...args); + }, + { preconnect: originalFetch.preconnect }, + ); + server = createMockServer({ + routes: { '/v1/test': () => jsonResponse({ result: 'ok' }) }, + }); + + const result = await requestJson<{ result: string }>(makeConfig(server.url), { + url: `${server.url}/v1/test`, + }); + + expect(result.result).toBe('ok'); + expect(attempts).toBe(2); + }); + + it('replays JSON request bodies on retry', async () => { + const bodies: unknown[] = []; + server = createMockServer({ + routes: { + '/v1/test': async (req) => { + bodies.push(await req.json()); + if (bodies.length === 1) { + return new Response('unavailable', { + status: 503, + headers: { 'Retry-After': '0' }, + }); + } + return jsonResponse({ result: 'ok' }); + }, + }, + }); + + await requestJson(makeConfig(server.url), { + url: `${server.url}/v1/test`, + method: 'POST', + body: { hello: 'world' }, + }); + + expect(bodies).toEqual([{ hello: 'world' }, { hello: 'world' }]); + }); + + it('replays FormData request bodies on retry', async () => { + const values: string[] = []; + server = createMockServer({ + routes: { + '/v1/test': async (req) => { + const form = await req.formData(); + values.push(String(form.get('purpose'))); + if (values.length === 1) { + return new Response('unavailable', { + status: 503, + headers: { 'Retry-After': '0' }, + }); + } + return jsonResponse({ result: 'ok' }); + }, + }, + }); + const body = new FormData(); + body.append('purpose', 'retrieval'); + + await requestJson(makeConfig(server.url), { + url: `${server.url}/v1/test`, + method: 'POST', + body, + }); + + expect(values).toEqual(['retrieval', 'retrieval']); + }); +}); + +describe('retryDelayMs', () => { + it('uses deterministic exponential backoff with injected jitter', () => { + expect(retryDelayMs(1, null, 0, 1)).toBe(250); + expect(retryDelayMs(2, null, 0, 0.5)).toBe(250); + }); + + it('supports Retry-After seconds and HTTP dates', () => { + const now = Date.parse('2026-08-05T00:00:00Z'); + expect(retryDelayMs(1, '2', now, 0)).toBe(2_000); + expect(retryDelayMs(1, 'Wed, 05 Aug 2026 00:00:03 GMT', now, 0)).toBe(3_000); + }); + + it('caps Retry-After and falls back for invalid values', () => { + expect(retryDelayMs(1, '120', 0, 0)).toBe(30_000); + expect(retryDelayMs(1, 'invalid', 0, 1)).toBe(250); + }); });