Measure retry amplification and a retry budget
Simulate a modelNeeds Python
Problem
A retry turns one failed request into two requests, and a client that retries r times can send 1 + r requests for one piece of work. When every layer of a call chain of depth d retries independently, the multipliers stack to (1 + r)^d. Retries arrive exactly when the backend is slow, so they add load to a server that is already short of capacity, and a request that times out keeps occupying the queue it left. A retry budget caps retries at a share of recent successes, so the multiplier collapses when nothing is succeeding.
Turn the claim "retries multiply load by (1 + r)^d" into a measured number. Simulate a backend that handles 2,000 requests per second and slows down for 3 seconds. Clients use a 100 ms timeout and up to 3 retries. Run the simulation twice: without a retry budget, and with a budget that allows a retry only when retries are below 10% of recent successes. Record the peak offered-load ratio for both runs. Then change retries to 1, 2, 3 and compare with the formula (1 + r)^d (here d = 1: one layer).
The question. How far above the base load does offered load peak without a retry budget, and with one?
Predict
Without a budget, offered load per second jumps to several times the base load during and after the stall, and it can stay high after the stall ends (the queue is full of requests that already timed out). With the budget, offered load stays within about 1.1 times the base.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
measure-retry-amplification-and-a-retry.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Measure retry amplification and a retry budget. A backend that handles 2,000 requests per second stalls for 3 seconds; clients time out after 100 ms and retry up to r times. Offered load per second without a budget, and with a budget that allows a retry only while retries are below 10% of recent successes. Then r = 1, 2, 3. """ import argparse, collections, json p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--capacity", type=int, default=2000, help="Requests per second the backend finishes.") p.add_argument("--base", type=int, default=1000, help="New requests per second.") p.add_argument("--seconds", type=int, default=30, help="Simulated seconds.") p.add_argument("--retries", type=int, default=3, help="Retries per request.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() TICK = 0.01 def run(budget, retries): q, offered, log = collections.deque(), 0, [] recent_ok = recent_retry = 0 for t in range(int(args.seconds / TICK)): now = t * TICK sick = 5 <= now < 8 cap = 2 if sick else args.capacity * TICK for _ in range(int(args.base * TICK)): q.append((now, 0)); offered += 1 nq = collections.deque() for (t0, n) in q: if now - t0 > 0.1: if n < retries and (not budget or recent_retry < 0.1 * max(recent_ok, 1)): nq.append((now, n + 1)); offered += 1; recent_retry += 1 else: nq.append((t0, n)) q = nq for _ in range(int(cap)): if q: q.popleft(); recent_ok += 1 if t % 100 == 99: recent_ok *= 0.5; recent_retry *= 0.5 log.append({"second": round(now), "offered": offered}); offered = 0 return log rows, peaks = [], [] for budget in (False, True): log = run(budget, args.retries) label = "retry budget (10%)" if budget else "no budget" for r in log: rows.append({**r, "policy": label}) peak = max(r["offered"] for r in log) / args.base peaks.append({"policy": label, "retries": args.retries, "peak_ratio": peak, "formula": (1 + args.retries) if not budget else 1.1}) print(f"{label:<20} peak offered load {peak:.2f}x base") for r in (1, 2, 3): peak = max(x["offered"] for x in run(False, r)) / args.base peaks.append({"policy": "no budget", "retries": r, "peak_ratio": peak, "formula": 1 + r}) print(f"no budget, r = {r}: peak {peak:.2f}x (1 + r)^1 = {1 + r}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"seconds": rows, "peaks": peaks}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run measure-retry-amplification-and-a-retry.py uv run measure-retry-amplification-and-a-retry.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --capacity2000 | Requests per second the backend finishes. |
| --base1000 | New requests per second. |
| --seconds30 | Simulated seconds. |
| --retries3 | Retries per request. |
Our run
Data table
| Second | series | Requests offered |
|---|---|---|
| 1 | no budget | 1,000 |
| 2 | no budget | 1,000 |
| 3 | no budget | 1,000 |
| 4 | no budget | 1,000 |
| 5 | no budget | 1,000 |
| 6 | no budget | 3,220 |
| 7 | no budget | 3,970 |
| 8 | no budget | 4,000 |
| 9 | no budget | 1,640 |
| 10 | no budget | 1,000 |
| 11 | no budget | 1,000 |
| 12 | no budget | 1,000 |
| 13 | no budget | 1,000 |
| 14 | no budget | 1,000 |
| 15 | no budget | 1,000 |
| 16 | no budget | 1,000 |
| 17 | no budget | 1,000 |
| 18 | no budget | 1,000 |
| 19 | no budget | 1,000 |
| 20 | no budget | 1,000 |
| 21 | no budget | 1,000 |
| 22 | no budget | 1,000 |
| 23 | no budget | 1,000 |
| 24 | no budget | 1,000 |
| 25 | no budget | 1,000 |
| 26 | no budget | 1,000 |
| 27 | no budget | 1,000 |
| 28 | no budget | 1,000 |
| 29 | no budget | 1,000 |
| 30 | no budget | 1,000 |
| 1 | retry budget (10%) | 1,000 |
| 2 | retry budget (10%) | 1,000 |
| 3 | retry budget (10%) | 1,000 |
| 4 | retry budget (10%) | 1,000 |
| 5 | retry budget (10%) | 1,000 |
| 6 | retry budget (10%) | 1,120 |
| 7 | retry budget (10%) | 1,020 |
| 8 | retry budget (10%) | 1,020 |
| 9 | retry budget (10%) | 1,000 |
| 10 | retry budget (10%) | 1,000 |
| 11 | retry budget (10%) | 1,000 |
| 12 | retry budget (10%) | 1,000 |
| 13 | retry budget (10%) | 1,000 |
| 14 | retry budget (10%) | 1,000 |
| 15 | retry budget (10%) | 1,000 |
| 16 | retry budget (10%) | 1,000 |
| 17 | retry budget (10%) | 1,000 |
| 18 | retry budget (10%) | 1,000 |
| 19 | retry budget (10%) | 1,000 |
| 20 | retry budget (10%) | 1,000 |
| 21 | retry budget (10%) | 1,000 |
| 22 | retry budget (10%) | 1,000 |
| 23 | retry budget (10%) | 1,000 |
| 24 | retry budget (10%) | 1,000 |
| 25 | retry budget (10%) | 1,000 |
| 26 | retry budget (10%) | 1,000 |
| 27 | retry budget (10%) | 1,000 |
| 28 | retry budget (10%) | 1,000 |
| 29 | retry budget (10%) | 1,000 |
| 30 | retry budget (10%) | 1,000 |
Peak offered load against the formula
| Policy | r | Peak ÷ base | (1 + r)^1 |
|---|---|---|---|
| no budget | 3 | 4× | 4× |
| retry budget (10%) | 3 | 1.12× | 1.1× |
| no budget | 1 | 2× | 2× |
| no budget | 2 | 3× | 3× |
| no budget | 3 | 4× | 4× |
no budget peak offered load 4.00x base
retry budget (10%) peak offered load 1.12x base
no budget, r = 1: peak 2.00x (1 + r)^1 = 2
no budget, r = 2: peak 3.00x (1 + r)^1 = 3
no budget, r = 3: peak 4.00x (1 + r)^1 = 4What we found
Without a budget, offered load peaked at 4× the base; with the 10% budget, at 1.12×. Varying r without a budget reproduced the formula exactly: 2× at r = 1, 3× at r = 2: during the stall every request times out, so every one of them is sent 1 + r times.
Read it
The multiplier is not a worst case, it is what happens whenever the backend stops answering within the timeout. With retries at several layers, multiply. A retry budget keeps retries for the rare isolated failure, which is what they are good at, and switches them off in an outage, when they only add load.
The chart also shows the second half of the claim: offered load stays above base after the stall, while the queue drains requests nobody is waiting for.