302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
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;
|
||
};
|
||
|
||
/**
|
||
* 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 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
|
||
*/
|
||
public 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 {@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 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
|
||
*/
|
||
public 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 activated
|
||
if (abortController.signal.aborted) {
|
||
// Throw an error if the abort signal has been activated
|
||
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);
|
||
}
|
||
}
|