What a microservice retry storm is and how it forms
A microservice retry storm happens when retries across many callers add up to more traffic than the failing system can absorb. No single retry policy causes it. Each one looks reasonable on its own, and the amplification only shows up when you look at the whole call graph.
The multiplication follows call depth. Take a chain where service A calls B, B calls C, and C calls D, and each of A, B and C makes up to 3 attempts. If every attempt fails, D receives up to 3 × 3 × 3 = 27 attempts for one user request (illustrative estimate). Add a fourth retrying layer and the worst case becomes 81.
Retries during overload also stop the system from recovering:
- An overloaded service responds slowly, so callers hit their timeouts.
- Each timeout triggers another attempt, which adds load and slows the service further.
- Work that was abandoned after a timeout often keeps running on the server, using capacity no caller is waiting for.
The feedback loop can sustain itself. The original trigger might be a deploy, a slow database or a brief traffic spike. After it clears, the retry traffic alone can keep latency high and the system degraded.
Much of the amplification is hidden because retries get configured in several layers at once. A client library may retry by default. A service mesh sidecar or proxy may retry the same call. Application code may wrap everything in its own loop. Each team sees only its own layer, so the real multiplier is rarely written down anywhere.
This matters to any backend or platform engineer who owns a service that calls another service.
1 user request
|
v
+-----------------------+
| Service A |
| up to 3 attempts |
+-----------------------+
| up to 3 calls to B
v
+-----------------------+ one hop, retries can stack:
| Service B | +---------------------------+
| up to 3 attempts/call | | app code retry loop |
+-----------------------+ | -> client lib retries |
| up to 9 calls | -> mesh/proxy retries |
v +---------------------------+
+-----------------------+ multiplier per hop is the
| Service C | product of all three layers
| up to 3 attempts/call |
+-----------------------+
| up to 27 calls to D
v
+-----------------------+
| Service D (failing) |
| slow -> timeouts -> |
| more retries -> load |
+-----------------------+
When A, B and C each make up to 3 attempts and every attempt fails, one user request can reach D up to 27 times (illustrative estimate), and retries stacked in application code, client libraries and mesh proxies multiply each hop further.
Why retries are used and when they help or hurt
Teams add retries because many failures are brief: a connection reset during a deploy, an instance that was just removed from the load balancer, a short leader election. A retry turns those into a slightly slower success instead of an error the user sees. The cost is added latency for the caller and added load on the dependency. That load tends to arrive when the dependency is least able to absorb it.
A retry is reasonable only when two things are true. The operation must be safe to run twice, and the error must suggest that a second attempt could succeed.
| Situation | Retry? | Reason |
|---|---|---|
| Idempotent read, or a PUT/DELETE by ID | Yes | Repeating produces the same end state |
| Write carrying an idempotency key | Yes | The server deduplicates repeats |
| Connection refused or reset before the request was sent | Yes | The server never processed it |
| 503 or 429 | Yes, after any Retry-After delay | The server is reporting a temporary condition |
| Validation error, 400, 401, 403, 404 | No | The same input will fail again |
| Non-idempotent write without a key | No | A repeat can duplicate side effects, such as a double charge |
A timeout after the request was sent is ambiguous, because the server may have completed the work. Treat it as retryable only when the operation is idempotent.
When a response includes Retry-After, use it as the minimum wait. The server is the only party that knows when capacity will return.
No single retry policy handles both transient blips and real overload. Aggressive retries recover from blips quickly but multiply load during overload. Conservative retries protect an overloaded dependency but let short blips reach users as errors.
Idempotency keys also have costs. The server must store each key with its result and check for it atomically. It must also keep each key for at least as long as any client might still retry. If keys expire too early, a late retry runs the operation a second time. If they are kept too long, the key store grows without limit.
How exponential backoff with jitter works
Plain exponential backoff doubles the wait after each failure, so attempt n sleeps for base × 2^n. The flaw is that clients failing at the same moment compute the same schedule. If a dependency rejects a thousand requests at once, all thousand retry after 100 ms, again at 200 ms, again at 400 ms. The waves spread out over time, but each one is as large as the first, and each one lands on a service that is trying to recover.
Jitter breaks that alignment by randomising the delay. Full jitter sleeps for a uniform random value between zero and the capped backoff, random(0, min(cap, base × 2^n)). Retries then spread across the whole window instead of clustering at its upper edge.
| Variant | Delay for attempt n | Behaviour |
|---|---|---|
| No jitter | min(cap, base × 2^n) |
Synchronised waves |
| Equal jitter | d/2 + random(0, d/2), where d is the capped backoff |
Guarantees a minimum wait, spreads load less |
| Full jitter | random(0, d) |
Widest spread, some retries happen almost immediately |
| Decorrelated jitter | min(cap, random(base, previous × 3)) |
Each delay depends on the previous one, not on n |
The cap and the attempt limit address different problems. Without a cap, the delay soon exceeds any useful deadline: ten doublings from 100 ms comes to about 102 seconds. Without an attempt limit, a capped loop keeps retrying at the cap interval for as long as the dependency stays down. That holds caller resources and keeps sending traffic to a service that is not answering. The cap bounds each individual wait, and the attempt limit bounds the total.
Jitter has two costs. The latency of a request that needs retries becomes a distribution rather than a fixed value, which widens tail percentiles. Timing-dependent bugs also become harder to reproduce. To keep them debuggable, make the random source injectable so tests can seed it, and log the delay that was actually chosen for each attempt.
import random
import time
BASE_DELAY = 0.1 # seconds; first backoff window is 0-100 ms
MAX_DELAY = 5.0 # cap: bounds any single wait
MAX_ATTEMPTS = 5 # limit: bounds total retries and total time spent
class RetryableError(Exception):
"""Raised for failures that are safe to retry (timeouts, 503s)."""
def full_jitter_delay(attempt):
# Capped exponential backoff for this attempt: min(cap, base * 2^n)
capped = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
# Full jitter: pick uniformly from [0, capped] so clients that
# failed together do not retry together
return random.uniform(0, capped)
def call_with_retries(operation):
for attempt in range(MAX_ATTEMPTS):
try:
return operation()
except RetryableError:
if attempt == MAX_ATTEMPTS - 1:
# Out of attempts: surface the failure instead of
# holding resources while the dependency stays down
raise
time.sleep(full_jitter_delay(attempt))
Capped exponential backoff with full jitter, where the delay cap bounds each wait and the attempt limit bounds how many times the caller retries.
Retry budgets and choosing a single retry layer
A per-request attempt limit caps how many times one call is retried. A retry budget caps how much retry traffic a client generates in total, as a ratio of requests over a sliding window. For example, retries may not exceed 10% of requests sent in the last 10 seconds. When the dependency fails broadly, an attempt limit still multiplies load. A budget does not, because retries can only grow in proportion to real traffic.
Illustrative estimate (assumed numbers, not a benchmark). Max retry rate = request rate × budget ratio.
- With a budget: assume 1,000 req/s and a 10% budget. Retries stay at or below 100/s whether 1% or 100% of calls fail.
- With an attempt limit only: 3 retries per call and total failure gives up to 1,000 × 3 = 3,000 retries/s.
A simple implementation is a token counter:
- Each successful or first-attempt request deposits 0.1 tokens, up to a fixed maximum.
- Each retry spends 1 token.
- If fewer than 1 token remains, the call fails immediately.
The maximum balance controls how much short-burst retrying the counter allows.
Budgets don't fix layered retries. If the gateway, a service and its client library each retry, the attempts multiply. Let one layer own retries. This is usually the one closest to the caller that knows whether the operation is idempotent and how much deadline remains. Every other layer should fail fast, or mark its responses with an explicit no-retry signal, such as a response header or error code that upstream layers respect.
The trade-offs:
- Blocked retries during spikes. A budget can block retries that would have succeeded during a brief transient spike.
- Choosing the ratio and window. Picking good values requires measuring your real failure and traffic patterns.
- Cross-team audits. Removing layered retries means auditing proxy, mesh, SDK and application settings, which different teams often own.
import threading
class TransientError(Exception):
"""Failure that may succeed on retry (timeout, 503)."""
class NoRetryError(Exception):
"""Downstream explicitly signaled: do not retry (another layer owns retries)."""
class RetryBudget:
def __init__(self, ratio=0.1, max_tokens=10.0):
self.ratio = ratio # each request earns 0.1 tokens => retries ~<= 10% of requests
self.max_tokens = max_tokens # caps how much short-burst retrying is allowed
self.tokens = max_tokens
self.lock = threading.Lock()
def record_request(self):
with self.lock: # deposit on first attempts only, so retries can't earn more retries
self.tokens = min(self.max_tokens, self.tokens + self.ratio)
def try_spend_retry(self):
with self.lock:
if self.tokens < 1.0:
return False # budget exhausted: fail fast instead of adding load
self.tokens -= 1.0
return True
def call_with_budget(budget, send, max_attempts=3):
# Per-request limit (max_attempts) AND a shared client-wide budget both apply.
budget.record_request()
for attempt in range(max_attempts):
try:
return send()
except NoRetryError:
raise # respect the no-retry signal from a lower layer
except TransientError:
last = attempt == max_attempts - 1
if last or not budget.try_spend_retry():
raise
A thread-safe token-counter retry budget in Python where first attempts deposit 0.1 tokens, each retry spends one, and an explicit no-retry error from a lower layer is never retried.
Circuit breakers, deadlines and server-side load shedding
A retry budget caps how much extra load clients add. A circuit breaker stops a client from calling a dependency that is already failing.
| State | Caller behavior | Leaves the state when |
|---|---|---|
| Closed | Sends all calls and records outcomes | Failure rate or slow-call rate crosses its threshold over a window with enough calls |
| Open | Skips the dependency and runs the fallback | A cool-down timer expires |
| Half-open | Lets a small, fixed number of probe calls through | Probes succeed (closed) or fail (open again) |
The minimum request volume keeps two failures out of three calls from tripping the breaker. The slow-call threshold matters because a dependency that hangs does more damage than one that errors quickly. Half-open probing limits recovery traffic, so a service coming back up does not get the full load at once.
While the breaker is open, callers should fall back to another path, serve a cached or degraded response, or return a fast error.
Deadlines should travel with each request as an absolute expiry time. When a downstream service sees the deadline has passed, it stops its work and does not retry. All retry attempts, including backoff, must fit inside the caller's timeout. Otherwise the caller has already given up and the retries are wasted load.
Server-side defenses include load shedding and concurrency limits. An overloaded server should reject excess requests early with an explicit overload response, such as a 503 with a retry-after hint. This lets clients tell "back off" apart from "try again."
Trade-offs
- Breakers flap when traffic is low or noisy.
- A breaker scoped to a whole service, rather than to one endpoint or host, can cut off healthy capacity.
- A breaker with no real fallback only turns slow errors into fast ones.
- Each instance decides on its own, so across a large fleet the combined probe and retry traffic can still overshoot what the dependency can handle.
import time
class CircuitBreaker:
"""Minimal single-threaded breaker; production code needs locking and a sliding window."""
def __init__(self, failure_rate=0.5, slow_call_s=2.0, min_calls=20,
window=100, cooldown_s=30.0, probes=3):
self.failure_rate, self.slow_call_s, self.min_calls = failure_rate, slow_call_s, min_calls
self.window, self.cooldown_s, self.probes = window, cooldown_s, probes
self._reset("closed")
def _reset(self, state):
self.state, self.calls, self.bad, self.admitted = state, 0, 0, 0
if state == "open":
self.opened_at = time.monotonic()
def call(self, fn, fallback, deadline):
now = time.monotonic()
if now >= deadline: # Caller already gave up: do no work and do not retry.
raise TimeoutError("deadline exceeded")
if self.state == "open":
if now - self.opened_at < self.cooldown_s:
return fallback() # Cached/degraded response or fast error, no downstream call.
self._reset("half_open")
if self.state == "half_open":
if self.admitted >= self.probes:
return fallback() # Only a few probes reach the recovering dependency.
self.admitted += 1
try:
result = fn(timeout=deadline - now) # Pass the remaining budget, not a fixed timeout.
except Exception:
self._record(ok=False)
return fallback()
self._record(ok=time.monotonic() - now < self.slow_call_s) # Slow success counts as bad.
return result
def _record(self, ok):
self.calls, self.bad = self.calls + 1, self.bad + (not ok)
if self.state == "half_open":
if not ok: self._reset("open") # One failed probe re-opens immediately.
elif self.calls >= self.probes: self._reset("closed")
elif self.calls >= self.min_calls and self.bad / self.calls >= self.failure_rate:
self._reset("open") # Trip only once the window has enough volume to be meaningful.
elif self.calls >= self.window: self._reset("closed") # Start a fresh window.
A minimal Python circuit breaker that trips on failure or slow-call rate only after a minimum call volume, admits a fixed number of half-open probes, serves a fallback while open, and refuses work once the caller's absolute deadline has passed.
Tuning the three mechanisms together and what to observe
Implementation order
- Classify errors. Retry only transient failures such as connection resets, 503s and explicit overload signals. Never retry validation errors or non-idempotent writes without idempotency keys.
- Pick one retry-owning layer, usually the client closest to the caller. Set every other layer to zero retries.
- Set backoff with full jitter and a cap.
- Add a retry budget so total retries stay bounded even when every request fails.
- Add breakers and propagate deadlines so downstream calls stop when the caller has given up.
- Validate under load by injecting latency and errors into a dependency, then confirm that total traffic stays near original volume.
How the settings constrain each other
Each setting limits the others, so tune them as a set. The attempt count times the per-attempt timeout, plus the worst-case backoff, must fit inside the deadline. If it doesn't, the last attempts run after nobody is waiting for them. The budget ratio caps amplification while the breaker is closed. Set the breaker's trip threshold so the budget runs out before the breaker opens. That way, budget exhaustion acts as the early warning.
Illustrative starting point:
- 2 s deadline
- 3 attempts, each with a 400 ms timeout
- 100 ms base backoff with full jitter, capped at 1 s
Worst case: 1.2 s of attempts plus about 300 ms of backoff, which fits inside 2 s.
- Retry budget of 10% of requests
- Breaker opens at 50% failures over at least 20 requests
What to observe
- Retry rate vs. original request rate. A rising ratio is amplification in progress.
- Budget exhaustion events. These are the first sign that a dependency is degrading.
- Breaker state transitions. Repeated open/half-open flapping means thresholds or probe rates are wrong.
- Attempts per request (p99). This should stay well below the configured maximum.
All four move minutes before error rates spike. Alert on them, not just on failures.
Alternatives and impact on cost and engineering time
Each alternative below handles transient failure differently and shifts the cost to a different place.
| Approach | Fits best | Trade-off |
|---|---|---|
| No retries, fail fast | User-facing paths that can degrade gracefully | Transient blips surface as errors |
| Hedged requests | Idempotent reads with long-tail latency | Adds load by design; needs a hedge cap |
| Queue with dead-letter handling | Work that need not finish inline | Backlog replaces amplification; needs idempotent consumers and DLQ triage |
| Adaptive concurrency limits | Servers whose capacity shifts | Limits can oscillate if the latency signal is noisy |
| Centralized rate control | Shared downstreams with many callers | The controller becomes a new dependency |
Centralized control brings its own failure modes. If the quota service slows down, every caller can stall behind it. Cached quotas go stale during partitions. Each client also has to fail open or fail closed, and you should make that choice deliberately rather than inherit it from a library default.
The upfront work is real. You need storage for idempotency keys with sensible expiry. You need an audit that finds every retry layer, including HTTP clients, SDKs, the mesh and hand-written loops. You also need load tests that inject failures, not just traffic.
The payoff is shorter incidents. A related saving comes from shared libraries: when defaults ship in one place, teams stop re-deriving them. The metrics from section 6 also expose amplification before it becomes a postmortem finding.
Illustrative estimate (assumed numbers, not a benchmark): engineer-hours saved per year = incidents per year × hours shorter per incident × responders.
Assume 6 incidents, each 0.75 hours shorter, with 4 responders: 6 × 0.75 × 4 = 18 engineer-hours. Compare that figure with your own audit and testing effort.
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 microservice retry storm, 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.