← 中文资源

简体中文版本

带取消与延迟的对冲请求

延迟后启动第二个尝试以降低尾延迟,取消落败请求,并在全部失败时聚合错误。

阅读英文原文

功能说明

该示例实现对冲请求:先发起第一次尝试,若在 delayMs 内没有结果,再并行发起第二次,谁先成功就采用谁并取消其余尝试。只有当所有尝试都失败时才拒绝,并使用 AggregateError 保留每个错误。适用于对尾延迟敏感的只读接口,通过少量额外请求换取更稳定的响应时间。

关键代码

每个尝试拥有独立的 AbortController,统一收集到 Set 中,任一尝试成功或外部取消时在 cleanup 中全部中止,避免后台请求继续消耗资源。启动过程由 setTimeout 串联,定时器也集中清理。成功回调通过 finish 保证只结算一次;失败回调累计 settled,只有已启动数量达到 maxAttempts 且全部失败时才聚合拒绝。外部信号在入口先检查,并在 Promise 内注册一次性监听。

使用注意

对冲只适合幂等请求,写操作重复执行可能造成副作用。启动第二个请求会增加下游负载,delayMs 应基于目标接口的延迟分位数设置,maxAttempts 不宜过大。取消是协作式的,attempt 必须把 AbortSignal 传给 fetch 等可取消 API,否则落败请求仍会继续执行。AggregateError 在旧运行时可能不存在,需要兼容时应改为自定义错误。

验证方式

用可控 Promise 模拟尝试:第一次永不解决,第二次成功后断言整体成功且第一次的 signal 被中止;让两次都失败,断言收到 AggregateError 且包含两个错误;外部信号在启动前已中止时立即拒绝;在延迟期间中止,断言不会启动第二次;测试负 delayMs 与非整数 maxAttempts 抛 RangeError。

代码示例

export interface HedgedRequestOptions {
  delayMs: number;
  maxAttempts?: number;
  signal?: AbortSignal;
}

export async function hedgedRequest<T>(
  attempt: (signal: AbortSignal, attemptIndex: number) => Promise<T>,
  options: HedgedRequestOptions,
): Promise<T> {
  if (typeof attempt !== "function") throw new TypeError("attempt must be a function");
  if (!Number.isFinite(options.delayMs) || options.delayMs < 0) {
    throw new RangeError("delayMs must be a non-negative finite number");
  }
  const maxAttempts = options.maxAttempts ?? 2;
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
    throw new RangeError("maxAttempts must be a positive integer");
  }
  const external = options.signal;
  if (external?.aborted) throw external.reason;

  return new Promise<T>((resolve, reject) => {
    const controllers = new Set<AbortController>();
    const timers = new Set<ReturnType<typeof setTimeout>>();
    const failures: unknown[] = [];
    let launched = 0;
    let settled = 0;
    let finished = false;

    const cleanup = (): void => {
      for (const timer of timers) clearTimeout(timer);
      timers.clear();
      external?.removeEventListener("abort", onExternalAbort);
      for (const controller of controllers) controller.abort();
      controllers.clear();
    };

    const finish = (callback: () => void): void => {
      if (finished) return;
      finished = true;
      cleanup();
      callback();
    };

    const onExternalAbort = (): void => {
      finish(() => reject(external?.reason ?? new Error("hedged request aborted")));
    };

    const launch = (): void => {
      if (finished || launched >= maxAttempts) return;
      const index = launched;
      launched += 1;
      const controller = new AbortController();
      controllers.add(controller);
      if (external?.aborted) controller.abort(external.reason);

      Promise.resolve()
        .then(() => attempt(controller.signal, index))
        .then(
          (value) => finish(() => resolve(value)),
          (error) => {
            if (finished) return;
            failures.push(error);
            settled += 1;
            if (settled === launched && launched === maxAttempts) {
              finish(() => reject(new AggregateError(failures, "all hedged attempts failed")));
            }
          },
        );

      if (index + 1 < maxAttempts) {
        const timer = setTimeout(() => {
          timers.delete(timer);
          launch();
        }, options.delayMs);
        timers.add(timer);
      }
    };

    external?.addEventListener("abort", onExternalAbort, { once: true });
    launch();
  });
}