Skip to content
markpaper

src/transport/retry.test.ts

v0.2.0 · 2.9 KB

Download file
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();
  });
});
All files