Add exponential backoff utility

This commit is contained in:
2026-08-20 10:31:44 +00:00
committed by Kuldeep
parent 0cd615249e
commit 4e555c6ffe
12 changed files with 1664 additions and 7 deletions
@@ -0,0 +1,364 @@
import {
ExponentialBackoffStoppedRetriesError,
ExponentialBackoffMaxRetriesHitError,
ExponentialBackoffNonIntegerError,
ExponentialBackoffNumberTooSmallError,
ExponentialBackoffNumberOutOfBoundsError,
ExponentialBackoffNumberNotFiniteError,
} from './errors.ts';
import { isWithinBounds } from '../misc.ts';
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;
};
/**
* Options that control a single exponential-backoff run.
*/
export type ExponentialBackoffRunOptions = {
/**
* Called after each failed task attempt.
*/
onError?: ((error: Error) => void) | undefined;
/**
* Stops pending delays and prevents future attempts when aborted.
*/
signal?: AbortSignal | undefined;
};
/**
* Options accepted by the static exponential-backoff run helper.
*/
export type ExponentialBackoffStaticRunOptions = Partial<ExponentialBackoffOptions> & ExponentialBackoffRunOptions;
/**
* 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`.
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
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
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*
* @returns The ExponentialBackoff instance
*/
public static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param options - Backoff configuration and options for this run
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: Partial<ExponentialBackoffStaticRunOptions> = {},
): Promise<T> {
// Grab onError and signal from the options
const { onError, signal, ...backoffOptions } = options;
const backoff = ExponentialBackoff.from(backoffOptions);
return backoff.run(taskFn, { onError, signal });
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
* @throws {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
/** Validate the value is finite, throwing an {@link ExponentialBackoffInvalidInfiniteIntegerError} if the value is infinite */
const assertIsFinite = (key: string, value: number): void => {
if (!Number.isFinite(value)) {
throw new ExponentialBackoffNumberNotFiniteError(key, value);
}
};
/** Validate the value is an integer, throwing a {@link ExponentialBackoffNonIntegerError} if it is not an integer */
const assertIsInteger = (key: string, value: number): void => {
if (!Number.isInteger(value)) {
throw new ExponentialBackoffNonIntegerError(key, value);
}
};
/** Validate the value is greater than the minimum, throwing a {@link ExponentialBackoffNumberTooSmallError} if it is not */
const assertIsHigherThan = (key: string, value: number, min: number): void => {
if (value < min) {
throw new ExponentialBackoffNumberTooSmallError(key, value, min);
}
};
/** Validate the value is within the bounds, throwing a {@link ExponentialBackoffNumberOutOfBoundsError} if it is not within the bounds */
const assertIsWithinBounds = (key: string, value: number, min: number, max: number): void => {
if (!isWithinBounds(value, min, max)) {
throw new ExponentialBackoffNumberOutOfBoundsError(key, value, min, max);
}
};
// Validate the max delay
assertIsFinite('maxDelay', options.maxDelay);
assertIsHigherThan('maxDelay', options.maxDelay, 0);
// Validate the max attempts
assertIsFinite('maxAttempts', options.maxAttempts);
assertIsInteger('maxAttempts', options.maxAttempts);
assertIsHigherThan('maxAttempts', options.maxAttempts, 0);
// Validate the base delay
assertIsFinite('baseDelay', options.baseDelay);
assertIsHigherThan('baseDelay', options.baseDelay, 0);
// Validate the growth rate
assertIsFinite('growthRate', options.growthRate);
assertIsHigherThan('growthRate', options.growthRate, 0);
// Validate the jitter
assertIsFinite('jitter', options.jitter);
assertIsWithinBounds('jitter', options.jitter, 0, 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 every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown
* with all errors from the task function.
*
* @param taskFn - The function to run
* @param options - Options that control this run
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: ExponentialBackoffRunOptions = {},
): Promise<T> {
const { onError, signal: suppliedSignal } = options;
// The task can stop its own retries without owning the signal supplied by the caller.
const abortController = new AbortController();
const stopRetries = abortController.abort.bind(abortController);
// Start with the abort controller signal
const signals = [ abortController.signal ];
// If we received a signal from the caller, add it to the signals array
if (suppliedSignal !== undefined) {
signals.push(suppliedSignal);
}
// Compose the signals together
const signal = AbortSignal.any(signals);
// If the composed signal is already aborted, throw an error
if (signal.aborted) {
throw new ExponentialBackoffStoppedRetriesError(signal.reason);
}
// 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 attempts, don't append this to the errors array to prevent a memory leak.
if (!unlimitedAttempts) {
errors.push(errorInstance);
}
}
// Check if the abort signal has been activated
if (signal.aborted) {
// Throw an error if the abort signal has been activated
throw new ExponentialBackoffStoppedRetriesError(signal.reason);
}
// 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;
}
// Wait before going to the next attempt
const delay = this.#calculateDelay(this.#options, attempt);
// Wait for the delay or the abort signal
await new Promise<void>((resolve, reject) => {
// eslint-disable-next-line prefer-const
let timeout: ReturnType<typeof setTimeout>;
// Handle the abort signal
const abortHandler = (): void => {
clearTimeout(timeout);
reject(new ExponentialBackoffStoppedRetriesError(signal.reason));
};
// Handle the timeout
const timeoutHandler = (): void => {
signal.removeEventListener('abort', abortHandler);
resolve(undefined);
};
// Set the timeout
timeout = setTimeout(timeoutHandler, delay);
// Add the abort handler to the abort signal
signal.addEventListener('abort', abortHandler, { once: true });
});
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
*
* @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
*/
#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;
}
}