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

308 lines
12 KiB
TypeScript

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