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

260 lines
11 KiB
TypeScript

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