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.

TypescriptResilience

Code

snippet
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;
}