'use strict';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function backoffDelay(attempt, baseMs, maxDelayMs) {
const exponential = baseMs * 2 ** attempt;
const capped = Math.min(exponential, maxDelayMs);
// Full jitter in the lower half keeps a floor while spreading load.
const jitter = 0.5 + Math.random() * 0.5;
return Math.round(capped * jitter);
}
async function retry(operation, options = {}) {
const {
retries = 3,
baseMs = 200,
maxDelayMs = 5000,
shouldRetry = () => true,
onRetry = () => {},
} = options;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await operation(attempt);
} catch (err) {
lastError = err;
const isLastAttempt = attempt === retries;
if (isLastAttempt || !shouldRetry(err, attempt)) {
throw err;
}
const delay = backoffDelay(attempt, baseMs, maxDelayMs);
onRetry({ error: err, attempt, nextDelayMs: delay });
await sleep(delay);
}
}
throw lastError;
}
module.exports = { retry, backoffDelay, sleep };
'use strict';
const { retry } = require('./retry');
class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
function isRetryable(err) {
if (err.name === 'AbortError') return true; // request timed out
if (err.name === 'ApiError') return err.status >= 500;
return err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT';
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new ApiError(`Upstream responded ${res.status}`, res.status);
}
return await res.json();
} finally {
clearTimeout(timer);
}
}
async function fetchOrder(orderId) {
const url = `https://orders.internal/api/orders/${orderId}`;
return retry(() => fetchWithTimeout(url, 2000), {
retries: 4,
baseMs: 250,
maxDelayMs: 4000,
shouldRetry: isRetryable,
onRetry: ({ attempt, nextDelayMs, error }) => {
console.warn(
`fetchOrder ${orderId} failed (attempt ${attempt + 1}): ${error.message}; retrying in ${nextDelayMs}ms`
);
},
});
}
module.exports = { fetchOrder, ApiError, isRetryable };
This snippet shows a small, reusable retry helper for async operations in Node.js and a realistic caller that uses it to fetch from a flaky upstream API. The core idea is that transient failures — a dropped connection, a 503, a brief rate-limit — are best handled by waiting and trying again rather than failing the whole request. Naive retries hammer the failing service and can amplify an outage, so the delay grows exponentially with each attempt and a random jitter is added to prevent many clients from retrying in lockstep (the thundering-herd problem).
In retry.js, retry wraps any function returning a promise. It loops up to retries + 1 times, awaiting the operation inside a try/catch. On success it returns immediately. On failure it consults a shouldRetry predicate so callers can distinguish retryable errors (network blips, 5xx) from permanent ones (a 400 or a validation error) — retrying a permanent failure just wastes time. When the attempt cap is reached, the last error is re-thrown so the failure still surfaces.
The delay is computed by backoffDelay: baseMs * 2 ** attempt, clamped to maxDelayMs, then multiplied by a random factor between 0.5 and 1.0 for jitter. The sleep helper is a promisified setTimeout. An optional onRetry callback exposes each attempt for logging or metrics without coupling the helper to any logger.
In apiClient.js, fetchOrder passes a fetch closure to retry. It treats AbortError timeouts and 5xx responses as retryable via isRetryable, while a 4xx throws an ApiError that stops the loop. Each request uses AbortController with a per-attempt timeout so a hung socket does not block the whole budget.
The main trade-off is latency versus resilience: more retries improve success rates but extend worst-case response time, so retries, baseMs, and maxDelayMs should be tuned against a real timeout budget. This pattern fits idempotent reads and safe writes; non-idempotent operations need an idempotency key before retrying is safe. Keeping the mechanism (retry) separate from the policy (isRetryable) makes it easy to reuse across clients.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.