Distributed systems · advanced
How to verify retry backoff instead of retry storms
Engineers need a way to prove that a retry policy is actually spreading work and stopping at a bounded budget, not just trusting that "exponential backoff with jitter" is configured. This guide defines a verification procedure: measure inter-attempt gaps, confirm jitter width, confirm an upper bound on attempts, and confirm downstream load is uncorrelated with the original failure.
The symptoms
- •Inter-attempt delay distribution collapses to a single value or to near-zero spread, so retries arrive as a tight burst instead of being spread.
- •Request volume against the downstream service spikes within a few seconds of the first failure, showing that retries are arriving as a correlated burst rather than being spread out.
- •Attempt counters grow past the configured maximum, or a circuit breaker opens only after the budget is exceeded rather than holding it.
- •Downstream latency p99 rises in lockstep with client retry counts, indicating correlation rather than independence between retries.
- •Logs show repeated identical nextDelay values across concurrent retrying clients, which is the fingerprint of missing or disabled jitter.
Likely causes
- •Jitter is implemented as a constant multiplier rather than full or equal jitter, so the spread collapses to one of two values.
- •The retry budget is not enforced per request class, so a failure in one endpoint drains the budget for unrelated endpoints.
- •The base delay and cap are set so that even with jitter, the maximum delay still rounds to the same second on the wire-clock used for measurement.
- •Retries are issued from a single shared scheduler that fans out work at fixed intervals, removing any per-attempt independence.
- •The client resets its attempt counter on a partial response rather than on a true success, so the effective maximum is higher than configured.
First ten minutes
- 01Triage in this exact order so you can separate a real retry storm from a benign burst: confirm scope, then measure the delay distribution, then confirm the budget bound, then check correlation with downstream load.
- 02Step 1, scope: identify the client process or service identifier and the downstream endpoint it is calling, plus the time window of the first failure. You need a fixed window before continuing, otherwise the distribution will look fine just by averaging.
- 03Step 2, measure inter-attempt gaps: extract the timestamps of each retry attempt for one logical request and compute the gap to the previous attempt. Record min, median, p99, and the count of identical gaps.
- 04Step 3, check the attempt budget: for each logical request, record total attempts and whether the stop condition was a success, a hard max, or a circuit-breaker trip. A budget that is exceeded by any single request is an immediate fail.
- 05Step 4, check downstream correlation: bin downstream request arrivals into the same wall-clock seconds as the client retry attempts. A retry storm shows as a single bin containing a large share of attempts.
Evidence to collect
- •Per-request attempt timeline with timestamps in milliseconds, plus the computed nextDelay for each attempt.
- •Distribution summary of inter-attempt gaps: min, median, p95, p99, and the cardinality of distinct gap values.
- •Attempt count per logical request compared against the configured maximum, with a histogram of attempt counts.
- •Downstream request arrival histogram aligned to wall-clock seconds during the failure window.
- •Configuration evidence: base delay, multiplier, cap, jitter mode, and max attempts, captured from the active config rather than from documentation.
Where to look
- •At the client retry boundary: the code path that reissues a request after a retriable failure, including any wrapper or middleware that applies the policy.
- •At the scheduling boundary: the timer, executor, or scheduler that decides when the next attempt fires, since shared schedulers can destroy per-attempt independence.
- •At the configuration boundary: the active config source for base delay, cap, jitter mode, and max attempts, since drift between intent and runtime is common.
- •At the downstream ingress: the receiving service's request log or metrics, which is the only place that sees the true arrival pattern after retries are added.
- •At the budget boundary: the code that decrements or caps a retry budget, often co-located with a circuit breaker or token bucket.
Diagnostic steps
- 01Compute the empirical delay distribution from observed attempts and compare it to the theoretical distribution implied by the configured base, cap, and jitter mode. Equal jitter and full jitter predict different shapes; a single-value distribution predicts no jitter.
- 02Verify the bound by inspecting the maximum observed attempt count per logical request and the stop reason for that maximum. A stop reason of "success" before max is fine; a stop reason of "max" before success is acceptable only if the budget policy allows it.
- 03Test correlation by shifting the downstream arrival series by random offsets and recomputing overlap with retry timestamps. A real spread shows low overlap at every offset; a storm shows a peak at the original alignment.
- 04Reproduce under controlled load using a fault-injecting downstream and a fixed client population. Increase the population and confirm that per-client attempt count and downstream arrival rate stay within the configured budget.
- 05Inspect the scheduler: confirm attempts are scheduled independently per request, not as a fan-out from a single periodic timer. Shared periodic scheduling is a common silent cause of correlated retries.
- 06Compare active config to intended config: capture the resolved values at runtime, not from source files or defaults, since environment overrides and feature flags often diverge.
Common mistakes
- •Reading the retry policy from documentation or source comments and treating it as evidence of behavior; runtime config and feature flags can override it.
- •Measuring average retry rate instead of the distribution of gaps; averages hide a tight burst as long as the burst is short.
- •Trusting wall-clock second bins when the base delay is sub-second, because rounding can collapse a valid jittered distribution into one or two bins.
- •Counting attempts across all endpoints as a single budget, which hides per-endpoint overruns and shared-budget starvation.
- •Stopping verification at "jitter is enabled in code" without checking the jitter mode, because decorrelated jitter, full jitter, and equal jitter have different spread characteristics.
- •Treating a circuit breaker that opens after the budget is exhausted as evidence of backoff; the budget must hold before the breaker opens, not after.
Safe fixes
- •If the gap distribution collapses to one value, confirm the jitter mode is full or equal jitter rather than a constant offset, and verify the random source produces distinct values across concurrent retrying clients.
- •If the attempt count exceeds the configured maximum, locate the reset condition for the counter and ensure it only resets on a definitive success status, not on partial or streamed responses.
- •If retries arrive in a single wall-clock bin, introduce per-attempt independent scheduling and confirm the scheduler does not fan out from a shared periodic timer.
- •If downstream correlation is high, lower the cap or switch to decorrelated jitter so that the next delay depends on the previous observed delay, breaking alignment across clients.
- •If the budget is shared across endpoints, split it per request class and verify each class holds its own bound under fault injection.
Prove the fix
- 01Run a controlled fault-injection test with a fixed client population and a downstream that fails for a defined window. The empirical gap distribution must show at least the cardinality implied by the jitter mode, and no single gap value may exceed a small share of attempts.
- 02Assert that for every logical request, total attempts are less than or equal to the configured maximum and the stop reason matches the policy. No request may exceed max.
- 03Assert that downstream request arrivals during the fault window, when binned to wall-clock seconds, do not contain any single bin holding more than a small share of total retries, with the share threshold derived from the configured cap.
- 04Assert that increasing the client population by a factor leaves per-client attempt counts unchanged and does not push any downstream arrival bin past the share threshold, proving independence under scale.
- 05Capture the resolved runtime configuration alongside the test output so the proof ties behavior to the actual values, not to assumed defaults.
Prevention and next steps
- •Treat the retry policy as a load-shaping contract: define base, cap, jitter mode, and max attempts in one place and resolve them at runtime so tests and code see the same values.
- •Add a regression test that fails if the inter-attempt gap cardinality falls below the expected minimum for the configured jitter mode, since cardinality collapse is the earliest signal of a broken spread.
- •Keep the budget per request class, and alert on per-class attempt-count overruns rather than only on global error rate, so budget leaks are caught before downstream impact.
- •Schedule retries from independent timers per request rather than from a shared periodic loop, so adding clients does not synchronize them.
Safe commands and checks
awk '{prev=$1; if(NR>1) print $1-prev; prev=$1}' <retry_timestamps_ms.txt> | sort -n | uniq -c | sort -rn | head -20
awk '{print int($1/1000)}' <downstream_arrivals_ms.txt> | sort | uniq -c | sort -rn | head -20
awk '{c[$2]++} END {for(k in c) print k, c[k]}' <attempts_per_request.txt> | sort -k2 -nr | head -10
grep -E 'nextDelay|maxAttempts|jitter' <resolved_config_snapshot.txt>
awk '{print $2}' <attempt_log.txt> | sort -n | uniq -c | awk '{print $1, $2}' | sort -k2 -nr | head -20