Hedged Requests With Abort And Delay

Cut tail latency by launching a second attempt after a delay, cancelling losing attempts and aggregating errors only when all fail.

TypescriptResilience

Code

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