Skip to the code
cf.

Retry with exponential backoff

Write retry(fn, options). It calls fn(attempt), with attempt starting at 1, and returns a promise. If the call succeeds, the promise fulfills with its result.

If the call fails (fn throws, or its promise rejects), retry waits, then calls fn again, up to retries more times. If every attempt fails, the promise rejects with the last error, as soon as the last attempt fails.

The wait grows exponentially: delay ms before the second attempt, then delay * factor, then delay * factor ** 2, and so on.

options is optional, and so is each of its fields, { retries, delay, factor }. A missing retries is 3, delay is 100, and factor is 2. So by default retry makes up to 4 calls, at 0, 100, 300, and 700 ms. retries: 0 means a single call.

const data = await retry((attempt) => fetchJson("/api/flaky"), { retries: 4, delay: 200 });
// tries at 0, 200, 600, 1400, and 3000 ms, then gives up with the last error

What the tests check

  1. fulfills with the first success, without waiting
    useFakeTimers();
    const fn = spy(async () => "ok");
    let result;
    retry(fn).then((value) => {
      result = value;
    });
    await flush();
    expect(result).toBe("ok");
    expect(fn).toHaveBeenCalledTimes(1);
    
  2. passes the attempt number, starting at 1
    const clock = useFakeTimers();
    const fn = spy(async (attempt) => {
      if (attempt < 3) throw new Error("fail " + attempt);
      return "ok on " + attempt;
    });
    let result;
    retry(fn, { retries: 5, delay: 10 }).then((value) => {
      result = value;
    });
    await clock.tickAsync(1000);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
    expect(result).toBe("ok on 3");
    
  3. doubles the wait after each failure
    const clock = useFakeTimers();
    const start = clock.now();
    const calledAt = [];
    const fn = async () => {
      calledAt.push(clock.now() - start);
      throw new Error("down");
    };
    retry(fn, { retries: 3, delay: 100 }).catch(() => {}); // every attempt fails; this test only checks when they ran
    await clock.tickAsync(1000);
    expect(calledAt).toEqual([0, 100, 300, 700]);
    
  4. grows the wait by factor
    const clock = useFakeTimers();
    const start = clock.now();
    const calledAt = [];
    const fn = async () => {
      calledAt.push(clock.now() - start);
      throw new Error("down");
    };
    retry(fn, { retries: 3, delay: 10, factor: 3 }).catch(() => {}); // every attempt fails; this test only checks when they ran
    await clock.tickAsync(1000);
    expect(calledAt).toEqual([0, 10, 40, 130]);
    
  5. rejects with the last error as soon as the last attempt fails
    const clock = useFakeTimers();
    const fn = spy(async (attempt) => {
      throw new Error("fail " + attempt);
    });
    let reason;
    retry(fn, { retries: 2, delay: 50 }).catch((e) => {
      reason = e;
    });
    await clock.tickAsync(150);
    expect(fn).toHaveBeenCalledTimes(3);
    expect(reason).toBeInstanceOf(Error);
    expect(reason.message).toBe("fail 3");
    
  6. retries when fn throws synchronously
    const clock = useFakeTimers();
    let calls = 0;
    const fn = () => {
      calls += 1;
      if (calls === 1) throw new Error("not ready");
      return "ready";
    };
    let result;
    retry(fn, { delay: 10 }).then((value) => {
      result = value;
    });
    await clock.tickAsync(10);
    expect(result).toBe("ready");
    
  7. with retries: 0, calls fn once
    const clock = useFakeTimers();
    const error = new Error("nope");
    const fn = spy(async () => {
      throw error;
    });
    let reason;
    retry(fn, { retries: 0 }).catch((e) => {
      reason = e;
    });
    await clock.tickAsync(1000);
    expect(fn).toHaveBeenCalledTimes(1);
    expect(reason).toBe(error);
    
  8. uses retries 3, delay 100, and factor 2 by default
    const clock = useFakeTimers();
    const start = clock.now();
    const calledAt = [];
    retry(() => {
      calledAt.push(clock.now() - start);
      throw new Error("down");
    }).catch(() => {}); // every attempt fails; this test only checks when they ran
    await clock.tickAsync(2000);
    expect(calledAt).toEqual([0, 100, 300, 700]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).