Add exponential backoff utility
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user