What a retry storm is and how the amplification math works

A retry storm happens when many clients respond to a slow or failing dependency by retrying at the same time. Each retry is reasonable on its own. Together they add load at the moment the dependency has the least capacity to absorb it. The problem belongs to the whole system, not to any single client.

The amplification compounds across layers. If each of N layers makes up to R retries (R+1 attempts in total), the deepest dependency can receive up to (R+1)^N attempts for every original request.

Illustrative estimate. Assume 3 layers, each making 3 attempts:

client
  └─> A  (3 attempts)
        └─> B  (3 × 3 = 9 attempts)
              └─> C  (3 × 3 × 3 = 27 attempts)

One user request can become 27 calls to C. If C was already near capacity, this worst case is far more traffic than it can serve.

The more serious effect is metastable failure. A short trigger, such as a deploy, a GC pause or a brief network fault, slows C down. Callers time out and retry. The retries keep C saturated, so more calls time out, which produces more retries. The trigger then goes away, but the system stays down. The load that keeps C saturated is now the retry traffic itself.

Fixing the original cause does not end the outage, because that cause is already gone. Recovery requires shedding load until the feedback loop breaks. The controls in this article exist to keep that loop from forming, or to cut it off quickly when it does.

ILLUSTRATIVE ESTIMATE (assumed: 3 layers, 3 attempts per hop, worst case)

+--------+           +--------+           +--------+           +--------+
| client |---------->|   A    |---------->|   B    |---------->|   C    |
| 1 req  |  3 tries  | 3 recv |  9 tries  | 9 recv | 27 tries  |27 recv |
+--------+           +--------+           +--------+           +--------+
                                                                   ^
             deepest dependency: up to (R+1)^N = 3^3 = 27x load ---+

METASTABLE LOOP

 TRIGGER (deploy, GC pause, network fault): brief, then gone
          |
          v
 +-----------------+     +-----------------+     +-----------------+
 | C slow /        |---->| callers time    |---->| callers retry   |
 | saturated       |     | out             |     | (more load on C)|
 +-----------------+     +-----------------+     +-----------------+
          ^                                               |
          |    retry traffic alone sustains the loop      |
          +-----------------------------------------------+

 EXIT: shed load until the loop breaks; fixing the trigger
       alone does not end the outage.

Illustrative estimate: with 3 attempts at each of 3 hops, one request becomes up to 27 calls to C, and after a brief trigger passes, the timeout-retry feedback loop can keep C saturated until load is shed.

Who needs these controls and when to apply them

If you own a service that sits in the middle of a deep call graph, these controls are your problem even if your own code never fails. The same applies if you fan out to many backends per request, or if you run a service mesh that injects retry policy into every hop. Platform engineers carry extra weight here: a single default retry setting in a mesh or shared client library is multiplied across every service that inherits it.

Apply the full set (backoff with jitter, budgets, breakers) in three situations:

  • Multi-layer synchronous calls. Retries at each layer multiply.
  • Shared databases or caches. Many callers converge on one resource, so their retries combine there.
  • Autoscaling lag. New capacity takes minutes to arrive, while retries arrive in milliseconds.

Retry only when a second attempt has a real chance of succeeding and cannot cause harm. Transient transport failures and explicit overload signals meet that bar. When the server sends a backoff hint such as Retry-After, follow it. The server knows its own recovery time better than your client's default schedule does.

Do not retry when:

  • The request itself is wrong. Repeating it will fail the same way.
  • The operation is not idempotent and has no idempotency key.
  • A timeout leaves the outcome unknown. A timed-out payment may already have been charged.
Failure type Retry? Reason
Connection reset / refused Yes, with backoff Usually transient; the request likely never ran
503 Service Unavailable Yes, with backoff and budget Temporary overload or restart
429 Too Many Requests Yes, honoring Retry-After Server has stated when to return
Timeout, idempotent operation Cautiously, within budget Safe to repeat, but adds load to a slow server
Timeout, non-idempotent, no key No Work may have completed
400, 401, 403, 404, 422 No Client error; retry repeats the failure
Non-idempotent write, no key No Risk of duplicate side effects

How jittered exponential backoff breaks synchronized waves

Plain exponential backoff spreads retries out in time but keeps them aligned. Suppose a dependency fails at one instant and a thousand clients see the error together. Each client waits exactly 100 ms, then 200 ms, then 400 ms, so all thousand retries land in the same few milliseconds every time. The dependency never gets a quiet window to recover. It gets a sequence of synchronized spikes that grow further apart but stay just as tall.

Jitter fixes this by randomizing each delay so the retries spread across the whole window.

Strategy Delay for attempt n (cap = C, base = B) Behaviour
Full jitter random(0, min(C, B·2ⁿ)) Widest spread; some retries fire almost immediately
Equal jitter half of min(C, B·2ⁿ), plus random(0, that half) Guarantees a minimum wait, but a narrower spread
Decorrelated jitter min(C, random(B, previous·3)) Each delay depends on the last; spread stays wide

The differences between these formulas matter far less than the choice to randomize at all. Any of them turns a spike into a smear. Full jitter is the easiest to reason about and the hardest to get wrong.

Set two hard limits in every case:

  • A maximum delay. Without one, the exponential term eventually produces waits of many minutes.
  • A maximum attempt count. Without one, a permanent failure keeps a request retrying forever.

Jitter has a cost. A retried request now waits a random amount of time, which pushes up p99 latency for exactly the requests that are already struggling. Long backoffs can also outlast the caller's deadline, which wastes the retry entirely. Check the remaining deadline before sleeping. If the next delay won't fit, fail fast instead.

import random
import time

BASE_DELAY = 0.1    # seconds; first retry ceiling
MAX_DELAY = 5.0     # hard cap so the exponential term cannot grow unbounded
MAX_ATTEMPTS = 5    # hard cap so a permanent failure stops retrying

class RetryableError(Exception):
    """Raised by operation() for failures worth retrying (e.g. 503, timeout)."""

def call_with_retries(operation, deadline):
    """deadline is an absolute time.monotonic() value set by the caller."""
    for attempt in range(MAX_ATTEMPTS):
        try:
            return operation()
        except RetryableError:
            if attempt == MAX_ATTEMPTS - 1:
                raise  # out of attempts: surface the failure

            # Capped exponential ceiling: 0.1, 0.2, 0.4, ... up to MAX_DELAY.
            ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))

            # Full jitter: any point in [0, ceiling], so clients that failed
            # together retry at different moments instead of in one wave.
            delay = random.uniform(0, ceiling)

            # If the sleep alone would use up the caller's remaining time,
            # the retry cannot succeed in time. Fail fast instead.
            remaining = deadline - time.monotonic()
            if delay >= remaining:
                raise

            time.sleep(delay)

Capped full-jitter backoff that limits both delay and attempt count and fails fast when the next sleep would not fit inside the caller's deadline.

Retry budgets and single-layer retries: bounding aggregate load

Per-request attempt limits such as "at most three attempts" protect a single call, not the dependency. If every request fails, three attempts per request still triples the load on a service that is already struggling. A retry budget caps the total instead: retries may not exceed a fixed fraction of recent request volume, often around 10%.

The usual implementation is a token bucket per client or per destination. Each first attempt deposits a fraction of a token (0.1 for a 10% budget). Each retry withdraws one full token. Cap the bucket so a quiet period cannot store up a large burst. When the bucket is empty, return the original error instead of retrying.

Illustrative estimate (assumed numbers, not a benchmark): worst-case load ≈ base rate × (1 + budget ratio). At 1,000 req/s with a 10% budget, the peak is about 1,100 req/s. With a three-attempt limit and no budget, the peak is 3,000 req/s.

Budgets break down if every layer retries, because attempts multiply with call depth: attempts^depth, so 3⁴ = 81 for four layers. Retry in only one place. That should be the edge, or the caller closest to the failing dependency. Intermediate services should fail fast and propagate the error. To enforce this, mark retried requests with a header or metadata flag, and have downstream services refuse to retry anything that carries it.

Propagate an absolute deadline as well. Before each attempt, check the remaining time. Set each attempt's timeout to the smaller of the per-attempt timeout and the remaining deadline. Stop when the remaining time cannot fit the next backoff plus a useful attempt.

These controls have trade-offs. During short blips, a budget will fail some requests that a single retry would have saved. Service mesh retries stacked on application retries multiply invisibly, so choose one layer and turn the other off.

import random, time

class RetryBudget:
    """Per-destination token bucket: retries capped at ~ratio of first attempts."""
    def __init__(self, ratio=0.1, max_tokens=10.0):
        self.ratio, self.max_tokens, self.tokens = ratio, max_tokens, max_tokens
    def on_request(self):
        # Each first attempt deposits a fraction; the cap stops quiet periods banking a burst.
        self.tokens = min(self.max_tokens, self.tokens + self.ratio)
    def try_spend(self):
        if self.tokens >= 1.0:
            self.tokens -= 1.0
            return True
        return False  # budget empty: caller returns the original error

def call_with_retries(send, budget, deadline, headers,
                      per_attempt=0.5, base=0.05, min_useful=0.05, max_attempts=3):
    # deadline: absolute time.monotonic() value derived from the caller's propagated deadline.
    # send(headers, timeout) raises TimeoutError/ConnectionError on retryable failures.
    budget.on_request()
    can_retry = headers.get("x-retried") != "1"  # someone upstream already retried: fail fast
    for attempt in range(max_attempts):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("deadline exceeded")
        try:
            # Attempt timeout never outlives the overall deadline.
            return send(headers, timeout=min(per_attempt, remaining))
        except (TimeoutError, ConnectionError):
            if not can_retry or attempt == max_attempts - 1:
                raise
            backoff = random.uniform(0, base * 2 ** attempt)  # full jitter
            # Stop if the backoff plus a useful attempt no longer fits the deadline.
            if deadline - time.monotonic() < backoff + min_useful:
                raise
            if not budget.try_spend():
                raise
            time.sleep(backoff)
            headers = {**headers, "x-retried": "1"}  # tell downstream not to retry again

A per-destination token-bucket retry budget combined with a deadline-aware, jittered retry loop that clamps each attempt timeout to the remaining deadline and uses an x-retried header so downstream services fail fast instead of retrying again (single-threaded sketch; add locking for concurrent use).

How circuit breakers stop base load to a failing dependency

Budgets and backoff only shape the extra traffic. A dependency that is already down still receives every first attempt, and that base load can keep it from recovering. A circuit breaker addresses this. In the closed state it passes calls through and records outcomes over a sliding window. When the error rate or slow-call rate crosses a threshold, and the window holds at least a minimum number of requests (so that two failures out of three calls do not trip it), the breaker goes open and fails calls locally without touching the network. After a cooldown it enters half-open, admits a small number of probe requests, and closes only if those probes succeed.

Tuning has two failure modes. A breaker that is too sensitive flaps, or trips the whole service because one bad host behind a load balancer accounts for a fifth of the errors. A breaker that is too lax opens only after the dependency is saturated, which is too late to help it. Per-host breakers isolate a single bad instance, but they need client-side visibility into individual endpoints. Per-service breakers are simpler to run but act on the dependency as a whole. In either case, if many clients open at once and all probe when the same cooldown expires, they reproduce the thundering herd. Add jitter to the cooldown and probe timing for the same reason you jitter backoff.

Control What it caps
Jittered backoff Synchronization of retry waves in time
Retry budget Total added load from retries
Circuit breaker Base load to a failing dependency

Alternatives and complements: server-side defenses and hedging

Client-side discipline only works when you control the clients. Third-party integrations, old mobile app builds and legacy services may ignore Retry-After or use fixed-interval loops. The server therefore needs its own defenses.

Server-side defenses

Load shedding rejects work early, before it consumes threads, connections or database capacity. A cheap rejection costs far less than a request that times out after doing most of its work. Concurrency limits cap in-flight requests per endpoint or tenant, so one misbehaving caller cannot fill every worker. When a server must shed, it should prioritize non-retry traffic. If clients mark attempts with a retry-count header, the server can drop retries first, which keeps first attempts succeeding. Rejections should send an explicit overload signal, such as 503 with Retry-After or 429, so that well-behaved clients back off and stop treating the error as transient.

Hedging and idempotency

A hedged request sends a second copy after a delay near the observed tail latency and keeps whichever response arrives first. This trims p99 latency, but every hedge is added load. Hedging needs its own budget and should be disabled when the dependency is overloaded. Idempotency keys let the server deduplicate repeated non-idempotent calls such as payments. The cost is a key store with a TTL, plus careful handling of requests that are still in flight.

Mechanism Preferable when Insufficient without client discipline because
Load shedding / concurrency limits You don't control the callers Rejected clients still hammer the server
Overload signals Clients honor them They are only hints
Hedging Latency SLOs matter more than load Unbudgeted hedges double traffic
Idempotency keys Writes must be retried safely They make retries safe, not cheap

Observability, testing, and impact on cost and engineering time

Make retries visible

Every retry decision should leave a trace. Emit the attempt number and the retry reason (timeout, 503, connection reset, budget exhausted) as both a metric label and a span attribute. Metrics show how much amplification is happening. Traces show where, which matters when a single request fans out into retries at several hops. Without the attempt number, a retry looks the same as fresh traffic, and an incident review can confuse a storm with an organic spike.

Alert on the retry ratio (retry attempts divided by first attempts) per caller and dependency pair, and on circuit breaker state transitions. A rising ratio is the earliest warning that amplification has started. Breakers that flap between open and half-open usually point to a threshold tuned too tightly or a dependency that is only partly recovering.

Metrics only tell you what happened. Load tests tell you whether your limits hold. Inject failures under realistic load: added latency, partial error rates, and a dependency that is fully down. Then confirm that total downstream attempts stay within the budget you designed for and that the system returns to normal after you remove the fault.

What it costs, and where it saves

The up-front work is real. You need organization-wide conventions for which layer retries, a shared client library or mesh policy to enforce them, storage for idempotency keys on retried writes, and time spent tuning budgets and breaker thresholds against production traffic.

Centralizing pays this back. If retry policy lives in one layer and one library, nobody can add a second retry loop by accident and multiply load without anyone noticing. Tuning happens once instead of separately in every service. During a metastable incident, responders have one set of controls to pull rather than dozens of scattered configurations to hunt through.

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.