The most dangerous code in your estate is the retry loop someone wrote to be "resilient." Timeout budgets, retry amplification math, backoff with jitter done correctly, retry budgets, and hedged requests.
~2.5h study~1.5h exercise2 interactive simsFast track ~70 min
Fast track — written retry configs before?
Compresses to ~70 min. Timeout taxonomy collapses; amplification math, jitter correctness, and retry budgets stay mandatory — these are the difference between retries that heal and retries that kill.
§01 takeaway — the budget rule · 10 min
§02 amplification sim, in full · 15 min · MANDATORY
§03 jitter sim, in full · 15 min · MANDATORY
§04 budgets & hedging, in full · 15 min · MANDATORY
Exercise step 2 · 15 min
STEP 01
Timeouts: the taxonomy and the budget rule
SKIM
Fast-track takeawayFour distinct timeouts, all needed: connect (short — 1s; failure to connect is fast information) · request/read (sized to the dependency's p99.5, not to hope) · idle (pool hygiene, < LB idle — Module 06) · total/deadline (caller's whole-operation budget, propagated — Module 07). The budget rule: each hop's total timeout ≤ remaining caller budget ÷ (attempts including retries) — otherwise you retry into a deadline that already expired, doing work nobody will read. Infinite defaults (looking at you, default RestTemplate) are estate-wide bugs.
Setting values: read timeouts come from the dependency's measured p99.5 + margin — not round numbers. A 2 s timeout on a 50 ms-p99 dependency doesn't "add safety"; it means that when things go wrong you hold threads/connections 40× longer than useful, converting a dependency brownout into your own pool exhaustion.
Timeout < caller's patience: serving a beautiful response at t=9 s to a client that gave up at t=3 s is pure waste — and with retries above you, it's waste × attempts. Deadlines (Module 07) are the systematic fix; per-hop timeouts are the floor when deadlines aren't plumbed.
Every await is a timeout site: HTTP, DB (javax.persistence.query.timeout, socket + statement), Redis, RabbitMQ publishes and consumer acks, lock acquisition, CompletableFuture.get. The audit finds the ones with no value set; those are where incident threads pile up.
STEP 02
Retry amplification
MANDATORY
Fast trackThe multiplication below is the single most important number in this module. Run it.
Retries at every layer multiply. If each of 4 layers does 3 attempts, one user tap can become 3⁴ = 81 requests at the bottom — arriving precisely when the bottom layer is least able to serve them:
Amplification · attempts per layer
Client app
sends 1
Gateway
sends 3
BFF
sends 9
Service → DB
receives 27
The rules that keep this survivable:
Retry at one layer — ideally the top (it knows user intent) — pass failures through elsewhere. Meshes/gateways doing silent retries under app retries is how 81 happens without anyone writing 81.
Retry only retryable errors: connect failures, 503, gRPC UNAVAILABLE — yes. 4xx/INVALID_ARGUMENT — never. Timeouts — only if idempotent (the request may have succeeded: Module 16 owns this).
Retries need remaining budget: a retry whose deadline already expired is amplification with zero possible benefit.
STEP 03
Backoff and the jitter that actually matters
MANDATORY
Exponential backoff (base × 2ⁿ, capped) spaces attempts out — but without jitter, every client that failed together retries together: synchronized waves hammering the recovering service at t=1s, 2s, 4s. Compare:
200 clients fail at t=0 · retry arrival times over 8 s
Each dot is one retry attempt arriving. Vertical stripes = synchronized waves = repeated mini-stampedes on a recovering dependency.
Full jitter — sleep = random(0, min(cap, base × 2ⁿ)) — spreads the entire wave flat and is the recommended default (per the canonical AWS analysis: best server-load profile, negligible completion-time cost). Equal jitter (half fixed + half random) is the compromise when you must guarantee minimum spacing. No jitter is a bug, not a style.
STEP 04
Retry budgets & hedging
MANDATORY
Retry budgets beat retry counts. Per-attempt counts look at one request; budgets look at the system: retries may be at most X% of recent request volume (e.g. 20%, token-bucket implemented). Healthy system: occasional retries sail through. Broken dependency: budget exhausts, retries stop fleet-wide, the storm never forms. This is the fleet-level guard that per-call config cannot provide — and what Envoy/Finagle-class systems implement natively.
Hedged requests attack tail latency, not errors (Module 02's amplification): after the p95 mark with no response, send a second attempt to a different replica; take the first answer, cancel the loser. Bounds: hedge only idempotent reads, cap hedges at ~5% of volume, and cancel aggressively — hedging without cancellation is deliberate load doubling.
Retries interact with everything downstream of this week: they're the amplifier that circuit breakers (M13) interrupt, the duplicate-source idempotency (M16) exists for, and the reason rate limiters (M14) see 3× traffic during brownouts. Design them as one system.
Staff expectationYou can answer, for any edge in your estate: which single layer retries, with what budget, on which error codes, with what jitter, against what deadline — and produce the dashboard that shows retry-rate % as a first-class metric. When retry rate is invisible, every brownout is a mystery multiplied by three.
STEP 05
Code: Resilience4j done right
SKIM
Fast-track takeawayOne snippet: Retry with full jitter via IntervalFunction.ofExponentialRandomBackoff, retryOnException filtering to transient classes only, wrapped inside a TimeLimiter that respects remaining deadline. If your configs retry on generic Exception with fixed intervals, that's the ticket to file today.
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction
.ofExponentialRandomBackoff(100, 2.0, 0.6)) // base 100ms, ×2, randomized — jittered
.retryOnException(e ->
e instanceofConnectException ||
(e instanceofWebClientResponseException w
&& w.getStatusCode().value() == 503)) // transient ONLY — never 4xx
.failAfterMaxAttempts(true)
.build();
// order matters: TimeLimiter (total budget) OUTSIDE Retry (attempts) —// so attempts can never outlive the caller's deadlineSupplier<CompletionStage<Entitlement>> call =
TimeLimiter.decorateCompletionStage(timeLimiter, scheduler,
Retry.decorateCompletionStage(retry, scheduler,
() -> client.check(userId, contentId)));
STEP 06
Exercise: cause a storm, then prevent it
MANDATORY
Fast trackStep 2 (~25 min): watching your own retry storm triple the load on a browning-out dependency is permanent education.
1
Audit. Grep one real service for every outbound call; table each with connect/read/total timeout and retry config. Every blank cell is a finding; every fixed-interval retry is a finding.
2
Storm. Chain: client(3 attempts) → gateway(3) → service(3) → slow dependency (toggleable 2 s brownout). Load at 100 RPS, flip the brownout, graph dependency-received RPS: watch it approach 27×-bounded amplification. Then: retries only at top layer + full jitter + 20% retry budget — flip the brownout again and compare graphs. Keep both.
3
Hedge. Two replicas of a read endpoint, one with random 1% slow responses (500 ms). Client hedges at p95. Measure p99 before/after and the extra-load %; verify cancellation actually cancels (count server-side completions).
Self-check
Why is retrying a timed-out POST different from retrying a refused connection?
A refused connection guarantees the request never executed — retry is free. A timeout is ambiguous: the request may have completed after you stopped waiting. Retrying non-idempotent work risks double execution (double charge, double grant) — which is why timeout-retries require idempotency keys (Module 16) or must not happen.
Your dependency recovers from a 30 s outage but immediately falls over again, twice. Classic cause?
Synchronized retry waves: all clients that failed together are backing off on the same schedule, so recovery is greeted by the accumulated herd at the next wave boundary — plus cold caches (Module 01). Fixes: full jitter, retry budgets, and slow-start/warming on the recovering side.
Why do retry budgets protect where max-attempts can't?
Max-attempts bounds one request's behavior; during a brownout, *every* request retries, so the fleet still multiplies load by the attempt count. A budget caps retries as a fraction of fleet volume — the multiplier is bounded at 1+X% no matter how many individual requests fail.
When is hedging harmful even for idempotent reads?
When the tail is caused by shared load rather than per-replica noise: hedging adds traffic to an already-saturated pool, deepening the tail it's fighting. Symptoms: hedge rate climbing with latency. Guard: cap hedge volume, disable hedging above a load-shed threshold (M14), and hedge to *different* replicas only.