src/transport/retry.test.ts
v0.2.0 · 2.9 KB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { backoffDelayMs, DEFAULT_RETRY, sleep, withRetry } from './retry.js';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('backoffDelayMs', () => {
it('grows by the factor with bounded jitter and an optional cap', () => {
const mid = () => 0.5; // jitter term = 0
expect(backoffDelayMs(0, { random: mid })).toBe(250);
expect(backoffDelayMs(1, { random: mid })).toBe(625);
expect(backoffDelayMs(2, { random: mid })).toBe(1563);
expect(backoffDelayMs(0, { random: () => 0 })).toBe(150); // -40%
expect(backoffDelayMs(0, { random: () => 0.999999 })).toBeLessThanOrEqual(350);
expect(backoffDelayMs(5, { random: mid, maxDelayMs: 1000 })).toBe(1000);
expect(backoffDelayMs(-3, { random: mid })).toBe(250);
expect(DEFAULT_RETRY.retries).toBe(3);
});
});
describe('withRetry', () => {
it('retries while shouldRetry approves and returns the first success', async () => {
let calls = 0;
const p = withRetry(
async () => {
calls++;
if (calls < 3) throw new Error('transient');
return 'ok';
},
{ shouldRetry: () => true, random: () => 0.5 },
);
await vi.advanceTimersByTimeAsync(250 + 625);
await expect(p).resolves.toBe('ok');
expect(calls).toBe(3);
});
it('stops on a non-retryable error and after the budget', async () => {
let calls = 0;
await expect(
withRetry(
async () => {
calls++;
throw new Error('final');
},
{ shouldRetry: () => false },
),
).rejects.toThrow('final');
expect(calls).toBe(1);
calls = 0;
const p = withRetry(
async () => {
calls++;
throw new Error('always');
},
{ shouldRetry: () => true, retries: 2, random: () => 0.5 },
);
const settled = p.catch((e: Error) => e.message);
await vi.advanceTimersByTimeAsync(10_000);
expect(await settled).toBe('always');
expect(calls).toBe(3);
});
it('reports each retry and honours abort during the sleep', async () => {
const seen: number[] = [];
const ctl = new AbortController();
const p = withRetry(
async () => {
throw new Error('t');
},
{ shouldRetry: () => true, signal: ctl.signal, onRetry: (_e, attempt) => seen.push(attempt), random: () => 0.5 },
);
const settled = p.catch((e: Error) => e.message);
await vi.advanceTimersByTimeAsync(10);
ctl.abort(new Error('stop'));
expect(await settled).toBe('stop');
expect(seen).toEqual([0]);
});
it('sleep 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');
const ok = sleep(5);
await vi.advanceTimersByTimeAsync(5);
await expect(ok).resolves.toBeUndefined();
});
});