Merge branch 'exponential-backoff' into sse-and-backoff
This commit is contained in:
@@ -40,3 +40,43 @@ export class ExponentialBackoffStoppedRetriesError extends Error {
|
|||||||
this.name = 'ExponentialBackoffStoppedRetriesError';
|
this.name = 'ExponentialBackoffStoppedRetriesError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when an exponential backoff option is too small
|
||||||
|
*/
|
||||||
|
export class ExponentialBackoffNumberTooSmallError extends Error {
|
||||||
|
constructor(option: string, value: number, min: number) {
|
||||||
|
super(`Exponential backoff option "${option}" is too small. Must be at least ${min}. Received value: ${value}`);
|
||||||
|
this.name = 'ExponentialBackoffNumberTooSmallError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when an exponential backoff option is out of bounds
|
||||||
|
*/
|
||||||
|
export class ExponentialBackoffNumberOutOfBoundsError extends Error {
|
||||||
|
constructor(option: string, value: number, min: number, max: number) {
|
||||||
|
super(`Exponential backoff option "${option}" is out of bounds. Must be between ${min} and ${max}. Received value: ${value}`);
|
||||||
|
this.name = 'ExponentialBackoffNumberOutOfBoundsError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when an exponential backoff option is an invalid infinite integer
|
||||||
|
*/
|
||||||
|
export class ExponentialBackoffNumberNotFiniteError extends Error {
|
||||||
|
constructor(option: string, value: number) {
|
||||||
|
super(`Exponential backoff option "${option}" is invalid. Must be a finite number. Received value: ${value}`);
|
||||||
|
this.name = 'ExponentialBackoffNumberNotFiniteError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when an exponential backoff option is not an integer
|
||||||
|
*/
|
||||||
|
export class ExponentialBackoffNonIntegerError extends Error {
|
||||||
|
constructor(option: string, value: number) {
|
||||||
|
super(`Exponential backoff option "${option}" is invalid. Must be an integer. Received value: ${value}`);
|
||||||
|
this.name = 'ExponentialBackoffNonIntegerError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+256
-230
@@ -1,233 +1,12 @@
|
|||||||
import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts';
|
import {
|
||||||
|
ExponentialBackoffStoppedRetriesError,
|
||||||
/**
|
ExponentialBackoffMaxRetriesHitError,
|
||||||
* Exponential backoff is a technique used to retry a function after a delay.
|
ExponentialBackoffNonIntegerError,
|
||||||
*
|
ExponentialBackoffNumberTooSmallError,
|
||||||
* The delay increases exponentially with each attempt, up to a maximum delay.
|
ExponentialBackoffNumberOutOfBoundsError,
|
||||||
*
|
ExponentialBackoffNumberNotFiniteError,
|
||||||
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
|
} from './errors.ts';
|
||||||
*
|
import { isWithinBounds } from './misc.ts';
|
||||||
* 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<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 = {
|
export type ExponentialBackoffOptions = {
|
||||||
|
|
||||||
@@ -273,3 +52,250 @@ export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void;
|
|||||||
export type ExponentialBackoffCallbackParameters = {
|
export type ExponentialBackoffCallbackParameters = {
|
||||||
stopRetries: ExponentialBackoffStopRetriesFunction;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './errors.ts';
|
||||||
export * from './event-emitter.ts';
|
export * from './event-emitter.ts';
|
||||||
export * from './exponential-backoff.ts';
|
export * from './exponential-backoff.ts';
|
||||||
export * from './extended-json.ts';
|
export * from './extended-json.ts';
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Validate the value is within the bounds, returning true if it is within the bounds, false otherwise
|
||||||
|
*
|
||||||
|
* @param value - The value to validate
|
||||||
|
* @param min - The minimum value
|
||||||
|
* @param max - The maximum value
|
||||||
|
*
|
||||||
|
* @returns True if the value is within the bounds, false otherwise
|
||||||
|
*/
|
||||||
|
export const isWithinBounds = (value: number, min: number, max: number): boolean => {
|
||||||
|
if (value < min || value > max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to execute an async function and handles any errors that occur.
|
* Tries to execute an async function and handles any errors that occur.
|
||||||
* @param fn - The function to execute.
|
* @param fn - The function to execute.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
|
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
|
||||||
import { ExponentialBackoffMaxRetriesHitError, ExponentialBackoffStoppedRetriesError } from '../source/errors.ts';
|
import {
|
||||||
|
ExponentialBackoffMaxRetriesHitError,
|
||||||
|
ExponentialBackoffNumberNotFiniteError,
|
||||||
|
ExponentialBackoffStoppedRetriesError,
|
||||||
|
} from '../source/errors.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
||||||
@@ -498,7 +502,7 @@ const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be not less than 0`);
|
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -515,7 +519,7 @@ const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
jitter: value,
|
jitter: value,
|
||||||
})).toThrow('jitter must be not less than 0 or greater than 1');
|
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,7 +542,24 @@ const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void =>
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be a finite number`);
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-integer values.
|
||||||
|
*/
|
||||||
|
const testExponentialBackoffValidateOptionsRejectsNonIntegerValues = (): void => {
|
||||||
|
// Define our test cases with each value being a non-integer
|
||||||
|
const nonIntegerCases = [{ field: 'maxAttempts', value: 1.5 }] as const;
|
||||||
|
|
||||||
|
// Iterate through the test cases and expect an error to be thrown
|
||||||
|
for (const { field, value } of nonIntegerCases) {
|
||||||
|
expect(() =>
|
||||||
|
ExponentialBackoff.validateOptions({
|
||||||
|
...validExponentialBackoffOptions,
|
||||||
|
[field]: value,
|
||||||
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be an integer`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -561,10 +582,47 @@ const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be a finite number`);
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tests that calculateDelay will not result in NaN from extremely large growth rates and attempts */
|
||||||
|
const testExponentialBackoffCalculateDelayDoesNotResultInNaN = (): void => {
|
||||||
|
// Large number, 1 trillion.
|
||||||
|
// Theory being that 1 trillion to the power of 1 trillion should be a very large number and cause either an unsafe value or a NaN.
|
||||||
|
const largeNumber = 1_000_000_000_000;
|
||||||
|
|
||||||
|
// Test the calculateDelay function
|
||||||
|
const result = ExponentialBackoff.calculateDelay(
|
||||||
|
{
|
||||||
|
baseDelay: 10000,
|
||||||
|
growthRate: largeNumber,
|
||||||
|
jitter: 0,
|
||||||
|
maxDelay: 10_000,
|
||||||
|
maxAttempts: largeNumber,
|
||||||
|
},
|
||||||
|
largeNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test to ensure it was bounded to the max delay
|
||||||
|
expect(result).toBe(10_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tests that passing undefined into the constructor does not cause an error during spread */
|
||||||
|
const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (): Promise<void> => {
|
||||||
|
const options = {
|
||||||
|
baseDelay: undefined,
|
||||||
|
growthRate: undefined,
|
||||||
|
jitter: undefined,
|
||||||
|
maxDelay: undefined,
|
||||||
|
maxAttempts: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We expect an error during validation as undefined is not a finite number, not an issue with the spread operator
|
||||||
|
// @ts-expect-error - Passing undefined is allowed if the exactOptionalPropertyTypes option is set to false in TS Compiler options.
|
||||||
|
expect(() => new ExponentialBackoff(options)).toThrow(ExponentialBackoffNumberNotFiniteError);
|
||||||
|
};
|
||||||
|
|
||||||
const runTests = async (): Promise<void> => {
|
const runTests = async (): Promise<void> => {
|
||||||
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
|
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
|
||||||
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
|
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
|
||||||
@@ -589,7 +647,13 @@ const runTests = async (): Promise<void> => {
|
|||||||
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
||||||
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
||||||
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
||||||
|
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
|
||||||
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
||||||
|
test(
|
||||||
|
'ExponentialBackoff: calculateDelay does not result in NaN from extremely large growth rates and attempts',
|
||||||
|
testExponentialBackoffCalculateDelayDoesNotResultInNaN,
|
||||||
|
);
|
||||||
|
test('ExponentialBackoff: constructor does not cause an error during spread', testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread);
|
||||||
};
|
};
|
||||||
|
|
||||||
await runTests();
|
await runTests();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true
|
"declarationMap": true
|
||||||
|
|||||||
Reference in New Issue
Block a user