import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts'; /** * Exponential backoff is a technique used to retry a function after a delay. * * The delay increases exponentially with each attempt, up to a maximum delay. * * The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems. * * The growth rate is the factor by which the delay increases with each attempt. */ export class ExponentialBackoff { readonly #options: ExponentialBackoffOptions; /** * Creates a new exponential-backoff instance. * * Unspecified options use the defaults listed below. * * @param options - Exponential-backoff configuration overrides. * @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms. * @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`. * @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms. * @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`. * @param options.jitter - Maximum proportional reduction subtracted from each delay (0–1). Default: `0.1`. */ constructor(options: Partial = {}) { this.#options = { maxDelay: 10_000, maxAttempts: 10, baseDelay: 1_000, growthRate: 2, jitter: 0.1, ...options, }; ExponentialBackoff.validateOptions(this.#options); } /** * Create a new ExponentialBackoff instance * * @param config - The configuration for the exponential backoff * @returns The ExponentialBackoff instance */ static from(config?: Partial): ExponentialBackoff { const backoff = new ExponentialBackoff(config); return backoff; } /** * Run the function with exponential backoff * * @param fn - The function to run * @param onError - The callback to call when an error occurs * @param options - The configuration for the exponential backoff * * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function * * @returns The result of the function */ static run( taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, onError = (_error: Error): void => {}, options?: Partial, ): Promise { const backoff = ExponentialBackoff.from(options); return backoff.run(taskFn, onError); } /** * Calculate the delay before we should attempt to retry * * @param options - The configuration for the exponential backoff * @param attempt - The current attempt number * @returns The time in milliseconds before another attempt should be made */ public static calculateDelay(options: ExponentialBackoffOptions, attempt: number): number { // Get the power of the growth rate const power = options.growthRate ** attempt; // Get the delay before jitter or limit const rawDelay = options.baseDelay * power; // Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay const cappedDelay = Math.min(rawDelay, options.maxDelay); // Get a random number for the amount to "jitter" the delay by const jitterAmount = Math.random(); // Calculate the jitter const jitter = jitterAmount * options.jitter * cappedDelay; // Subtract the jitter from the delay return cappedDelay - jitter; } /** * Validate the options for the exponential backoff * * @param options - The options to validate * * @throws An error if the options are invalid */ public static validateOptions(options: ExponentialBackoffOptions): void { // Validate the max delay is a finite number not less than 0 if (!Number.isFinite(options.maxDelay)) { throw new Error('maxDelay must be a finite number'); } if (options.maxDelay < 0) { throw new Error('maxDelay must be not less than 0'); } // Validate the max attempts is a finite number not less than 0 if (!Number.isFinite(options.maxAttempts)) { throw new Error('maxAttempts must be a finite number'); } if (options.maxAttempts < 0) { throw new Error('maxAttempts must be not less than 0'); } // Validate the base delay is a finite number not less than 0 if (!Number.isFinite(options.baseDelay)) { throw new Error('baseDelay must be a finite number'); } if (options.baseDelay < 0) { throw new Error('baseDelay must be not less than 0'); } // Validate the growth rate is a finite number not less than 0 if (!Number.isFinite(options.growthRate)) { throw new Error('growthRate must be a finite number'); } if (options.growthRate < 0) { throw new Error('growthRate must be not less than 0'); } // Validate the jitter is a finite number not less than 0 or greater than 1 if (!Number.isFinite(options.jitter)) { throw new Error('jitter must be a finite number'); } if (options.jitter < 0 || options.jitter > 1) { throw new Error('jitter must be not less than 0 or greater than 1'); } } /** * Run the function with exponential backoff * * If the function fails but we have not hit the max attempts, the error will be passed to the onError callback * and the function will be retried with an exponential delay * * If the function fails and we have hit the max attempts, an ExponentialBackoffMaxRetriesHitError will be thrown with all the errors that were thrown by the task function * * @param fn - The function to run * @param onError - The callback to call when an error occurs * * @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function * * @returns The result of the function */ async run( taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise, onError = (_error: Error): void => {}, ): Promise { // Initialize an abort signal to allow the task function to be aborted const abortController = new AbortController(); const stopRetries = abortController.abort.bind(abortController); // Initialize an empty array to store the errors const errors: Error[] = []; // Initialize the attempt counter let attempt = 0; // If the max attempts is 0, we should continue indefinitely. const unlimitedAttempts = this.#options.maxAttempts === 0; // Loop until we succeed, hit the max attempts, or the abort signal is activated while (true) { try { // Await the promise before returning so its execution context remains in the try-catch // If we didn't await, this `run` function would successfully return and any errors would not be caught here. return await taskFn({ stopRetries }); } catch (error) { // Store the error in case we fail every attempt const errorInstance = error instanceof Error ? error : new Error(`${error}`); onError(errorInstance); // If we have unlimited attemps, don't append this to the errors array to prevent a memory leak. if (!unlimitedAttempts) { errors.push(errorInstance); } } // Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt. const nextAttemptCount = attempt + 1; const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts; // If the next attempt exceeds the max attempts, break out of the loop if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) { break; } // Check if the abort signal has been aborted if (abortController.signal.aborted) { // Throw an error if the abort signal has been aborted throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason); } // Wait before going to the next attempt const delay = ExponentialBackoff.calculateDelay(this.#options, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); attempt++; } // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got throw new ExponentialBackoffMaxRetriesHitError(errors); } } export type ExponentialBackoffOptions = { /** * The maximum delay between attempts in milliseconds */ maxDelay: number; /** * The maximum number of attempts. Passing 0 will result in infinite attempts. */ maxAttempts: number; /** * The base delay between attempts in milliseconds */ baseDelay: number; /** * The growth rate of the delay */ growthRate: number; /** * The jitter of the delay as a percentage of growthRate. The jitter is subtracted from the delay. */ jitter: number; }; /** * The function to call to stop the retries. * This mimics the AbortSignal.abort function by taking in a reason for stopping * * @param reason - The reason for stopping the retries. */ export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void; /** * The parameters for the task function * * @param stopRetries - The function to call to stop the retries */ export type ExponentialBackoffCallbackParameters = { stopRetries: ExponentialBackoffStopRetriesFunction; };