Skip to content
markpaper

src/transport/retry.test.ts

v0.3.0 · 4.2 KB

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