import { 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 added to the delay to prevent thundering herd problems. * * The growth rate is the factor by which the delay increases with each attempt. */ export class ExponentialBackoff { /** * 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 The last error if the function fails and we have hit the max attempts * * @returns The result of the function */ static run(taskFn: () => Promise, onError = (_error: Error): void => {}, options?: Partial): Promise { const backoff = ExponentialBackoff.from(options); return backoff.run(taskFn, onError); } private 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 - Random proportional variation applied to each delay. Default: `0.1`. */ constructor(options: Partial = {}) { this.options = { maxDelay: 10_000, maxAttempts: 10, baseDelay: 1_000, growthRate: 2, jitter: 0.1, ...options, }; } /** * 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 ExponentialBackoffMaxRetriesHitError with all the errors that were thrown by the task function * * @returns The result of the function */ async run(taskFn: () => Promise, onError = (_error: Error): void => {}): Promise { // Initialize an empty array to store the errors const errors: Error[] = []; let attempt = 0; // If the max attempts is 0, we should continue indefinitely. const unlimitedAttempts = this.options.maxAttempts === 0; while (attempt < this.options.maxAttempts || unlimitedAttempts) { try { return await taskFn(); } catch (error) { // Store the error in case we fail every attempt const errorInstance = error instanceof Error ? error : new Error(`${error}`); errors.push(errorInstance); onError(errorInstance); // 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); } /** * Calculate the delay before we should attempt to retry * * NOTE: The maximum delay is (maxDelay * (1 + jitter)) * * @param attempt * @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 the jitter direction. This will be between -1 and 1 const jitterDirection = 2 * Math.random() - 1; // Calculate the jitter const jitter = jitterDirection * options.jitter * cappedDelay; // Add the jitter to the delay return cappedDelay + jitter; } } 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 */ jitter: number; };