import { mockDateNow, mockRandom } from '../../../test/mock'; import { retry, RetryFunc, RetryParams } from '../retry'; describe('retry', () => { async function expectRetry(params: RetryParams, maybeFunc?: RetryFunc) { const func = maybeFunc ?? (() => Promise.reject(new Error('always failing'))); const spy = vi.fn(func); // Preserve the empty name of the anonymous functions. Spy wrapper overrides it. const funcParam = func.name === '' ? (...args: Parameters>) => spy(...args) : spy; const promise = retry(funcParam, params).catch(err => `throw ${err}`); await vi.runAllTimersAsync(); const result = await promise; const retries = spy.mock.calls.map(([attempt, nextDelayMs]) => { const suffix = nextDelayMs === undefined ? '' : 'ms'; return `${attempt}: ${nextDelayMs}${suffix}`; }); return expect([...retries, result]); } // Remove randomness and real delays from the tests. mockRandom(); mockDateNow(0); const consoleErrorSpy = vi.spyOn(console, 'error'); afterEach(() => { consoleErrorSpy.mockClear(); }); describe('stops on a successful attempt', () => { it('first attempt', async () => { (await expectRetry({}, async () => 'ok')).toMatchInlineSnapshot(` [ "1: 200ms", "ok", ] `); }); it('middle attempt', async () => { const func: RetryFunc<'ok'> = async attempt => { if (attempt !== 2) { throw new Error('middle failure'); } return 'ok'; }; (await expectRetry({}, func)).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "ok", ] `); }); it('last attempt', async () => { const func: RetryFunc<'ok'> = async attempt => { if (attempt !== 3) { throw new Error('last failure'); } return 'ok'; }; (await expectRetry({}, func)).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: undefined", "ok", ] `); }); }); it('produces a reasonable retry sequence within maxAttempts', async () => { (await expectRetry({})).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: undefined", "throw Error: always failing", ] `); (await expectRetry({ maxAttempts: 5 })).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: 600ms", "4: 1125ms", "5: undefined", "throw Error: always failing", ] `); }); it('turns into a fixed delay retry algorithm if min/max bounds are equal', async () => { (await expectRetry({ maxAttempts: 3, minDelayMs: 200, maxDelayMs: 200 })).toMatchInlineSnapshot(` [ "1: 200ms", "2: 200ms", "3: undefined", "throw Error: always failing", ] `); }); it('allows for zero delay', async () => { (await expectRetry({ maxAttempts: 3, minDelayMs: 0, maxDelayMs: 0 })).toMatchInlineSnapshot(` [ "1: 0ms", "2: 0ms", "3: undefined", "throw Error: always failing", ] `); }); describe('fails on first non-retryable error', () => { it('all errors are retryable', async () => { (await expectRetry({ isRetryable: () => false })).toMatchInlineSnapshot(` [ "1: 200ms", "throw Error: always failing", ] `); }); it('middle error is non-retriable', async () => { const func: RetryFunc = async attempt => { if (attempt === 3) { throw new Error('non-retryable'); } throw new Error('retryable'); }; const params: RetryParams = { isRetryable: error => error.message === 'retryable', }; (await expectRetry(params, func)).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: undefined", "throw Error: non-retryable", ] `); }); }); it('rejects invalid inputs', async () => { (await expectRetry({ maxAttempts: 0 })).toMatchInlineSnapshot(` [ "throw Error: Invalid 'maxAttempts' for retry: 0", ] `); (await expectRetry({ minDelayMs: -1 })).toMatchInlineSnapshot(` [ "throw Error: Invalid 'minDelayMs' for retry: -1", ] `); (await expectRetry({ maxDelayMs: 100 })).toMatchInlineSnapshot(` [ "throw Error: Invalid 'maxDelayMs' for retry: 100, 'minDelayMs' is 200", ] `); }); it('should use the provided label in logs', async () => { (await expectRetry({ label: 'test-routine' })).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: undefined", "throw Error: always failing", ] `); expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(` [ [ "All 3 attempts of running test-routine failed", [Error: always failing], ], ] `); }); it('should use the function name in logs', async () => { async function testFunc() { throw new Error('always failing'); } (await expectRetry({}, testFunc)).toMatchInlineSnapshot(` [ "1: 200ms", "2: 300ms", "3: undefined", "throw Error: always failing", ] `); expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(` [ [ "All 3 attempts of running testFunc failed", [Error: always failing], ], ] `); }); });