Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<Response> {
const isFormData = typeof FormData !== 'undefined' && opts.body instanceof FormData;

Expand Down Expand Up @@ -53,17 +97,37 @@ export async function request(config: Config, opts: RequestOpts): Promise<Respon
}

const timeoutMs = (opts.timeout ?? config.timeout) * 1000;
const requestBody = opts.body
? isFormData
? (opts.body as FormData)
: JSON.stringify(opts.body)
: undefined;

let res: Response | undefined;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
res = await fetch(opts.url, {
method: opts.method ?? 'GET',
headers,
body: requestBody,
signal: opts.stream ? undefined : AbortSignal.timeout(timeoutMs),
});
} catch (error) {
if (attempt === MAX_ATTEMPTS || !isRetryableNetworkError(error)) throw error;
await waitBeforeRetry(attempt, null);
continue;
}

const res = await fetch(opts.url, {
method: opts.method ?? 'GET',
headers,
body: opts.body
? isFormData
? (opts.body as FormData)
: JSON.stringify(opts.body)
: undefined,
signal: opts.stream ? undefined : AbortSignal.timeout(timeoutMs),
});
if (!RETRYABLE_STATUSES.has(res.status) || attempt === MAX_ATTEMPTS) break;

const retryAfter = res.headers.get('retry-after');
await res.body?.cancel();
await waitBeforeRetry(attempt, retryAfter);
}

if (!res) {
throw new Error('HTTP request completed without a response');
}

if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
Expand Down
183 changes: 182 additions & 1 deletion test/client/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { requestJson } from '../../src/client/http';
import { requestJson, retryDelayMs } from '../../src/client/http';
import { CLI_VERSION } from '../../src/version';
import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server';
import type { Config } from '../../src/config/schema';
Expand All @@ -23,9 +23,11 @@ function makeConfig(baseUrl: string): Config {

describe('HTTP client', () => {
let server: MockServer;
const originalFetch = globalThis.fetch;

afterEach(() => {
server?.close();
globalThis.fetch = originalFetch;
});

it('makes authenticated GET request', async () => {
Expand Down Expand Up @@ -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<typeof fetch>) => {
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<typeof fetch>) => {
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);
});
});
Loading