export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
}
export function computeDelay(attempt: number, baseMs: number, maxMs: number): number {
const cap = Math.min(maxMs, baseMs * 2 ** attempt);
return Math.floor(Math.random() * cap); // full jitter in [0, cap]
}
export function isRetryable(err: unknown): boolean {
if (err instanceof TypeError) return true; // network / DNS failure
const status = (err as { status?: number }).status;
if (status === undefined) return false;
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
export async function withRetry<T>(
op: (attempt: number) => Promise<T>,
opts: RetryOptions
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= opts.retries; attempt++) {
opts.signal?.throwIfAborted();
try {
return await op(attempt);
} catch (err) {
lastErr = err;
if (attempt === opts.retries || !isRetryable(err)) break;
const delay = computeDelay(attempt, opts.baseMs, opts.maxMs);
opts.onRetry?.(attempt, delay, err);
await sleep(delay, opts.signal);
}
}
throw lastErr;
}
import { withRetry, RetryOptions } from './backoff';
export class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}`);
this.name = 'HttpError';
}
}
async function fetchJson<T>(url: string, timeoutMs: number, signal?: AbortSignal): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
signal?.addEventListener('abort', () => controller.abort(), { once: true });
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new HttpError(res.status, await res.text());
}
return (await res.json()) as T;
} finally {
clearTimeout(timer);
}
}
export function fetchWithRetry<T>(
url: string,
opts: Partial<RetryOptions> & { timeoutMs?: number } = {}
): Promise<T> {
const { retries = 4, baseMs = 200, maxMs = 10_000, timeoutMs = 5_000, signal } = opts;
return withRetry<T>(
() => fetchJson<T>(url, timeoutMs, signal),
{
retries,
baseMs,
maxMs,
signal,
onRetry(attempt, delay, err) {
const retryAfter = err instanceof HttpError
? Number(new Headers().get('retry-after')) * 1000
: NaN;
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : delay;
console.warn(`retry #${attempt + 1} for ${url} in ${wait}ms`);
},
}
);
}
This snippet shows a small, self-contained retry layer built around the exponential backoff with full jitter strategy, the standard approach for retrying transient failures against remote services without turning a blip into a stampede. The core problem it solves is thundering herds: if many clients fail at the same instant and all wait the same fixed delay, they retry in lockstep and hammer a recovering service. Backoff spaces retries out geometrically, and jitter randomizes each wait so callers spread across the window instead of synchronizing.
In backoff.ts, computeDelay derives an exponential ceiling of baseMs * 2^attempt, clamps it to maxMs, then applies full jitter by picking a uniform random value in [0, cap]. This is the AWS-recommended variant: it trades a little worst-case latency for much better decorrelation between clients. isRetryable centralizes the policy for what deserves another attempt — network errors and the retryable status codes 408, 425, 429, and 5xx — so the decision lives in one place. sleep is a trivial promise wrapper, and withRetry is the generic driver: it loops up to retries + 1 times, awaits the operation, and on a retryable failure computes a delay and waits before the next pass. It also honors an AbortSignal so an in-flight retry sequence can be cancelled, and it rethrows immediately on non-retryable errors rather than wasting attempts.
One subtlety worth noting: the delay is computed from the upcoming attempt index, and the final failure is rethrown after the loop exhausts, preserving the original error for the caller.
In fetchWithRetry.ts, the policy is wired to a real HTTP client. fetchJson throws a typed HttpError carrying the status so isRetryable can inspect it, and it forwards a per-request AbortController timeout that is cleared in a finally. It also reads the Retry-After header when a server sends one, letting the server's own guidance override the computed jitter — a common courtesy that respects rate limiters. The withRetry call wraps this so callers simply await fetchWithRetry(url) and get bounded, jittered retries for free.
The trade-offs are deliberate: retries only make sense for idempotent operations, maxMs caps tail latency, and a retry budget prevents unbounded loops. This pattern is the right reach whenever a client talks to a flaky network, a rate-limited API, or a service that may briefly return 503 during deploys.
Related snips
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.