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
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "@xo-cash/utils",
"version": "0.0.3",
"version": "0.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xo-cash/utils",
"version": "0.0.3",
"version": "0.0.4",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
@@ -8197,9 +8197,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -8217,7 +8217,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@xo-cash/utils",
"version": "0.0.3",
"version": "0.0.4",
"description": "XO Cash utilities",
"type": "module",
"types": "./dist/index.d.mts",
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable max-classes-per-file */
/**
* Error thrown when the maximum number of retries is hit in an exponential backoff
*/
export class ExponentialBackoffMaxRetriesHitError extends Error {
constructor(errors: Array<Error>) {
super('Exponential backoff: Max retries hit', { cause: errors });
this.name = 'ExponentialBackoffMaxRetriesHitError';
}
}
/**
* Error thrown when the exponential backoff retries are stopped
*/
export class ExponentialBackoffStoppedRetriesError extends Error {
constructor(reason: unknown) {
// Convert the reason to an error if it is not an error
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
super(`Exponential backoff was aborted: "${reasonError.message}"`, { cause: reasonError });
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';
}
}
/**
* Error thrown when an externally aborted exponential backoff is aborted
* due to the external abort signal that was passed in to the constructor being aborted by an upstream consumer
*/
export class ExternallyAbortedExponentialBackoffExternalSignalAbortedError extends Error {
constructor(reason: unknown) {
// Convert the reason to an error if it is not an error
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
super(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError });
this.name = 'ExternallyAbortedExponentialBackoffExternalSignalAbortedError';
}
}
/**
* Error thrown when an externally aborted exponential backoff is aborted
* due to the internal abort signal being aborted using the .abort() method
*/
export class ExternallyAbortedExponentialBackoffInternalSignalAbortedError extends Error {
constructor(reason: unknown) {
// Convert the reason to an error if it is not an error
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
super(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError });
this.name = 'ExternallyAbortedExponentialBackoffInternalSignalAbortedError';
}
}
@@ -0,0 +1,96 @@
import {
ExternallyAbortedExponentialBackoffExternalSignalAbortedError,
ExternallyAbortedExponentialBackoffInternalSignalAbortedError,
} from './errors.ts';
import { ExponentialBackoff } from './exponential-backoff.ts';
import type { ExponentialBackoffCallbackParameters, ExponentialBackoffOptions, ExponentialBackoffRunOptions } from './exponential-backoff.ts';
/**
* Options for the ExponentialBackoffExternallyAbortable class
*
* @extends Partial<ExponentialBackoffOptions>
* @property abortSignal - The abort signal to use for this instance
*/
export type ExponentialBackoffExternallyAbortableOptions = Partial<ExponentialBackoffOptions> & {
abortSignal: AbortSignal;
};
/**
* An exponential backoff that can be stopped by calling `.abort()` or by passing an
* `abortSignal` to the constructor.
*
* @remarks One instance can run many tasks. Aborting it stops retries for every run.
*/
export class ExponentialBackoffExternallyAbortable {
readonly #abortController = new AbortController();
readonly #exponentialBackoff: ExponentialBackoff;
constructor(options: Partial<ExponentialBackoffExternallyAbortableOptions> = {}) {
const { abortSignal, ...backoffOptions } = options;
this.#exponentialBackoff = new ExponentialBackoff(backoffOptions);
// Listen for the provided abort signal to be aborted
abortSignal?.addEventListener(
'abort',
() => {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
},
{
once: true,
// Clean up this listener if the internal abort controller is aborted
signal: this.#abortController.signal,
},
);
// If the abort signal is already aborted, immediately abort the internal abort controller
if (abortSignal?.aborted) {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
}
}
/**
* 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
* @throws An {@link ExternallyAbortedExponentialBackoffExternalSignalAbortedError}
* if the abort signal that was provided during construction is activated
* @throws An {@link ExternallyAbortedExponentialBackoffInternalSignalAbortedError}
* if {@link ExponentialBackoffExternallyAbortable.abort} is called on this class
*
* @returns The result of the function
*/
public run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: Partial<ExponentialBackoffRunOptions> = {},
): Promise<T> {
let signal = this.#abortController.signal;
// If a signal was provided for this run, compose it with the instance-wide signal
if (options.signal !== undefined) {
signal = AbortSignal.any([ signal, options.signal ]);
}
// Call the exponential backoff run method with the composed signal
return this.#exponentialBackoff.run(taskFn, { ...options, signal });
}
/**
* Stops retries for all current and future runs.
*
* @param reason - The reason for stopping retries
*/
public abort(reason: unknown): void {
this.#abortController.abort(new ExternallyAbortedExponentialBackoffInternalSignalAbortedError(reason));
}
}
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './errors.ts';
export * from './exponential-backoff.ts';
export * from './exponential-backoff-externally-aborted.ts';
+2
View File
@@ -1,4 +1,6 @@
export * from './errors.ts';
export * from './event-emitter.ts';
export * from './exponential-backoff/index.ts';
export * from './extended-json.ts';
export * from './script.ts';
export * from './sse-session/index.ts';
+17
View File
@@ -20,3 +20,20 @@ export const deepFreeze = <T>(value: T): DeeplyReadonly<T> => {
return value;
};
/**
* 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;
};
@@ -0,0 +1,307 @@
import { expect, test, vi } from 'vitest';
import { ExponentialBackoffExternallyAbortable } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts';
import type { ExponentialBackoffExternallyAbortableOptions } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts';
import {
ExponentialBackoffNumberTooSmallError,
ExponentialBackoffStoppedRetriesError,
ExternallyAbortedExponentialBackoffExternalSignalAbortedError,
ExternallyAbortedExponentialBackoffInternalSignalAbortedError,
} from '../../source/exponential-backoff/errors.ts';
import { testExponentialBackoff } from './exponential-backoff-run.shared.ts';
/**
* Tests that invalid constructor options are still rejected by the inner ExponentialBackoff.
*/
const testExternallyAbortedExponentialBackoffConstructorValidatesOptions = (): void => {
expect(() => new ExponentialBackoffExternallyAbortable({ maxAttempts: -1 })).toThrow(ExponentialBackoffNumberTooSmallError);
};
/**
* Tests that {@link ExponentialBackoffExternallyAbortable.abort} before `run` prevents the task from
* ever being invoked and rejects with a stopped-retries error whose cause is the internal abort error.
*/
const testExternallyAbortedExponentialBackoffAbortBeforeRun = async (): Promise<void> => {
const taskFn = vi.fn(async () => 'should not run');
const onError = vi.fn();
const abortReason = 'stopped by abort()';
const backoff = new ExponentialBackoffExternallyAbortable({
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
// Abort before any attempt so the wrapper should short-circuit on the first inner call.
backoff.abort(abortReason);
try {
await backoff.run(taskFn, { onError });
expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError);
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason));
}
// The user task must not run; the wrapper throws before forwarding to it.
expect(taskFn).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that {@link ExponentialBackoffExternallyAbortable.abort} during a retry delay takes effect
* immediately without calling the task again.
*/
const testExternallyAbortedExponentialBackoffAbortBetweenAttempts = async (): Promise<void> => {
vi.useFakeTimers();
try {
const taskFn = vi.fn(async () => {
throw new Error('retry me');
});
const onError = vi.fn();
const abortReason = new Error('stopped between attempts');
const backoff = new ExponentialBackoffExternallyAbortable({
baseDelay: 1_000,
jitter: 0,
maxAttempts: 0,
});
const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error);
// First attempt runs immediately and fails, then a 1000ms delay is scheduled.
await Promise.resolve();
expect(taskFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
backoff.abort(abortReason);
const error = await errorPromise;
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError);
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason);
// The user task ran only on the first attempt; the aborted second attempt never forwarded to it.
expect(taskFn).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0].message).toBe('retry me');
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
};
/**
* Tests that a constructor `abortSignal` that is already aborted prevents the task from running.
*/
const testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted = async (): Promise<void> => {
const taskFn = vi.fn(async () => 'should not run');
const onError = vi.fn();
const abortReason = 'signal already aborted';
const abortController = new AbortController();
abortController.abort(abortReason);
const backoff = new ExponentialBackoffExternallyAbortable({
abortSignal: abortController.signal,
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
try {
await backoff.run(taskFn, { onError });
expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError);
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason));
}
expect(taskFn).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that aborting the constructor `abortSignal` interrupts the current retry delay.
*/
const testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts = async (): Promise<void> => {
vi.useFakeTimers();
try {
const taskFn = vi.fn(async () => {
throw new Error('retry me');
});
const onError = vi.fn();
const abortReason = new Error('signal aborted between attempts');
const abortController = new AbortController();
const backoff = new ExponentialBackoffExternallyAbortable({
abortSignal: abortController.signal,
baseDelay: 1_000,
jitter: 0,
maxAttempts: 0,
});
const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error);
await Promise.resolve();
expect(taskFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
abortController.abort(abortReason);
const error = await errorPromise;
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError);
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason);
expect(taskFn).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0].message).toBe('retry me');
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
};
/**
* Tests that a single {@link ExponentialBackoffExternallyAbortable.abort} stops every concurrent `run` on that instance.
*/
const testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns = async (): Promise<void> => {
vi.useFakeTimers();
try {
const firstTaskFn = vi.fn(async () => {
throw new Error('first run failed');
});
const secondTaskFn = vi.fn(async () => {
throw new Error('second run failed');
});
const abortReason = new Error('stop all runs');
// One instance is shared across concurrent runs, matching the class remark that abort stops all of them.
const backoff = new ExponentialBackoffExternallyAbortable({
baseDelay: 1_000,
jitter: 0,
maxAttempts: 0,
});
// Handle both rejections before aborting the shared instance.
const firstErrorPromise = backoff.run(firstTaskFn).catch((error: unknown) => error);
const secondErrorPromise = backoff.run(secondTaskFn).catch((error: unknown) => error);
await Promise.resolve();
expect(firstTaskFn).toHaveBeenCalledTimes(1);
expect(secondTaskFn).toHaveBeenCalledTimes(1);
backoff.abort(abortReason);
await expect(firstErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
await expect(secondErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect(firstTaskFn).toHaveBeenCalledTimes(1);
expect(secondTaskFn).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
};
/**
* Tests that a signal supplied for one run composes with the instance-wide abort sources.
*/
const testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay = async (): Promise<void> => {
vi.useFakeTimers();
try {
const abortReason = new Error('stop this run');
const abortController = new AbortController();
const taskFn = vi.fn().mockRejectedValue(new Error('retry me'));
const backoff = new ExponentialBackoffExternallyAbortable({
baseDelay: 1_000,
jitter: 0,
maxAttempts: 0,
});
const errorPromise = backoff.run(taskFn, { signal: abortController.signal }).catch((error: unknown) => error);
await Promise.resolve();
expect(taskFn).toHaveBeenCalledOnce();
abortController.abort(abortReason);
const error = await errorPromise;
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBe(abortReason);
expect(taskFn).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
};
/**
* Tests that a successful run is unaffected by a later abort of the shared instance.
*/
const testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort = async (): Promise<void> => {
const taskFn = vi.fn(async () => 'success');
const backoff = new ExponentialBackoffExternallyAbortable({
baseDelay: 0,
jitter: 0,
});
const result = await backoff.run(taskFn);
expect(result).toBe('success');
expect(taskFn).toHaveBeenCalledOnce();
// Aborting after success must not throw retroactively; it only affects subsequent runs.
backoff.abort(new Error('too late'));
};
const runTests = async (): Promise<void> => {
const factory = (options?: Partial<ExponentialBackoffExternallyAbortableOptions>): ExponentialBackoffExternallyAbortable => {
return new ExponentialBackoffExternallyAbortable(options);
};
// Shared retry/delay/stopRetries contract, run against an adapter around instance run.
testExponentialBackoff('ExternallyAbortedExponentialBackoff', factory);
// Behavior that only exists on the externally-aborted wrapper.
test(
'ExternallyAbortedExponentialBackoff: constructor still validates options',
testExternallyAbortedExponentialBackoffConstructorValidatesOptions,
);
test(
'ExternallyAbortedExponentialBackoff: abort before run stops without calling the task',
testExternallyAbortedExponentialBackoffAbortBeforeRun,
);
test(
'ExternallyAbortedExponentialBackoff: abort between attempts interrupts the delay',
testExternallyAbortedExponentialBackoffAbortBetweenAttempts,
);
test(
'ExternallyAbortedExponentialBackoff: abortSignal already aborted stops without calling the task',
testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted,
);
test(
'ExternallyAbortedExponentialBackoff: abortSignal between attempts interrupts the delay',
testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts,
);
test('ExternallyAbortedExponentialBackoff: abort stops every concurrent run', testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns);
test(
'ExternallyAbortedExponentialBackoff: a per-run signal interrupts the delay',
testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay,
);
test(
'ExternallyAbortedExponentialBackoff: successful run is unaffected by a later abort',
testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort,
);
};
await runTests();
@@ -0,0 +1,516 @@
import { describe, expect, test, vi } from 'vitest';
import type {
ExponentialBackoffCallbackParameters,
ExponentialBackoffOptions,
ExponentialBackoffRunOptions,
} from '../../source/exponential-backoff/index.ts';
import {
ExponentialBackoffMaxRetriesHitError,
ExponentialBackoffNumberNotFiniteError,
ExponentialBackoffStoppedRetriesError,
} from '../../source/exponential-backoff/index.ts';
/**
* The common run interface implemented by ExponentialBackoff and ExponentialBackoffExternallyAbortable.
*/
export type ExponentialBackoffInstance = {
run: <T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options?: Partial<ExponentialBackoffRunOptions>,
) => Promise<T>;
};
/**
* Factory function passed into the tests to create an {@link ExponentialBackoffInstance}.
*/
export type ExponentialBackoffFactory = (options?: Partial<ExponentialBackoffOptions>) => ExponentialBackoffInstance;
/**
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
*/
const testExponentialBackoffSucceedsOnFirstAttempt = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Always resolves — never enters the catch/retry branch.
const resolveFn = vi.fn(async () => 'success');
const onError = vi.fn();
const options = {
baseDelay: 0,
jitter: 0,
};
const result = await exponentialBackoffFactory(options).run(resolveFn, { onError });
expect(result).toBe('success');
expect(resolveFn).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that retries continue across multiple failures until the function eventually resolves.
*/
const testExponentialBackoffRetriesUntilSuccess = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Three invocations: two rejections then a success on the third call.
const tripleRejectFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
const options = {
baseDelay: 0,
jitter: 0,
maxAttempts: 5,
};
const result = await exponentialBackoffFactory(options).run(tripleRejectFn);
expect(result).toBe('success');
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
};
/**
* Tests that the onError callback is invoked once for every failed attempt, including the last one
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
*/
const testExponentialBackoffCallsOnErrorForEachFailure = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
const error = new Error('temporary failure');
// Always rejects with the same error — we will exhaust all attempts.
const rejectFn = vi.fn().mockRejectedValue(error);
const onError = vi.fn();
// maxAttempts: 3 means three tries total, all of which will fail.
const options = {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
};
await expect(exponentialBackoffFactory(options).run(rejectFn, { onError })).rejects.toThrow(ExponentialBackoffMaxRetriesHitError);
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(error);
};
/**
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
* with every task error preserved in order on the cause.
*/
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
const firstError = new Error('first');
const lastError = new Error('last');
// Two distinct errors so we can prove both are collected, not just the last one.
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(lastError);
try {
const options = {
baseDelay: 0,
jitter: 0,
maxAttempts: 2,
};
await exponentialBackoffFactory(options).run(doubleRejectFn, { onError: vi.fn() });
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]);
}
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
* so callers always observe a consistent error type in the callback.
*/
const testExponentialBackoffWrapsNonErrorThrows = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Reject with a plain string — not an Error subclass.
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
const onError = vi.fn();
// Single attempt — we fail fast and inspect what onError received.
try {
const options = {
baseDelay: 0,
jitter: 0,
maxAttempts: 1,
};
await exponentialBackoffFactory(options).run(rejectedFn, { onError });
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[];
expect(wrappedError).toBeInstanceOf(Error);
expect(wrappedError.message).toBe('not-an-error');
}
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onError.mock.calls[0][0].message).toBe('not-an-error');
};
/**
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
* and the onError callback is not called.
*/
const testExponentialBackoffRunSuccessAndAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Define the function which aborts the exponential backoff and succeeds
const abortAndSucceedFn = vi.fn(({ stopRetries }) => {
stopRetries(new Error('retry me'));
return Promise.resolve('success');
});
const onErrorFn = vi.fn();
// Run the exponential backoff with the function and the onError callback
const options = {
baseDelay: 0,
jitter: 0,
};
const result = await exponentialBackoffFactory(options).run(abortAndSucceedFn, { onError: onErrorFn });
// Expect the result to be the success message
expect(result).toBe('success');
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
// Expect the onError callback to not have been called
expect(onErrorFn).not.toHaveBeenCalled();
};
/**
* Tests that when the abort signal is aborted with an error, an ExponentialBackoffStoppedRetriesError is thrown
* with the error as the message.
*/
const testExponentialBackoffRunWithAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowFn = vi.fn(({ stopRetries }) => {
stopRetries(new Error('exponential backoff aborted'));
throw new Error('error message');
});
const onErrorFn = vi.fn();
// Define the expected error
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
const options = {
baseDelay: 0,
jitter: 0,
};
await expect(exponentialBackoffFactory(options).run(abortAndThrowFn, { onError: onErrorFn })).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowFn).toHaveBeenCalledOnce();
expect(abortAndThrowFn).not.toHaveResolved();
};
/**
* Tests that when the abort signal is aborted with a string, an ExponentialBackoffStoppedRetriesError is thrown
* with the string as the message.
*/
const testExponentialBackoffRunAbortedStringCreatesError = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowStringFn = vi.fn(({ stopRetries }) => {
stopRetries('exponential backoff aborted');
// eslint-disable-next-line
throw 'error message';
});
const onErrorFn = vi.fn();
// Define the expected error, Note that we "stopRetries" with just a string, not an error. They are treated equivalently.
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
const options = {
baseDelay: 0,
jitter: 0,
};
await expect(exponentialBackoffFactory(options).run(abortAndThrowStringFn, { onError: onErrorFn })).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
expect(abortAndThrowStringFn).not.toHaveResolved();
};
/**
* Tests that the delay is aborted when the abort signal is activated.
*/
const testExponentialBackoffSignalInterruptsDelay = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
vi.useFakeTimers();
try {
let abort: (reason: unknown) => void;
const taskFn = vi.fn(async ({ stopRetries }) => {
abort = stopRetries;
throw new Error('error message');
});
// Start the exponential backoff and wait for it to complete
const options = {
baseDelay: 1000,
jitter: 0,
maxAttempts: 0,
};
const result = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn() });
// Advance the timer by 500 (mid delay)
await vi.advanceTimersByTimeAsync(500);
// Make sure the abort function is defined (That the taskFn was called)
if (!abort!) {
throw new Error('abort is not defined');
}
// Check that the abort function is defined
expect(abort).toBeDefined();
// Abort the exponential backoff
abort?.(new Error('exponential backoff aborted'));
// Expect the result to be rejected with an ExponentialBackoffStoppedRetriesError
await expect(result).rejects.toThrow(ExponentialBackoffStoppedRetriesError);
} finally {
vi.useRealTimers();
}
};
/**
* Tests that the exponential backoff is cancelled if the abort signal was activated before run()
*/
const testExponentialBackoffAlreadyAbortedSignalPreventsRun = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
const abortController = new AbortController();
const abortSignal = abortController.signal;
abortController.abort(new Error('already aborted'));
const taskFn = vi.fn();
const options = {
baseDelay: 0,
jitter: 0,
};
const promise = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn(), signal: abortSignal });
await expect(promise).rejects.toThrow(ExponentialBackoffStoppedRetriesError);
expect(taskFn).not.toHaveBeenCalled();
};
/**
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
*/
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (factory: ExponentialBackoffFactory): Promise<void> => {
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
const tripleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockRejectedValueOnce(new Error('attempt 3'))
.mockResolvedValueOnce('eventually');
const options = {
baseDelay: 0,
jitter: 0,
maxAttempts: 0,
};
const result = await factory(options).run(tripleRejectThenResolveFn, { onError: vi.fn() });
expect(result).toBe('eventually');
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
};
/**
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
* (with jitter disabled so the math is exact).
*/
const testExponentialBackoffIncreasesDelayExponentially = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const options = {
baseDelay: 100,
growthRate: 2,
jitter: 0,
maxDelay: 10_000,
maxAttempts: 5,
};
const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() });
// Attempt 0 fires synchronously on the first microtask tick.
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
await vi.advanceTimersByTimeAsync(100);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
await vi.advanceTimersByTimeAsync(200);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
*/
const testExponentialBackoffCapsDelayAtMaxDelay = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const options = {
baseDelay: 1_000,
growthRate: 4,
jitter: 0,
maxDelay: 2_000,
maxAttempts: 5,
};
const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() });
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
await vi.advanceTimersByTimeAsync(1_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
await vi.advanceTimersByTimeAsync(2_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
*/
const testExponentialBackoffAppliesJitter = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
vi.useFakeTimers();
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
vi.spyOn(Math, 'random').mockReturnValue(1);
try {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
.mockResolvedValueOnce('success');
const options = {
baseDelay: 1_000,
growthRate: 1,
jitter: 0.1,
maxDelay: 10_000,
maxAttempts: 3,
};
const promise = exponentialBackoffFactory(options).run(rejectThenResolveFn, { onError: vi.fn() });
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
await vi.advanceTimersByTimeAsync(899);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that passing undefined into the constructor does not cause an error during spread
*/
const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (factory: ExponentialBackoffFactory): 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(() => factory(options)).toThrow(ExponentialBackoffNumberNotFiniteError);
};
export const testExponentialBackoff = (name: string, exponentialBackoffFactory: ExponentialBackoffFactory): void => {
describe(name, () => {
test('ExponentialBackoff: returns the result on first success', () =>
testExponentialBackoffSucceedsOnFirstAttempt(exponentialBackoffFactory));
test('ExponentialBackoff: retries until the function succeeds', () => testExponentialBackoffRetriesUntilSuccess(exponentialBackoffFactory));
test('ExponentialBackoff: calls onError for each failed attempt', () =>
testExponentialBackoffCallsOnErrorForEachFailure(exponentialBackoffFactory));
test('ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', () =>
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted(exponentialBackoffFactory));
test('ExponentialBackoff: wraps non-Error throws before calling onError', () =>
testExponentialBackoffWrapsNonErrorThrows(exponentialBackoffFactory));
test('ExponentialBackoff: succeeds and aborts with abort signal', () =>
testExponentialBackoffRunSuccessAndAbortSignal(exponentialBackoffFactory));
test('ExponentialBackoff: aborts with abort signal', () => testExponentialBackoffRunWithAbortSignal(exponentialBackoffFactory));
test('ExponentialBackoff: aborts with aborted string creates error', () =>
testExponentialBackoffRunAbortedStringCreatesError(exponentialBackoffFactory));
test('ExponentialBackoff: aborts with abort signal, interrupts delay', () =>
testExponentialBackoffSignalInterruptsDelay(exponentialBackoffFactory));
test('ExponentialBackoff: already aborted signal prevents run', () =>
testExponentialBackoffAlreadyAbortedSignalPreventsRun(exponentialBackoffFactory));
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', () =>
testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero(exponentialBackoffFactory));
test('ExponentialBackoff: increases delay exponentially between attempts', () =>
testExponentialBackoffIncreasesDelayExponentially(exponentialBackoffFactory));
test('ExponentialBackoff: caps delay at maxDelay', () => testExponentialBackoffCapsDelayAtMaxDelay(exponentialBackoffFactory));
test('ExponentialBackoff: subtracts jitter from the capped delay', () => testExponentialBackoffAppliesJitter(exponentialBackoffFactory));
test('ExponentialBackoff: constructor does not cause an error during spread', () =>
testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread(exponentialBackoffFactory));
});
};
@@ -0,0 +1,259 @@
import { expect, test, vi } from 'vitest';
import { testExponentialBackoff } from './exponential-backoff-run.shared.ts';
import { ExponentialBackoff } from '../../source/exponential-backoff/exponential-backoff.ts';
import type { ExponentialBackoffOptions } from '../../source/exponential-backoff/exponential-backoff.ts';
/**
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
*/
const validExponentialBackoffOptions = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
};
/**
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
* as an alternative to the static helper.
*/
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
const successfullyResolve = vi.fn(async () => 42);
// from() is a convenience constructor; run() on the result should behave like the static path.
const backoff = ExponentialBackoff.from({
baseDelay: 0,
jitter: 0,
});
const result = await backoff.run(successfullyResolve);
expect(result).toBe(42);
expect(successfullyResolve).toHaveBeenCalledOnce();
};
/**
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
* with library defaults (including the default 1000ms base delay) when no options are passed.
*/
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
// Fake timers let us advance time without waiting real seconds between retries.
vi.useFakeTimers();
// Pin Math.random to 0 so jitter does not reduce the default delay.
vi.spyOn(Math, 'random').mockReturnValue(0);
try {
// The wrapped function fails on its first invocation and succeeds on the second.
// That forces ExponentialBackoff.run down the retry path using default options.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('static-result');
// Call the static helper with no onError and no options — defaults apply entirely.
const promise = ExponentialBackoff.run(rejectThenResolveFn);
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
await vi.advanceTimersByTimeAsync(1_000);
// The retry should have succeeded and returned the resolved value from the mock.
await expect(promise).resolves.toBe('static-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
* with defaults, still retrying when only some fields are overridden.
*/
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('done');
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
// onError is omitted to verify the default no-op handler is used.
const result = await ExponentialBackoff.run(rejectThenResolveFn, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
expect(result).toBe('done');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
* the instance's stored options when no per-run options are supplied.
*/
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('instance-result');
// Options live on the instance; run(fn) should read them instead of static defaults.
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
const result = await backoff.run(rejectThenResolveFn);
expect(result).toBe('instance-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
*/
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
const validCases = [
validExponentialBackoffOptions,
{
...validExponentialBackoffOptions,
maxDelay: 0,
maxAttempts: 0,
baseDelay: 0,
growthRate: 0,
jitter: 0,
},
{
...validExponentialBackoffOptions,
jitter: 1,
},
] as const;
for (const options of validCases) {
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
*/
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
// Define our test cases with each value being less than 0
const negativeCases = [
{ field: 'maxDelay', value: -1 },
{ field: 'maxAttempts', value: -1 },
{ field: 'baseDelay', value: -1 },
{ field: 'growthRate', value: -1 },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of negativeCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
*/
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
// Define our test cases with each value being less than 0 or greater than 1
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
// Iterate through the test cases and expect an error to be thrown
for (const { value } of invalidJitterCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
jitter: value,
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
*/
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
// Define our test cases with each value being Infinity
const nonFiniteCases = [
{ field: 'maxDelay', value: Infinity },
{ field: 'maxAttempts', value: Infinity },
{ field: 'baseDelay', value: Infinity },
{ field: 'growthRate', value: Infinity },
{ field: 'jitter', value: Infinity },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nonFiniteCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).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`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
*/
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
// Define our test cases with each value being NaN
const nanCases = [
{ field: 'maxDelay', value: Number.NaN },
{ field: 'maxAttempts', value: Number.NaN },
{ field: 'baseDelay', value: Number.NaN },
{ field: 'growthRate', value: Number.NaN },
{ field: 'jitter', value: Number.NaN },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nanCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
}
};
const runTests = async (): Promise<void> => {
const exponentialBackoffFactory = (options?: Partial<ExponentialBackoffOptions>): ExponentialBackoff => {
return new ExponentialBackoff(options);
};
// Test the shared ExponentialBackoff contract.
testExponentialBackoff('ExponentialBackoff', exponentialBackoffFactory);
// APIs that only exist on ExponentialBackoff itself.
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
test('ExponentialBackoff: uses the default options when no options are passed', testExponentialBackoffRunUsesDefaultOptions);
test('ExponentialBackoff: accepts partial options and merges them with defaults', testExponentialBackoffRunWithPartialOptions);
test('ExponentialBackoff: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
};
await runTests();
+1
View File
@@ -7,6 +7,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"declaration": true,
"declarationMap": true