← 中文资源

简体中文版本

完全抖动的退避重试调度器

使用指数退避与完全抖动计算重试延迟,支持可中止等待与尝试次数校验,避免同步重试风暴。

阅读英文原文

功能说明

该示例实现带 full jitter 的退避重试。基础延迟按指数增长并以 capMs 封顶,随后在零到该延迟之间均匀取随机值,因此大量客户端不会在同一时刻再次重试。retryWithFullJitter 负责执行操作、在失败后等待并重试,直到成功或达到最大次数;等待过程可通过 AbortSignal 取消。适合调用外部 API、消息投递与连接重建。

关键代码

fullJitterDelay 校验尝试序号、基础值、上限与随机源,计算 exponential = min(cap, base * 2^attempt),再返回 floor(random * (exponential + 1))。加一是为了让区间包含上限附近的整数值。sleep 在每次调用时注册一次性 abort 监听,定时器与监听在完成或中止时统一清理;已经中止的信号会立即拒绝。重试循环保留最后一次错误,次数耗尽后原样抛出。

使用注意

full jitter 的期望等待约为上限的一半,比等抖或装饰抖动更激进,适合对重试同步敏感但能接受较大方差的场景。只有可重试错误才应进入循环,认证失败或参数错误应直接抛出。等待被中止时抛出的是 signal.reason,可能不是 Error,调用方需区分取消与业务失败。maxAttempts 包含首次执行,设为三表示最多尝试三次。

验证方式

固定随机源为 0.5,断言每次延迟为指数上限的一半左右并在 cap 处封顶;随机源返回负值或一应抛 RangeError;让操作前两次失败第三次成功,统计调用次数与传入的 attempt 序号;达到最大次数后断言抛出最后一次错误;在等待期间中止信号,确认 Promise 以中止原因拒绝且没有继续重试。

代码示例

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