$ snippet fetch-retry-backoff
Retry fetch with exponential backoff
Retry failed requests with growing delays and jitter, only for errors that are worth retrying.
/**
* fetch with retries. Retries on network errors, 408, 429 and 5xx.
* Honors Retry-After. Delays: base * 2^attempt (+ jitter), capped at maxDelay.
*/
async function fetchWithRetry(url, options = {}, {
retries = 3, base = 300, maxDelay = 8000, signal = options.signal,
} = {}) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok || !shouldRetry(res.status)) return res;
lastError = new Error(`HTTP ${res.status}`);
if (attempt === retries) return res; // give caller the final response
await sleep(retryDelay(res, attempt, base, maxDelay), signal);
} catch (err) {
if (err.name === "AbortError") throw err; // caller cancelled: stop
lastError = err;
if (attempt === retries) throw err;
await sleep(retryDelay(null, attempt, base, maxDelay), signal);
}
}
throw lastError;
}
const shouldRetry = (status) => status === 408 || status === 429 || status >= 500;
function retryDelay(res, attempt, base, maxDelay) {
const header = res?.headers.get("Retry-After");
if (header) {
const secs = Number(header);
if (!Number.isNaN(secs)) return Math.min(secs * 1000, maxDelay);
const at = Date.parse(header);
if (!Number.isNaN(at)) return Math.max(0, Math.min(at - Date.now(), maxDelay));
}
const exp = base * 2 ** attempt;
return Math.min(maxDelay, exp / 2 + Math.random() * exp / 2); // jitter: 50–100% of exp
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason ?? new DOMException("Aborted", "AbortError")); }, { once: true });
});
}How it works
Retrying everything is a bug: a 404 or 400 will never succeed, and retrying a non-idempotent POST can create duplicate orders. This version retries only transient failures, backs off exponentially so a struggling server isn't hammered, adds jitter so many clients don't retry in lockstep, and respects Retry-After when the server tells you how long to wait.
Gotchas
fetchonly rejects on network failure; a 500 is a resolved promise withok: false. That's why the status check is explicit.- For POST/PUT that create things, add an idempotency key header so a retry doesn't duplicate work.
- A request body that is a stream can't be re-sent. Pass strings/Blobs/FormData when you plan to retry.
Ad slot