const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
export function isRetryable(error: unknown, response?: Response): boolean {
if (response) {
return RETRYABLE_STATUS.has(response.status);
}
// fetch throws TypeError for network-level failures
return error instanceof TypeError;
}
export function computeDelay(attempt: number, baseMs: number, maxMs: number): number {
const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
return Math.random() * exponential; // full jitter
}
export function parseRetryAfter(response?: Response): number | undefined {
const header = response?.headers.get('retry-after');
if (!header) return undefined;
const seconds = Number(header);
if (!Number.isNaN(seconds)) {
return seconds * 1000;
}
const date = Date.parse(header);
if (!Number.isNaN(date)) {
return Math.max(0, date - Date.now());
}
return undefined;
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
import { isRetryable, computeDelay, parseRetryAfter, sleep } from './backoff';
export interface RetryOptions {
maxRetries?: number;
baseMs?: number;
maxMs?: number;
perRequestTimeoutMs?: number;
signal?: AbortSignal;
}
export async function retryFetch(
url: string,
init: RequestInit = {},
opts: RetryOptions = {},
): Promise<Response> {
const {
maxRetries = 4,
baseMs = 300,
maxMs = 10_000,
perRequestTimeoutMs = 8_000,
signal,
} = opts;
const method = init.method ?? 'GET';
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => controller.abort(), perRequestTimeoutMs);
try {
const response = await fetch(url, { ...init, method, signal: controller.signal });
if (response.ok || !isRetryable(undefined, response)) {
return response;
}
lastError = new Error(`HTTP ${response.status} on ${method} ${url}`);
if (attempt === maxRetries) break;
const wait = parseRetryAfter(response) ?? computeDelay(attempt, baseMs, maxMs);
await sleep(wait, signal);
} catch (error) {
lastError = error;
if (!isRetryable(error) || attempt === maxRetries) break;
await sleep(computeDelay(attempt, baseMs, maxMs), signal);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
throw lastError ?? new Error(`retryFetch exhausted for ${url}`);
}
import { retryFetch } from './retryFetch';
export interface Invoice {
id: string;
total: number;
}
export async function loadInvoice(id: string, signal?: AbortSignal): Promise<Invoice> {
const response = await retryFetch(`/api/invoices/${id}`, { headers: { accept: 'application/json' } }, {
maxRetries: 5,
baseMs: 250,
maxMs: 5_000,
signal,
});
if (!response.ok) {
throw new Error(`Failed to load invoice ${id}: ${response.status}`);
}
return response.json() as Promise<Invoice>;
}
async function main() {
const controller = new AbortController();
const cancel = setTimeout(() => controller.abort(), 30_000);
try {
const invoice = await loadInvoice('inv_123', controller.signal);
console.log('loaded invoice', invoice.id, invoice.total);
} catch (error) {
console.error('gave up loading invoice', error);
} finally {
clearTimeout(cancel);
}
}
void main();
This snippet implements a resilient HTTP client that retries transient failures using exponential backoff with full jitter, a technique that spreads retries randomly over a growing window to avoid the thundering-herd problem where many clients retry in lockstep and re-overload a recovering service.
In backoff.ts, computeDelay calculates the base delay as baseMs * 2 ** attempt, clamps it with Math.min against maxMs, and then applies full jitter by multiplying against Math.random(). Full jitter is preferred over fixed or equal jitter because it maximizes the spread between clients, and the cap prevents delays from growing unbounded on high retry counts. isRetryable centralizes the retry policy: network errors (a thrown TypeError from fetch) and the status codes 408, 429, and 5xx are considered transient, while 4xx client errors are not, since replaying a malformed request will never succeed.
The parseRetryAfter helper honors the server's Retry-After header, which may be either a delta in seconds or an HTTP date. When present it overrides the computed backoff, because the server's own hint about when to come back is more accurate than a blind guess.
In retryFetch.ts, retryFetch wraps the native fetch in a loop bounded by maxRetries. Each attempt gets its own AbortController wired to a perRequestTimeoutMs via setTimeout, so a single hung request cannot stall the whole retry budget; the timer is always cleared in a finally block. A caller-supplied signal is respected too, so an outer cancellation aborts the in-flight attempt and stops further retries. On a retryable outcome the code sleeps for retryAfter ?? computeDelay(...) before looping, and on a non-retryable response it returns immediately. When the budget is exhausted the last error or a synthesized one is rethrown so callers see a real failure rather than a silent undefined.
A key caveat shown here: retries are only safe for idempotent operations, so retryFetch defaults to GET and the demo in usage.ts uses it for reads. Applying blind retries to non-idempotent POST requests risks duplicate side effects unless the endpoint enforces idempotency keys. The trade-off is added latency and load in exchange for surviving brief outages without failing the user.
Share this code
Here's the card — post it anywhere.