Building Resilient HTTP Clients in Distributed Systems
A practical guide to implementing exponential backoff, jitter, timeout budgets, and circuit breaker patterns for fault-tolerant network communication.
In distributed architectures, transient network failures are not exceptions—they are statistical certainties. External APIs experience momentary latency spikes, load balancers drop idle sockets, and downstream services restart.
Without defensive client design, a single sluggish endpoint can cascade, exhausting connection pools and taking down entire upstream services.
This tutorial guides you through implementing four foundational resiliency patterns in TypeScript:
- Strict Timeout Budgets
- Exponential Backoff with Full Jitter
- Idempotency Awareness
- Lightweight Circuit Breaking
1. Timeout Budgets with AbortSignal
Never issue a network request without an explicit timeout. In modern Node.js and browser runtimes, AbortSignal.timeout() provides clean cancellation:
async function fetchWithBudget<T>(
url: string,
timeoutMs: number = 3000
): Promise<T> {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
return (await response.json()) as T;
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
}
throw err;
}
}
2. Exponential Backoff with Full Jitter
When a service is under load, retrying requests at fixed intervals causes the thundering herd problem. Full jitter spreads retries randomly across the backoff interval:
$$\text{sleep} = \text{random}(0, \min(M, B \times 2^i))$$
Here is the implementation:
interface RetryOptions {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxRetries: 3,
baseDelayMs: 200,
maxDelayMs: 5000,
};
export async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: Partial<RetryOptions> = {}
): Promise<T> {
const { maxRetries, baseDelayMs, maxDelayMs } = {
...DEFAULT_RETRY_OPTIONS,
...options,
};
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > maxRetries) {
throw err;
}
// Calculate exponential backoff with full jitter
const exponential = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitterDelay = Math.floor(Math.random() * exponential);
console.warn(`[Retry] Attempt ${attempt} failed. Retrying in ${jitterDelay}ms...`);
await new Promise((resolve) => setTimeout(resolve, jitterDelay));
}
}
}
3. Retrying Only Idempotent Requests
Not all HTTP methods are safe to retry. In general:
GET,HEAD,PUT,DELETEare semantically idempotent.POSTandPATCHare non-idempotent unless accompanied by an explicit Idempotency-Key header.
| Status Code | Action | Safe for Automatic Retry? |
|---|---|---|
408 Request Timeout |
Retry with jitter | Yes |
429 Too Many Requests |
Respect Retry-After header |
Yes |
500 Internal Error |
Log and inspect; caution | Only on idempotent methods |
502 Bad Gateway |
Retry with jitter | Yes |
503 Service Unavailable |
Respect Retry-After header |
Yes |
504 Gateway Timeout |
Retry with jitter | Yes |
400 / 401 / 403 / 404 |
Terminal client error | No |
4. Summary and Next Steps
Building resilient systems requires treating network instability as a first-class operational state. By pairing strict timeouts with jittered exponential retries, you protect both your own clients and the downstream services you depend upon.