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.
Code
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();
});
}Report a problem with this exampleOpens a pre-filled issue on GitHub.