import {isAbortError, catchAbortError, AbortError} from './AbortError'; import {delay} from './delay'; import {execute} from './execute'; export type ProactiveRetryOptions = { /** * Base delay between attempts in milliseconds. * * Defaults to 1000. * * Example: if `baseMs` is 100, then retries will be attempted in 100ms, * 200ms, 400ms etc (not counting jitter). */ baseMs?: number; /** * Maximum for the total number of attempts. * * Defaults to `Infinity`. */ maxAttempts?: number; /** * Called after each failed attempt. * * Rethrow error from this callback to prevent further retries. */ onError?: (error: unknown, attempt: number) => void; }; /** * Proactively retry a function with exponential backoff. * * Also known as hedging. * * The function will be called multiple times in parallel until it succeeds, in * which case all the other calls will be aborted. */ export function proactiveRetry( signal: AbortSignal, fn: (signal: AbortSignal, attempt: number) => Promise, options: ProactiveRetryOptions = {}, ): Promise { const {baseMs = 1000, onError, maxAttempts = Infinity} = options; return execute(signal, (resolve, reject) => { const innerAbortController = new AbortController(); let attemptsExhausted = false; const promises = new Map>(); function handleFulfilled(value: T) { innerAbortController.abort( new AbortError('One of the proactiveRetry() attempts fulfilled', false), ); promises.clear(); resolve(value); } function handleRejected(err: unknown, attempt: number) { promises.delete(attempt); if (attemptsExhausted && promises.size === 0) { reject(err); return; } if (isAbortError(err)) { return; } if (onError) { try { onError(err, attempt); } catch (err) { innerAbortController.abort( new AbortError( 'Error was thrown from proactiveRetry() onError callback', false, ), ); promises.clear(); reject(err); } } } async function makeAttempts(signal: AbortSignal) { for (let attempt = 0; ; attempt++) { const promise = fn(signal, attempt); promises.set(attempt, promise); promise.then(handleFulfilled, err => handleRejected(err, attempt)); if (attempt + 1 >= maxAttempts) { break; } // https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/ const backoff = Math.pow(2, attempt) * baseMs; const delayMs = Math.round((backoff * (1 + Math.random())) / 2); await delay(signal, delayMs); } attemptsExhausted = true; } makeAttempts(innerAbortController.signal).catch(catchAbortError); return reason => { innerAbortController.abort(reason ?? new AbortError()); }; }); }