Files
xo-cash-utils/source/exponential-backoff.ts
2026-07-19 16:59:20 +00:00

276 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (01). Default: `0.1`.
*/
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
* @returns The ExponentialBackoff instance
*/
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 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
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
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 taskFn - 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
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
): Promise<T> {
// 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 attempts, 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;
};