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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { const factory = (options?: Partial): 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();