Files
xo-cash-utils/test/exponential-backoff/exponential-backoff-run.shared.ts
T

517 lines
20 KiB
TypeScript

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));
});
};