Full Jitter Backoff Scheduler For Retries
Schedule retry delays with exponential backoff and full jitter, abortable sleeps and attempt validation to avoid synchronized retry storms.
Code
export interface BackoffOptions {
baseMs?: number;
capMs?: number;
maxAttempts?: number;
random?: () => number;
signal?: AbortSignal;
}
function positive(value: number, name: string): void {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(name + " must be a positive finite number");
}
}
export function fullJitterDelay(attempt: number, options: BackoffOptions = {}): number {
if (!Number.isInteger(attempt) || attempt < 0) {
throw new RangeError("attempt must be a non-negative integer");
}
const baseMs = options.baseMs ?? 100;
const capMs = options.capMs ?? 20_000;
const random = options.random ?? Math.random;
positive(baseMs, "baseMs");
positive(capMs, "capMs");
if (typeof random !== "function") throw new TypeError("random must be a function");
const exponential = Math.min(capMs, baseMs * 2 ** attempt);
const sample = random();
if (!(sample >= 0 && sample < 1)) {
throw new RangeError("random must return a value in [0, 1)");
}
return Math.floor(sample * (exponential + 1));
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = (): void => {
cleanup();
reject(signal?.reason ?? new Error("backoff aborted"));
};
function cleanup(): void {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function retryWithFullJitter<T>(
operation: (attempt: number) => Promise<T>,
options: BackoffOptions = {},
): Promise<T> {
if (typeof operation !== "function") throw new TypeError("operation must be a function");
const maxAttempts = options.maxAttempts ?? 5;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new RangeError("maxAttempts must be a positive integer");
}
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await operation(attempt);
} catch (error) {
lastError = error;
if (attempt === maxAttempts - 1) break;
const delay = fullJitterDelay(attempt, options);
await sleep(delay, options.signal);
}
}
throw lastError;
}Report a problem with this exampleOpens a pre-filled issue on GitHub.