Part IV · Chapter 7 · When It Breaks · lesson 1
The retries turned a blip into a two-hour outage
14 min
Builds on The payment went through twice
Part IV · Chapter 7 · When It Breaks · lesson 1
14 min
Builds on The payment went through twice
At 14:02 a database failover starts dropping about one request in twenty. It finishes at 14:03:30. At 14:04 the API is failing on a fifth of requests, and it stays there until 16:10, when somebody turns retries off in the mobile client.
Nothing was broken after 14:03:30 except the traffic the system was making about itself.
Three layers on this path retry: the mobile SDK, the gateway, the service. Three attempts each.
Predict
Retries compose by multiplication, and every layer in the path configures its own without seeing the others.
An error handed upward is indistinguishable from a single failed request. The layer above cannot tell whether its callee tried once or tried nine times, so it spends its own full allowance on top. The amplification is the product of the per-layer attempt counts, with the number of layers as the exponent.
That product is the number that matters, and it exists in no configuration file. It is not owned by anybody, it is not reviewed by anybody, and it is the figure that decides whether a short fault becomes a long outage.
Multiplication is only half of it. The other half is that the extra load raises the failure rate that produced it, which produces more retries.
The model below walks one tier of that loop. A thousand requests a second arrive, the system can serve twelve hundred attempts a second before it starts shedding, and some share of requests fails for its own reasons. Each round of retries is the previous round's failures coming back as new load, priced at the failure rate that all the accumulated load implies.
Watch the failure rate the system reports against the fault that caused it.
Retry amplification
Hypothetical model, not a forecast
Follows retries round by round as failed requests come back as new load, raising the failure rate that caused them.
It assumes
It ignores
Leave the attempt count alone — you are not allowed to ask three teams to change their configuration. Find the single setting that moves the underlying failure rate at which amplified load reaches capacity from 17.5 per cent up to 37 per cent.
The share that fails for its own reasons, with the system not overloaded.
Attempts after the first one. Zero means a failure is simply a failure.
One means each retry round is spread over a longer window than the last.
| Retry round | Load the system is seeing | Capacity |
|---|---|---|
| 0 | 1k/s | 1.2k/s |
| 1 | 1.2k/s | 1.2k/s |
| 2 | 1.2k/s | 1.2k/s |
Not there yet — keep moving the controls.
Two figures out of that. A fault that fails one request in five arrives, and the system reports failing nearly 23 per cent — it manufactures two and a half percentage points of failure that no fault caused, because the retries pushed offered load to 1,240 attempts a second against a ceiling of 1,200. Turn the retries off entirely and the reported rate is exactly the 20 per cent that broke, with 200 attempts a second of headroom to spare.
And the tipping point. With these attempt counts and no backoff, amplified load reaches capacity once the underlying failure rate passes 17.5 per cent. Below that the retries are invisible and genuinely useful — they recover real work, and goodput with retries beats goodput without them. Above it they are the outage. There is no gradual region: the same configuration is a feature on one side of that number and the cause on the other.
These three get named together and conflated constantly. They defend against different things, and two of them do not bound amplification at all.
Exponential backoff spreads the retries out in time. Round two lands after roughly twice the delay of round one, round three after twice that. What it prevents is a single client hammering a dependency while it is trying to recover. What it does not prevent is the total count: twenty-seven attempts spread over thirty seconds is still twenty-seven attempts. In the model, turning backoff on moves the tipping point from 17.5 per cent to 37 per cent — a real and large gain, and entirely about how many retries share a window rather than how many exist.
Jitter randomises each delay. What it prevents is synchronisation: ten
thousand clients that all failed at the same instant, all backing off by the
same multiplier, all returning in the same hundred-millisecond bucket, forever,
because every round re-synchronises them. Jitter reduces the total number of
retries by exactly zero. It converts a series of spikes into a flat elevated
rate, which a system under recovery can survive and a spike cannot. Full jitter
— sleep a uniform random time between zero and the backoff ceiling, rather than
the ceiling plus a wobble — is the version worth copying, and it is what
tenacity's wait_random_exponential, Polly's DecorrelatedJitterBackoffV2
and resilience4j's IntervalFunction.ofExponentialRandomBackoff all implement.
Retry budgets are the only one of the three that bounds amplification. A
budget caps retries as a share of successful traffic: Envoy's retry_budget
allows retries up to a percentage of active requests to that cluster, gRPC's
retryThrottling is a per-server token bucket that a failed retry debits and a
success refills, and the AWS SDKs' standard and adaptive retry modes keep a
client-side retry quota that a retry debits and a timeout debits harder. When
the bucket empties, the retry is not attempted at all and the error goes
straight back to the caller. That is the behaviour you want during an outage and
it is the one people disable first, because during normal operation it does
nothing visible.
If you are adding one thing, add the budget. Backoff and jitter make a recovering dependency recoverable; only a budget stops the retry traffic from being the thing it has to recover from.
Check
The cheapest fix for a twenty-seven is not better retries at every layer. It is retrying at one layer.
Retry at the innermost layer and you retry fast, against a dependency you can see the health of, and you consume the caller's remaining deadline rather than starting a new one. Retry at the outermost layer and you retry a whole operation, which is more likely to succeed on a different path, and far more expensive.
The middle layers are the ones to strip. A proxy that retries is multiplying a
decision somebody else already made, with no information either of them has.
Envoy's x-envoy-max-retries and gRPC's per-method retryPolicy can both be
set to one attempt at the hop, which is often the single highest-value change
available in this whole lesson.
The other half is deadlines. If the caller passes a deadline down and every hop honours it, a retry that cannot finish inside the remaining time is not attempted. That bounds the time the twenty-seven can consume even when it does not bound the count — and a retry nobody is waiting for is pure amplification.
Everything above assumes each layer owns its own retry allowance. Once the allowance travels with the request — a retry count or a remaining-attempts token in the request metadata, decremented at every hop, with hops refusing to retry when it reaches zero — the product collapses to a single budget and the exponent disappears. The multiplier becomes whatever that one number says, three layers or thirty.
That is the design worth having, and it is also why this lesson's arithmetic stops describing a system that has it. If you can see a per-request retry budget in the request headers, stop reasoning about products and go read the budget.