Kata: retry with exponential backoff and jitter
A retry kata is judged at its edges: how many calls, where the waits fall, which error escapes, and whether tests can fake time.
Predict first
Before you read: predict
ExamHall's autosave client sends each draft through its retry helper:
In a unit test the fake API answers every PUT with 503, and sleep is a fake that writes down each wait it is asked for and returns at once.
When retry gives up, what do the written-down waits add up to?
await retry(() => putAnswer(draft), { retries: 4, baseMs: 200, factor: 2, maxMs: 500, jitter: 'none', shouldRetry: (err) => err.status === 503, sleep,});The 30-second version
retry(fn, options) calls fn at most 1 + retries times, awaiting each attempt inside try. On a failure it decides first: attempts spent, or shouldRetry saying no, means rethrow that error unchanged, with no wait. Otherwise it sleeps min(maxMs, baseMs × factor^i), scaled by random() for full jitter, and tries again. Inject sleep and random to test it.
A retry kata is judged at its edges: how many calls, where the waits fall, which error escapes, and whether tests can fake time.
Step through the code
Trace of the opening question
Play options
Ready: press Play or step with Next frame
Row 1 of 5 · Call 1 (i = 0)
Starting row · computed by comparing each row with the one before
- Call: 1 (i = 0)
- Answer: 503
- Decision: 0 < 4, and 503 is retried
- Wait written down: min(500, 200) = 200
Rule · step 4
Decide, then sleep. In catch, stop when attempts are spent or shouldRetry(err) is false. Stopping sleeps nothing, so the last failure gets no wait.
Rule · step 6
The wait. Before retry i, wait min(maxMs, baseMs × factor^i). Since i starts at 0, the first wait is the base (ds-retries-backoff-jitter step 3).
- Call
- 1 (i = 0)
- Answer
- 503
- Decision
- 0 < 4, and 503 is retried
- Wait written down
- min(500, 200) = 200
- Step
- 4, 6
| Call | Answer | Decision | Wait written down | Step |
|---|---|---|---|---|
| 1 (i = 0) | 503 | 0 < 4, and 503 is retried | min(500, 200) = 200 | 4, 6 |
| 2 (i = 1) (changed) | 503 | retry (changed) | min(500, 400) = 400 (changed) | 4, 6 |
| 3 (i = 2) (changed) | 503 | retry | min(500, 800) = 500 (changed) | 4, 6 |
| 4 (i = 3) (changed) | 503 | retry | min(500, 1600) = 500 (changed) | 4, 6 |
| 5 (i = 4) (changed) | 503 | 4 ≥ 4: rethrow this 503 (changed) | none (changed) | 2, 4, 5 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table.
The probe code (rows are not linked to code lines)
await retry(() => putAnswer(draft), { retries: 4, baseMs: 200, factor: 2, maxMs: 500, jitter: 'none', shouldRetry: (err) => err.status === 503, sleep,});How it works
Step 1 of 10
retry(fn, { retries, baseMs, factor, maxMs, jitter, shouldRetry, sleep, random }) resolves with fn's value or rejects with an error.See it run
Not in this run
Analogy: A salvage diver freeing a fouled anchor, by her dive club's repeat-dive card
| On the dive boat | In the code |
|---|---|
| the card allows the first dive and three repeats | fn runs at most 1 + retries times (step 2) |
| after a failed dive she rests on deck, each rest twice the last but never past the card's limit | min(maxMs, baseMs × factor^i) (step 6) |
| after the last failed dive she heads for harbour at once | no wait after the last failure (step 4) |
| "anchor wedged under rock" ends the job at the first look | shouldRetry false: rethrow at once (step 4) |
| the job sheet records what the last dive found, in her words | rethrow the last error unchanged (step 5) |
| each job starts its own count on its own sheet | state per call (step 8) |
| the instructor checks a new diver's card with a stopwatch she can skip forward and a die she sets | injected sleep and random (step 9) |
In real terms: a correct retry makes at most 1 + retries calls, sleeps only between them, stops at once on a permanent error, and rejects with the error that stopped it.
Breaks down when: a diver can see the anchor's state before she dives; code learns only by trying. And the club's doubling rule is the analogy's own, not dive-table practice.
Contrasting case: a diver who dives once and goes home. That is a plain await fn(): no waits, and the first failure reaches the caller.
Examples, run for real
4.1 ExamHall EXH-2800: autosave gives up on the first 503 (MR !3100)
At 09:00 a burst of 503s from the answers API made ExamHall's autosave show "Not saved" while the API recovered within a second. MR !3100 sends each save through the reference retry, with shouldRetry true only for 503. Every autosave carries a sequence number and an Idempotency-Key, which is what makes repeating it safe (api-idempotency-keys).
export async function retry(fn, {
retries = 3, baseMs = 100, factor = 2, maxMs = 10_000, jitter = 'full',
shouldRetry = () => true, sleep, random = Math.random,
} = {}) {
for (let attempt = 0; ; attempt += 1) {
try {
return await fn(attempt);
} catch (error) {
if (attempt >= retries || !shouldRetry(error)) throw error;
const capped = Math.min(maxMs, baseMs * factor ** attempt);
await sleep(jitter === 'full' ? capped * random() : capped);
}
}
}
The same outage replayed on a virtual clock, with random stubbed:
4.1 MR !3100 retry(putAnswer, { retries: 5, baseMs: 250, factor: 2, maxMs: 4000, jitter: 'full', shouldRetry: 503 only }) · random() stub 0.8, 0.4, 0.6
4.1 call 1 at 0 ms: 503 · wait 200 (0.8 × 250)
4.1 call 2 at 200 ms: 503 · wait 200 (0.4 × 500)
4.1 call 3 at 400 ms: 503 · wait 600 (0.6 × 1000)
4.1 call 4 at 1000 ms: 200 saved · no more waits
4.1 resolved saved after 4 calls · waits written down 200 + 200 + 600 = 1000 ms
The waits sit between calls only, each the capped backoff times that wait's own draw (steps 4, 6, 7). After the success nothing waits.
4.2 Proof domain, ticketing: the test list as a mutation check
A box office's seat-hold client uses the same retry. Its test list (step 10, lib/cases.mjs) is run against the reference and against four one-line mutants of it:
4.2 the test list T1, T2, T3, T4, T5, T6, T7 on the reference: passes 7 of 7
4.2 mutant await removed (return await fn(attempt); -> return fn(attempt);): killed by T2, T3, T5, T6, T7
4.2 mutant shouldRetry dropped (|| !shouldRetry(error) -> nothing): killed by T4
4.2 mutant cap dropped (Math.min(maxMs, baseMs * factor ** attempt) -> baseMs * factor ** attempt): killed by T5, T6
4.2 mutant exponent dropped (baseMs * factor ** attempt -> baseMs): killed by T2, T3, T5, T6, T7
Each mutant is killed by at least one test: removing await lets rejections escape the catch (step 3), dropping shouldRetry retries a seat already taken (step 4), and dropping the cap or the exponent changes the waits (step 6). A test list earns its place by killing mutants like these.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A box office's seat-hold client has its own retry(holdSeat, options). Its maxAttempts option counts every call, the first one included, and its loop counts calls from 1:
maxAttempts, shouldRetry and sleep come from the options, and wait(n) returns Math.min(maxMs, baseMs * factor ** (n - 1)), the wait after the n-th failed call. Four teammates each wrote the rest of the catch body.
Which one gives a correct retry?
for (let attempt = 1; ; attempt += 1) { try { return await holdSeat(attempt); } catch (err) { log.push(err); // the rest of the catch body goes here }}Question 2 of 7
A chat client builds its message sender once, at start-up:
Its tests all pass. Each candidate test below builds its own sender with retries: 3, baseMs: 100, factor: 2, maxMs: 1000, shouldRetry: (err) => err.status === 503 and a fake sleep.
Which test fails on this code and passes on a correct sender?
export function createSender({ retries, baseMs, factor, maxMs, shouldRetry, sleep }) { let attempt = 0; return async function send(post) { for (; ; attempt += 1) { try { return await post(attempt); } catch (err) { if (attempt >= retries || !shouldRetry(err)) throw err; await sleep(Math.min(maxMs, baseMs * factor ** attempt)); } } };}Question 3 of 7
A video player fetches each segment through retry with retries: 3, baseMs: 100, factor: 2, maxMs: 300, jitter: 'full'. Its wait line is await sleep(Math.min(maxMs, baseMs * factor ** attempt) * random());. A mutant changes it to await sleep(Math.min(maxMs, baseMs * factor ** attempt * random()));. Each test below fakes a fetch that fails every time and a sleep that writes down its waits.
Which test fails on the mutant and passes on the correct line?
Question 4 of 7
A catalog page loads a product's price through the reference retry:
The price service answers the first call with 503, the second with 502, the third with 503 and the fourth with the price 12.99.
What does the await give the page?
const price = await retry(() => priceApi.get(sku), { retries: 3, baseMs: 100, factor: 2, maxMs: 1000, jitter: 'none', shouldRetry: (err) => err.status === 503, sleep,});Question 5 of 7
Time box: 25 minutes. Say your test list out loud first. Then, in an empty file, write retry(fn, options) with the options retries, baseMs, factor, maxMs, jitter ('full' or 'none'), shouldRetry, sleep and random. Run the kata's tests against your file.
Which tests pass?
Question 6 of 7
A seat-reservation client ends its retry loop like this:
It runs with retries: 3 and shouldRetry: (err) => err.status === 503. The reservation page picks its message from the rejected error's status: 409 "Seat taken", 503 "Busy, try again", anything else "Something went wrong". Buyers whose seat was already taken now read "Something went wrong".
Which throw line makes the page right for a taken seat and for a long outage?
} catch (err) { if (attempt >= retries || !shouldRetry(err)) { throw new Error(`reserve failed after ${attempt + 1} attempt(s)`); } await sleep(Math.min(maxMs, baseMs * factor ** attempt));}Question 7 of 7
A ledger client reads an account's balance through this retry:
In a test with the default options, the balance service answers 503 every time. The read rejects after fn has run 3 times, and the fake sleep wrote down 200 and 400.
Which line holds the bug?
export async function retry(fn, { retries = 3, baseMs = 100, factor = 2, maxMs = 2000, shouldRetry = () => true, sleep }) { let attempt = 0; while (true) { try { return await fn(attempt); } catch (err) { attempt += 1; if (attempt >= retries || !shouldRetry(err)) throw err; await sleep(Math.min(maxMs, baseMs * factor ** attempt)); } }}Go deeper
Pro tips
- Inject
sleepandrandomas options, and assert exact waits. The curriculum row's tip: with both injected the kata is testable without real time. When: writing the test list (step 9). Verify (RAN, Node 24.19.0):node --test retry.test.mjsruns all seven tests with the fakesleep; T3 checks the waits[100, 200, 400]and the run takes no real wait.
Source
No item is keyed on a source; every key is executed. Valid as of 2026-10-04.
- AWS Builders' Library, Timeouts, retries, and backoff with jitter (author not shown in the rendered copy): https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/, which redirects to https://builder.aws.com/content/3EumjoZascWd1oZiEgL8ORlv3qE/timeouts-retries-and-backoff-with-jitter (
builders-library.txt). The page builds its text in the browser, so its visible text was read in Chromium 153.0.8010.12 (render-source.mjs,document.body.innerText); the header names the renderer and carries the sha256 of the raw rendered file, since the served HTML holds no body text. Quoted: "The most common pattern is an exponential backoff, where the wait time is increased exponentially after every attempt." and "implementations typically cap their backoff to a maximum value". Supports steps 6–7. - Marc Brooker, AWS Architecture Blog, Exponential Backoff And Jitter (2015): https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ (
brooker-2015.txt). Quoted: "Let’s call the algorithm above “Full Jitter”". Supports step 7; the formulas areds-retries-backoff-jitter's. - Sindre Sorhus, p-retry README: https://raw.githubusercontent.com/sindresorhus/p-retry/main/readme.md (
p-retry-readme.txt). Quoted: "inputis called again until the max retries are reached, it then rejects with the last rejection reason." and, forretries, "The maximum amount of times to retry the operation." A widely used library with the same contract as steps 2 and 5. - Node.js documentation, Test runner (v24.x
doc/api/test.md): https://raw.githubusercontent.com/nodejs/node/v24.x/doc/api/test.md (node-test.txt). Thenode:testAPI andnode --testused byretry.test.mjs.
Related lessons
Checked against the tools and docs as of 2026-10-04.