308 lines
12 KiB
TypeScript
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();
|