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
46 changes: 46 additions & 0 deletions src/lib/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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: {
Expand Down
23 changes: 21 additions & 2 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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})`,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<string, string> | undefined): boolean {
if (!headers) return false;
return Object.keys(headers).some(name => name.toLowerCase() === 'idempotency-key');
}

function apiRetryDecision(
code: ErrorCode,
attempt: number,
Expand Down
Loading