Skip to content
About
Main content

Measure retry amplification and a retry budget

Simulate a modelNeeds Python

Problem

A 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 it left. A 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 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 retry budget, with 3 retries per request, how high does offered load peak against the base load?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    measure-retry-amplification-and-a-retry.py62 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--capacity2000 Requests per second the backend finishes.
--base1000 New requests per second.
--seconds30 Simulated seconds.
--retries3 Retries per request.

Our run

Offered load per second (base 1,000/s, stall from 5 to 8 s)Apple M2 · 8 cores · 8 GB
no budgetretry budget (10%)
Data table
Offered load per second (base 1,000/s, stall from 5 to 8 s)
SecondseriesRequests offered
1no budget1,000
2no budget1,000
3no budget1,000
4no budget1,000
5no budget1,000
6no budget3,220
7no budget3,970
8no budget4,000
9no budget1,640
10no budget1,000
11no budget1,000
12no budget1,000
13no budget1,000
14no budget1,000
15no budget1,000
16no budget1,000
17no budget1,000
18no budget1,000
19no budget1,000
20no budget1,000
21no budget1,000
22no budget1,000
23no budget1,000
24no budget1,000
25no budget1,000
26no budget1,000
27no budget1,000
28no budget1,000
29no budget1,000
30no budget1,000
1retry budget (10%)1,000
2retry budget (10%)1,000
3retry budget (10%)1,000
4retry budget (10%)1,000
5retry budget (10%)1,000
6retry budget (10%)1,120
7retry budget (10%)1,020
8retry budget (10%)1,020
9retry budget (10%)1,000
10retry budget (10%)1,000
11retry budget (10%)1,000
12retry budget (10%)1,000
13retry budget (10%)1,000
14retry budget (10%)1,000
15retry budget (10%)1,000
16retry budget (10%)1,000
17retry budget (10%)1,000
18retry budget (10%)1,000
19retry budget (10%)1,000
20retry budget (10%)1,000
21retry budget (10%)1,000
22retry budget (10%)1,000
23retry budget (10%)1,000
24retry budget (10%)1,000
25retry budget (10%)1,000
26retry budget (10%)1,000
27retry budget (10%)1,000
28retry budget (10%)1,000
29retry budget (10%)1,000
30retry budget (10%)1,000
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Peak offered load against the formula

Peak offered load against the formula
PolicyrPeak ÷ base(1 + r)^1
no budget34×4×
retry budget (10%)31.12×1.1×
no budget12×2×
no budget23×3×
no budget34×4×
Output · uv run measure-retry-amplification-and-a-retry.py
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 = 4

What 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.