← back to journal

Three Attempts, One Third

2026-10-08 · Kit Wickham

My last post on retries left out timeouts and retry limits, and ended by saying the fixes for overload were "elsewhere" and that I hadn't tested them. This one tests them. The failure I care about is the kind where a system that was fine gets knocked over by something brief, like a stall, a deploy or a slow dependency, and then stays down after the cause is gone. Bronson and colleagues called these metastable failures, and retries are the textbook way to get one.

The short version: with plain retries, a server stays stuck after a hiccup whenever the load is above one over the number of attempts. With 3 attempts that's a third of capacity. Backoff doesn't move that line, because it changes when the attempts happen, not how many there are. A retry budget moves the line up to near full capacity, but at tight timeouts it did worse than not retrying at all. What made the stuck state disappear was the server refusing to work on requests nobody is waiting for: serving newest-first, or capping the queue.

The setup

One server handles one request at a time, with exponentially distributed service times (the unit of time is the mean service time). New requests arrive at random at a rate called the load, as a fraction of capacity. Each client waits a fixed timeout for a response; if none comes, it counts the attempt as failed and may try again. The server doesn't know the client gave up and finishes the old attempt anyway, so that work is wasted. 1% of served attempts fail with a quick error, so retries have something to win back. Unless noted, clients make up to 3 attempts.

I used two timeouts: 10 service times, about the 99th percentile of healthy latency at 60% load, and 50. To knock the system over I stopped the server for a while (a stall) at time 2,000, then ran 60,000 more time units and measured the fraction of requests that succeeded in the last 2,500. Each point is 40 runs. As a check, with no retries and no stall the simulator matches the textbook single-server queue: mean latency 2.02, 4.85 and 10.2 at loads 0.5, 0.8 and 0.9 (theory 2, 5 and 10), and at load 0.8 the fraction answered within 10 was 0.863 ± 0.002 against 0.865.

Left: success rate over time at 60% load with a timeout of 50, around a 200-unit server stall starting at time 1000. All lines drop to zero during the stall. Server LIFO is back at once, no retries after about 250, a 10% retry budget after about 450; three attempts with or without backoff stay at zero to the end. Middle: success rate at the end of the run after a 2000-unit stall, against load from 0.2 to 0.98. Three attempts with or without backoff succeed fully up to load 0.3 and drop to zero from 0.35, just past a marked line at one third. The 10% budget holds until 0.85 and is zero from 0.9, near a line at 1 over 1.1. The refund bucket and server-skips-dead hold to 0.9 and fall to about 0.6 and 0.5 near full load. No retries holds to 0.95. Server LIFO stays above 0.97 everywhere. Right: mean time until three attempts lock up with no stall, log scale, against timeout. At load 0.5 it rises from 10 thousand at timeout 5 to 2 million at 20, with no lockup at 30. At 0.7 from 5 thousand to 20 million at 40. At 0.9 from 3 thousand at timeout 5 to 2 million at 100.

Left: one run, successes per new request in 150-unit windows; the two orange lines overlap at zero, and the spikes above 1 are the backlog clearing. Middle: timeout 50, 40 runs per point; the lines marked 1/3 and 1/1.1 are where retries alone could keep the server busy. Right: timeout varies, 20 runs per point of up to a million time units each.

Why it sticks

Once the queue is longer than the timeout, every request times out, so every request makes all 3 attempts, and none of them succeeds. In that state I measured 2.98 to 2.99 attempts per request and 100% of the server's time spent on abandoned work. The state keeps itself going as long as 3 times the load is more than the server can do, so it's stuck above a load of 1/3 and drains below it. That's what the runs show: after a 2,000-unit stall, every run recovered at load 0.3 and none did at 0.35 (3 × 0.35 = 1.05). With 10 attempts the lowest load I tried, 0.2, was already stuck.

A bigger stall isn't needed to get there, just a bigger load. With a timeout of 50 and load 0.6, a 100-unit stall was enough, and a 200-unit one in the left panel never recovered. With the tight timeout of 10, the system locked up on its own, without any stall, at every load from 0.45 up: an ordinary run of bad luck in the queue is enough to cross the timeout. The right panel shows how fast. At load 0.5 the mean time to lock up was about 20,000 service times with a timeout of 10 and 2 million with 20; at 0.9 it took a timeout of 100 to get to 2 million. If a service takes 10 ms, 20,000 service times is under four minutes.

Backoff doesn't help

Full-jitter exponential backoff, with a base of 1 or 10 service times, gave the same line as no backoff: stuck from 0.35 after a long stall, at both timeouts. Without a stall, it changed the mean time to lock up by a factor between 0.7 and 3.1 across the 34 load and timeout settings where both locked up: sometimes later, sometimes sooner. That's the arithmetic: the stuck state needs 3 times the load in attempts, and backoff still sends all 3; it just sends them later. In the last post backoff helped a burst drain because the retries kept coming forever until they won. Here they stop at 3, so all it can change is when.

Budgets move the line, and cost something

A retry budget caps retries as a share of requests. I tried two kinds:

With the timeout of 50 and load up to 0.8, both budgets did their job: essentially every request succeeded, against 99% without retries, because they won back the 1% errors. The trouble was elsewhere.

fraction succeeding, no stallload 0.6, timeout 10load 0.8, timeout 10load 0.95, timeout 50
no retries0.970.860.90
ratio budget, bank 1000.880.580.00
ratio budget, bank 10.970.760.00
refund bucket 5000.920.710.69
refund bucket 500.970.800.82

Budget rows: 20 runs per cell, a separate sweep; no-retries row from the 40-run grid. The 0.95 column is above the ratio budget's 1/1.1 line.

At the tight timeout, the budgets were worse than not retrying. The bank is the problem. While things are healthy it fills up, and when a fluctuation causes a few timeouts, a full bank lets a hundred retries through at once. That pushes the queue past the timeout for everyone, and the system cycles: drain, refill, burst. A bank of 1 removed most of the damage at 0.6 but not at 0.8, and a bigger ratio made it worse (20% with a bank of 100: 0.83 at load 0.6). A budget limits how bad it gets, but it's a limit on the stuck state, not a way out of it.

What made the stuck state go away

Every option that never got stuck changes which work the server does, not which work the clients send:

What I'd do

Limits: one server, exponential service times, one class of request, and every client following the same policy with one shared budget (real budgets are per client, which lets a fleet send more). The queue has no memory or connection limit, so a stuck run's queue just grows. Rejections and skips cost the server nothing here; in practice they cost a little. The exact lines will move with all of this, but the 1/k arithmetic doesn't depend on the details: it only needs abandoned work to keep using the server.