src/transport/retry.test.ts
v0.3.0 · 4.2 KB
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_RETRY, backoffDelayMs, sleep, withRetry } from './retry.js';
afterEach(() => {
vi.useRealTimers();
});
describe('backoffDelayMs', () => {
it('follows 250 ms x 2.5^n without jitter noise at random=0.5', () => {
const r = { random: () => 0.5 };
expect([0, 1, 2, 3].map((n) => backoffDelayMs(n, r))).toEqual([250, 625, 1563, 3906]);
});
it('applies +-40% jitter', () => {
expect(backoffDelayMs(0, { random: () => 0 })).toBe(150);
expect(backoffDelayMs(0, { random: () => 0.999999 })).toBe(350);
for (let i = 0; i < 200; i++) {
const d = backoffDelayMs(1);
expect(d).toBeGreaterThanOrEqual(375);
expect(d).toBeLessThanOrEqual(875);
}
});
it('respects maxDelayMs, custom base/factor, and never goes negative', () => {
expect(backoffDelayMs(10, { random: () => 0.5, maxDelayMs: 5000 })).toBe(5000);
expect(backoffDelayMs(2, { baseDelayMs: 100, factor: 2, jitter: 0 })).toBe(400);
expect(backoffDelayMs(-1, { random: () => 0.5 })).toBe(250);
expect(backoffDelayMs(0, { jitter: 5, random: () => 0 })).toBe(0);
});
it('defaults are 4 retries, 250 ms, x2.5, 40%', () => {
expect(DEFAULT_RETRY).toEqual({ retries: 4, baseDelayMs: 250, factor: 2.5, jitter: 0.4 });
});
});
describe('withRetry', () => {
it('retries until success', async () => {
let calls = 0;
const result = await withRetry(
async (attempt) => {
calls++;
if (attempt < 2) throw new Error('transient');
return 'ok';
},
{ shouldRetry: () => true, baseDelayMs: 1, jitter: 0 },
);
expect(result).toBe('ok');
expect(calls).toBe(3);
});
it('gives up after `retries` and throws the last error', async () => {
let calls = 0;
await expect(
withRetry(
async (attempt) => {
calls++;
throw new Error(`fail ${attempt}`);
},
{ shouldRetry: () => true, retries: 2, baseDelayMs: 1, jitter: 0 },
),
).rejects.toThrow('fail 2');
expect(calls).toBe(3);
});
it('does not retry when shouldRetry says no', async () => {
let calls = 0;
await expect(
withRetry(
async () => {
calls++;
throw new Error('final');
},
{ shouldRetry: () => false, baseDelayMs: 1 },
),
).rejects.toThrow('final');
expect(calls).toBe(1);
});
it('reports retries and waits the backoff delay', async () => {
vi.useFakeTimers();
const onRetry = vi.fn();
let calls = 0;
const p = withRetry(
async () => {
calls++;
if (calls === 1) throw new Error('x');
return calls;
},
{ shouldRetry: () => true, random: () => 0.5, onRetry },
);
await vi.advanceTimersByTimeAsync(249);
expect(calls).toBe(1);
await vi.advanceTimersByTimeAsync(1);
await expect(p).resolves.toBe(2);
expect(onRetry).toHaveBeenCalledWith(expect.any(Error), 0, 250);
});
it('abort during the backoff sleep rejects and stops retrying', async () => {
const ctl = new AbortController();
let calls = 0;
const p = withRetry(
async () => {
calls++;
throw new Error('x');
},
{ shouldRetry: () => true, baseDelayMs: 10_000, signal: ctl.signal },
);
await new Promise((r) => setTimeout(r, 5));
ctl.abort(new Error('stop'));
await expect(p).rejects.toThrow('stop');
expect(calls).toBe(1);
});
it('does not retry once the signal is aborted', async () => {
const ctl = new AbortController();
let calls = 0;
await expect(
withRetry(
async () => {
calls++;
ctl.abort();
throw new Error('after abort');
},
{ shouldRetry: () => true, baseDelayMs: 1, signal: ctl.signal },
),
).rejects.toThrow('after abort');
expect(calls).toBe(1);
});
});
describe('sleep', () => {
it('rejects immediately on an aborted signal', async () => {
const ctl = new AbortController();
ctl.abort(new Error('gone'));
await expect(sleep(10, ctl.signal)).rejects.toThrow('gone');
});
it('resolves after the delay', async () => {
await expect(sleep(1)).resolves.toBeUndefined();
});
});