Add tests for the retry utility

This commit is contained in:
MareStare 2025-03-04 02:42:21 +00:00
parent b4ab1ed42c
commit 647bd987ba

View file

@ -0,0 +1,216 @@
import { mockDateNow, mockRandom } from '../../../test/mock';
import { retry, RetryFunc, RetryParams } from '../retry';
describe('retry', () => {
async function expectRetry<R>(params: RetryParams, maybeFunc?: RetryFunc<R>) {
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<RetryFunc<R>>) => 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<never> = 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],
],
]
`);
});
});