What is a retry storm?
A retry storm is a failure mode where client retries, meant to hide brief errors, become the main source of load on a struggling dependency. Each caller acts reasonably on its own: a request failed or timed out, so it tries again. Across many callers and many layers, those retries add up to far more traffic than users are actually sending. The dependency then slows further, which causes more failures and more retries.
The defenses covered here are exponential backoff, jitter and retry budgets. Backoff and jitter control when retries happen. Retry budgets control how many happen. Two supporting practices make them effective: retrying at a single layer and making writes idempotent. Server-side mechanisms complete the picture: pushback signals, deadline propagation, load shedding and circuit breakers.
How a retry storm forms in microservices
Teams add retries to hide transient failures such as a dropped connection, a pod restarting mid-request, or a brief leader election. A single retry usually succeeds, and the caller never sees the error. A retry storm happens when that same logic fires under sustained failure. Retries then add load to a dependency that is already short on capacity.
The feedback loop
- A dependency slows down because of a GC pause, a hot shard, or a noisy neighbor.
- Callers' timeouts fire before responses arrive.
- Callers retry. The original requests are often still running on the server, so it now handles the abandoned work as well as the new attempts.
- Offered load rises, queues grow, latency gets worse, and more timeouts fire.
This loop can produce a metastable failure: the system stays degraded after the trigger is gone. The GC pause ends, but retry traffic alone keeps the service above capacity. Removing the original cause does not bring it back. You have to shed load.
Amplification across layers
If each of N layers makes up to k attempts, the deepest service can see up to k^N times the original load.
Illustrative estimate (assumed inputs): 4 layers, 3 attempts each (1 try + 2 retries), and every call failing:
Client ──► Gateway ──► Orders ──► Inventory ──► Database
1 req 3 calls 9 calls 27 calls 81 calls
(×3) (×3) (×3) (×3)
One user request becomes up to 81 calls at the database, and every one of them arrives while the database is already failing.
┌──────────────┐
│ Client │ 1 user request ◄────────────────────┐
└──────┬───────┘ ▲
│ ×3 attempts │
▼ │
┌──────────────┐ │
│ Gateway │ up to 3 calls │
└──────┬───────┘ │ timeouts fire,
│ ×3 per call │ every layer
▼ │ retries again,
┌──────────────┐ │ load rises
│ Orders │ up to 9 calls │
└──────┬───────┘ │
│ ×3 per call │
▼ │
┌──────────────┐ │
│ Inventory │ up to 27 calls │
└──────┬───────┘ │
│ ×3 per call │
▼ │
┌──────────────┐ │
│ Database │ up to 81 calls (3^4) slows ─────────┘
└──────────────┘
Illustrative estimate: with 4 layers each making up to 3 attempts and every call failing, one user request becomes up to 81 database calls, and the slower database then triggers more timeouts and retries in every layer above it.
How retry storm protection works
The defenses form layers, and each one addresses a different part of the feedback loop above:
- Backoff spaces out attempts from one client, so a single caller does not retry in a tight loop.
- Jitter randomizes those delays, so many clients that failed at the same moment do not retry in the same instant.
- Attempt caps and deadlines bound how long one logical request can keep retrying.
- Retry budgets bound total retry volume as a fraction of first-attempt traffic. This turns the worst case from multiplicative into a small, fixed overhead.
- Retrying at one layer removes the k^N multiplication across the call graph.
- Idempotency keys make retried writes safe, so retries do not cause duplicate side effects.
- Server-side pushback, deadline propagation, load shedding and circuit breakers let the overloaded service refuse work cheaply and let clients stop sending it.
No single mechanism is enough. Jitter without a budget still allows every caller its full set of attempts. A budget without load shedding still lets many clients exceed capacity together. The sections below take each mechanism in turn.
Exponential backoff and jitter: what they fix and what they don't
Backoff controls when a client tries again. The delay for attempt n is min(cap, base × multiplier^n). With a 100 ms base and a multiplier of 2, the waits are 100, 200, 400, 800 ms, until the delay reaches the cap. A separate limit, on attempt count or total elapsed time, decides when to give up.
Backoff alone does not prevent a thundering herd. When a dependency fails, thousands of clients see the error in the same few milliseconds. They all compute identical delays, so they retry together at 100 ms, again at 300 ms, and so on. The load does not drop. It arrives as synchronized waves that hit the service each time it starts to recover. Jitter breaks this alignment by randomizing each delay.
| Strategy | Sleep computation | Optimizes for |
|---|---|---|
| Full jitter | random(0, min(cap, base × 2^n)) |
Lowest aggregate load; fastest system recovery |
| Equal jitter | t = min(cap, base × 2^n); t/2 + random(0, t/2) |
Guaranteed minimum wait per client |
| Decorrelated jitter | min(cap, random(base, prev × 3)) |
Spread without tracking the attempt count |
Each choice has a cost. Full jitter can pick long delays for an individual request, which raises its tail latency, but the fleet as a whole recovers faster. A cap set too low collapses every client into a narrow window, so a recovering service is still hammered. A cap set too high lets retries run past the caller's deadline, wasting work that no one will receive.
Jitter spreads retries out in time. It does not limit how many retries there are.
import random
import time
BASE = 0.1 # seconds; first backoff window
CAP = 5.0 # upper bound on any single sleep
MAX_ATTEMPTS = 5 # total tries, including the first call
DEADLINE = 8.0 # total elapsed budget; keep it under the caller's deadline
def full_jitter(n, prev):
# Anywhere in [0, window]: lowest aggregate load, worst per-request tail
return random.uniform(0, min(CAP, BASE * 2 ** n))
def equal_jitter(n, prev):
# Always waits at least half the window
t = min(CAP, BASE * 2 ** n)
return t / 2 + random.uniform(0, t / 2)
def decorrelated_jitter(n, prev):
# Grows from the previous sleep, not from the attempt number
return min(CAP, random.uniform(BASE, prev * 3))
def call_with_retries(op, sleep_fn=full_jitter):
start = time.monotonic()
prev = BASE
for n in range(MAX_ATTEMPTS):
try:
return op()
except ConnectionError: # retry only errors that are likely transient
if n == MAX_ATTEMPTS - 1:
raise
delay = sleep_fn(n, prev)
prev = delay
# Give up rather than sleep past the deadline; no one would get the result
if time.monotonic() - start + delay > DEADLINE:
raise
time.sleep(delay)
# Note: jitter changes WHEN these retries land, not HOW MANY.
# Every caller can still send up to MAX_ATTEMPTS requests.
A Python retry loop with base delay, multiplier, per-sleep cap, attempt cap and elapsed-time deadline, plus full, equal and decorrelated jitter as interchangeable sleep functions.
Retry budgets: capping retries as a fraction of real traffic
A retry budget puts a ceiling on total retry volume. The client may retry only while retries stay below a fixed ratio of first-attempt requests. A common assumed policy is 10–20% extra load. When the dependency degrades, retry volume stops growing with failures and stays near that ceiling.
A token bucket implements this. Each first-attempt request deposits a fraction of a token, such as 0.1. Each retry withdraws one whole token. If the bucket is empty, the client returns the original error instead of retrying. Cap the bucket's size so that a long quiet period cannot build up a reserve large enough to fund a burst of retries.
Illustrative estimate (assumed numbers, not a benchmark): Max retry rate = first-attempt rate × deposit ratio. At 2,000 requests/s with a ratio of 0.1, retries cannot exceed 200/s. That holds even when every request fails, so peak load is 1.1× normal instead of 3× under a three-attempt policy.
The budget can live in three places:
- Client library: cheapest to adopt, but every language needs its own implementation.
- Sidecar: applies one policy to every service in a pod, whatever language it uses.
- Proxy: allows a budget shared across many callers.
There are two costs. First, during a brief, isolated blip, the budget may refuse retries that would have succeeded, so the success rate drops slightly. Second, client-side budgets are local. A thousand clients, each within its own 10%, still add 10% to an already struggling shared dependency.
Teams that own high fan-in services or shared dependencies benefit most. They receive the combined retries of every caller, so a cap on the callers' side protects them most directly.
import threading
class RetryBudget:
"""Token bucket: first attempts deposit a fraction of a token, retries spend one."""
def __init__(self, deposit_ratio=0.1, max_tokens=10.0):
self.deposit_ratio = deposit_ratio # 0.1 -> retries capped near 10% of first attempts
self.max_tokens = max_tokens # cap so quiet periods cannot fund a big retry burst
self.tokens = max_tokens
self.lock = threading.Lock()
def on_first_attempt(self):
with self.lock:
self.tokens = min(self.max_tokens, self.tokens + self.deposit_ratio)
def try_spend_retry(self):
with self.lock:
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False # budget exhausted: caller surfaces the original error
def call_with_budget(budget, send, max_attempts=3):
budget.on_first_attempt() # only first attempts refill the bucket
last_error = None
for attempt in range(max_attempts):
if attempt > 0 and not budget.try_spend_retry():
break # stop retrying; do not add load to a degraded dependency
try:
return send()
except ConnectionError as err:
last_error = err
# backoff with jitter would go here before the next attempt
raise last_error
A thread-safe retry budget as a capped token bucket, where each first attempt deposits 0.1 tokens and each retry spends one, so retries stay near 10% of first-attempt traffic even when every request fails.
Deciding what and where to retry
A cap only works if retries happen in a predictable place. If every hop retries, attempts multiply. Three layers making three attempts each can turn one user request into 27 calls at the bottom of the stack. The safer default is to retry at one layer, usually the edge or the caller directly in front of the failing dependency, and have every other layer fail fast.
That has a cost. Inner services now pass transient errors upward instead of hiding them. Callers across teams therefore need consistent error semantics, so a given status means "safe to retry" in every service, not only in some.
| Error type | Decision | Why |
|---|---|---|
| Connection reset or refused | Retry (idempotent or keyed requests only) | Usually transient. The request may still have been processed. |
Explicit "unavailable" (HTTP 503, gRPC UNAVAILABLE) |
Retry | The server is saying a later attempt may succeed. |
| "Overloaded, back off" (HTTP 429, load-shed responses) | Don't retry immediately | Retrying adds the load the server just refused. |
| Most 4xx (400, 401, 403, 404, 422) | Don't retry | The same request will fail the same way. |
| Non-idempotent write with no key | Don't retry | The first attempt may have succeeded. |
Writes need idempotency. A retried payment after a timeout can charge a card twice. With an idempotency key, the client sends a unique key with the request and the server returns the stored result for a repeat key instead of running the operation again. The key store has its own costs:
- It must be written atomically with the side effect, or a crash between the two reopens the duplicate.
- Keys must be kept longer than the longest retry window.
- Keys must then expire, or the store grows without bound.
Finally, audit every layer. Client libraries, proxies and service meshes may retry by default, and those hidden retries stack on top of yours.
Coordinating with servers, timeouts and circuit breakers
Retries issued by libraries, proxies or meshes on a client's behalf should also count against the budget. Retries are only half of the protocol, though. The server has to push back, and the client has to listen.
Honor pushback. A 429 or 503 with Retry-After comes from the one party that can see the queue depth. Treat it as a minimum delay, and treat an explicit "do not retry" signal as final.
Propagate deadlines. A per-attempt timeout bounds a single try. An overall deadline bounds the whole operation, including every retry and backoff sleep. Pass the remaining deadline downstream so services stop work the caller has already abandoned. Skip a retry when the time left cannot fit another attempt.
Shed load server-side. A retry budget is local. Many clients each staying within their own budget can still exceed capacity in aggregate. Servers must reject excess early, before auth, parsing or database work, so that a rejection costs far less than serving the request.
Keep breakers and budgets from fighting. A breaker reacts to sustained failure of a dependency. A budget limits amplification during partial or transient failure. Feed the breaker the final outcome of each logical request, not the raw attempt count, so retries cannot inflate the error rate and trip it early. When the budget is exhausted, return the original error and emit a metric, so a real outage stays visible instead of looking like throttling.
| Mechanism | Responds to | Scope | Effect |
|---|---|---|---|
| Retry budget | Retry amplification | Per client | Caps retries as a fraction of traffic |
| Retry-After / pushback | Server overload | Per response | Delays or forbids the next attempt |
| Deadline propagation | Abandoned work | Whole call chain | Stops downstream work on expiry |
| Load shedding | Excess aggregate load | Per server | Fails extra requests fast and cheaply |
| Circuit breaker | Sustained dependency failure | Per dependency | Stops calls entirely for a period |
import random, time
class AttemptFailed(Exception):
def __init__(self, retry_after=None, do_not_retry=False):
super().__init__("attempt failed")
self.retry_after = retry_after # seconds parsed from Retry-After, if sent
self.do_not_retry = do_not_retry # explicit server "stop" signal
def call(send, budget, breaker, metrics, deadline_s=2.0, per_attempt_s=0.5,
base_s=0.05, cap_s=1.0, max_attempts=4, min_attempt_s=0.05):
deadline = time.monotonic() + deadline_s # bounds the whole logical request
for attempt in range(max_attempts):
remaining = deadline - time.monotonic()
try:
# Per-attempt timeout never outlives the overall deadline, and the
# remaining time goes downstream so callees can stop abandoned work.
result = send(timeout=min(per_attempt_s, remaining),
deadline_remaining_s=remaining)
breaker.record(success=True) # final outcome of the logical request
return result
except AttemptFailed as err:
last_err = err
if err.do_not_retry or attempt == max_attempts - 1:
break
delay = random.uniform(0, min(cap_s, base_s * 2 ** attempt)) # full jitter
if err.retry_after is not None:
delay = max(delay, err.retry_after) # server pushback is a floor
if time.monotonic() + delay + min_attempt_s >= deadline:
break # another attempt cannot fit in the time left
if not budget.try_spend():
# Keep the real error visible; do not disguise it as throttling.
metrics.increment("retry_budget_exhausted")
break
time.sleep(delay)
# One failure per logical request, so retries cannot inflate the error rate
# the breaker sees and trip it early.
breaker.record(success=False)
raise last_err
A client retry loop that treats Retry-After as a minimum delay, honors do-not-retry, bounds attempts by an overall deadline it propagates downstream, checks a retry budget, and reports only final outcomes to the circuit breaker.
Observability and testing for retry behavior
Retries hide inside aggregate request counts, so instrument them as their own signal. Each client should emit three things per dependency:
- First attempts and retries as separate counters. A single "requests" counter rises during a storm whether traffic is real or amplified. Separate counters show which one is happening.
- Retry ratio (retries ÷ first attempts). This is the number your budget enforces, so graph it against the budget limit.
- Budget-exhaustion events. Count each retry the budget refused, and alert on a sustained non-zero rate. Exhaustion means the dependency is failing faster than the budget can absorb, which usually needs a human to look at it.
Keeping these separate lets you tell apart failure modes that look the same in a total-traffic graph. If the breaker opens and both first attempts and retries drop to near zero, the breaker is shedding load. If first attempts stay flat while budget exhaustion climbs and the breaker stays closed, the breaker's threshold may be too lenient. In that case the budget is the only thing holding back amplification. A combined counter cannot show either pattern.
Around a retry wrapper, the instrumentation is small. Before the first call, increment attempts_total{dependency, attempt="first"}. Before each retry, increment it with attempt="retry". When the budget denies a retry, increment retry_budget_exhausted_total{dependency} and return the original error. Record latency per attempt, not per logical call, so you can see backoff time separately from the dependency's own response time.
For testing, run load at realistic volume and inject a fault into one dependency, such as added latency or a raised error rate. Hold the fault long enough for the retry ratio to reach the budget, then remove it. The test passes only if throughput and latency return to baseline without a restart or a traffic drop. If the system stays degraded after the fault is gone, it is metastable: retry load is keeping it in the failed state.
Alternatives to retry budgets
Retry budgets are one way to bound amplification. Other approaches overlap with them, and most systems combine several.
- No client retries; fail fast. The simplest option. Errors surface immediately and callers degrade or show an error. It removes amplification entirely, but every transient blip becomes a user-visible failure.
- Fixed attempt caps with backoff and jitter only. Adequate for shallow call graphs and low volume. The worst case is still multiplicative: every caller can send its full set of attempts during an outage.
- Circuit breakers alone. A breaker stops calls to a dependency after sustained failure. It does not limit retries before it trips, and a threshold that is too lenient or too slow lets a storm build first. Breakers work best alongside a budget, not instead of one.
- Server-side load shedding and admission control. The server rejects work it cannot handle, ideally before doing anything expensive. This protects the server whatever clients do, which client budgets cannot guarantee. It does not reduce the network and connection cost of rejected requests, so client-side limits are still useful.
- Adaptive concurrency limits. Clients or servers adjust how many requests are in flight based on observed latency or errors, similar in spirit to TCP congestion control. This limits load from all requests, not only retries, but takes more tuning.
- Rate limiting per caller. Caps each caller's request rate at the server or gateway. It bounds aggregate load but does not tell first attempts from retries, so under a storm it can reject real traffic in favor of retries.
- Asynchronous processing through a queue. For work that does not need an immediate answer, the caller enqueues a job and a worker retries at its own pace. Retry pressure then becomes queue depth, which is easier to observe and drain than live traffic. It changes the API contract, so it only fits suitable workloads.
Hedged requests, which send a second copy of a slow request to another replica, are sometimes listed here. They cut tail latency but add load. Under overload they behave like retries, so they need their own budget.
When to use retry budgets, when to avoid them, and the cost
When budgets pay off
Retry budgets and retrying at a single layer earn their cost when amplification compounds. That happens in deep call graphs, in shared dependencies such as an auth service or a primary database that many callers hit, and in high fan-in services where one slow backend sees retries from dozens of clients at once.
Illustrative estimate (assumed numbers, not a benchmark). Assume 4 layers, each allowing 3 retries (4 attempts). Worst-case load at the bottom is attempts_per_layer ^ depth = 4⁴ = 256× the original request. If you retry only at the edge, it is 4×. A 10% budget caps it near 1 + budget = 1.1×.
When to avoid or simplify
- Single-hop, low-volume calls. A plain capped backoff with jitter is enough, and a budget adds little.
- Non-idempotent operations without idempotency keys. Don't retry these at all until keys exist.
- Latency-critical paths. When a late answer is worthless, failing fast and degrading is better than retrying.
The cost
You need a shared retry library, an agreement across teams on which errors are retryable, storage for idempotency keys with a retention policy, and an audit of service mesh and client library defaults that may be retrying silently. The audit is often the largest single task.
The return
Incidents end sooner because retries stop feeding the overload. Separate metrics for original requests and retries show quickly whether load is real traffic or self-inflicted. Teams maintain one central policy instead of tuning retries service by service.
How retry budgets save engineering time
The time savings come from fewer, shorter incidents and from less duplicated work across teams.
- Shorter incidents. Without a budget, a metastable failure often needs manual action, such as scaling out, restarting services or blocking traffic, before it clears. With retries bounded, the system usually recovers once the trigger goes away, so responders spend less time breaking the feedback loop by hand.
- Faster diagnosis. Separate counters for first attempts and retries, plus budget-exhaustion alerts, answer early on the question responders otherwise spend time on: is this real demand or self-inflicted load?
- One policy instead of many. A shared library or sidecar replaces per-service retry code in each language. Backoff, jitter, budget and deadline handling get written, reviewed and tested once. Tuning changes happen in one place.
- Fewer duplicate-write cleanups. Idempotency keys and clear rules on which errors are retryable prevent double charges and duplicate records. Those otherwise lead to data-repair work and support tickets after an incident.
- Safer capacity planning. When retries add a known overhead near
1 + budgetinstead of a multiplicative worst case, teams can size dependencies for real traffic plus a fixed margin. They do not need to over-provision for amplification.
Illustrative estimate (assumed inputs, not a measurement). Assume a team has 4 retry-amplified incidents a year. Assume each involves 3 engineers for 4 hours, and that bounded retries cut each to 1 hour, mostly by removing manual load shedding. That saves 4 × 3 × 3 = 36 engineer-hours a year. Weigh it against the one-time cost of the shared library and the default-retry audit described above. Your own incident history is the input that matters.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to retry storm microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.