Why a 2-Second Client Timeout Quietly Becomes a 30-Second Outage
Your checkout service calls the pricing service. Pricing's database starts stalling: every query takes 8 seconds instead of 40 milliseconds. Your checkout code uses a default HTTP client with no timeout, so every request waits patiently. Within a minute, 200 worker threads are parked inside pricing calls, the thread pool is full, and /checkout returns nothing to anyone — not even errors. A pricing slowdown has become a total checkout outage, and nothing in the logs explains it, because no call ever failed. They just never finished.
The fix is a number on every wait. A timeout is a statement about how long you are willing to wait, and every hop in the call chain needs one. Threads and sockets are finite resources held for the entire duration of a call; an unbounded wait is an unbounded leak of both. Worse, the OS-level give-up is slow — Linux TCP retransmission can hold a dead socket for over 15 minutes before erroring — so "the network will eventually fail it" is not a timeout, it is a slow-motion pileup.
Think of it as an inventory of every wait your request performs:
| Wait | Where it is configured | Reasonable starting point |
|---|---|---|
| TCP connect | dialer / transport | 200–500 ms |
| TLS handshake | transport | 1 s |
| Waiting for response headers | HTTP client, per attempt | 1–2 s |
| Borrowing a connection from the pool | pool config | 100–300 ms; a full pool is a symptom, not a wait |
| Database statement | query timeout | 500 ms–2 s |
| Whole call including every retry | request deadline | derived from the caller's SLO |
Two stacking rules follow from that inventory, and both are interview favorites.
First, the server-side timeout should be slightly shorter than the client's per-attempt timeout. If the dependency can decide it is stuck, it should answer 503 with a reason while the client is still listening. When the client gives up first, the server keeps executing the work to completion anyway and the caller learns nothing — you pay full price for a request that returns no diagnosis.
Second, the caller's total deadline must cover everything it might actually do: attempts × per-attempt-timeout + sum(backoff sleeps). Three attempts at 2 seconds with exponential backoff is a 7–10 second operation wearing a 2-second label. If your own caller — a browser at 30 seconds, an API gateway at 10 seconds — has a shorter deadline than your retry plan, your late successes are computed and then discarded. You pay for the work twice and observe it zero times.
A working per-attempt client in Go shows how many separate ceilings one "timeout" hides:
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: 300 * time.Millisecond}).DialContext,
TLSHandshakeTimeout: 1 * time.Second,
ResponseHeaderTimeout: 1500 * time.Millisecond,
IdleConnTimeout: 90 * time.Second, // stale keep-alive sockets after an LB restart
MaxIdleConnsPerHost: 32,
}
client := &http.Client{Transport: transport, Timeout: 2 * time.Second} // total, incl. body
The forgotten one is usually IdleConnTimeout: a load balancer restarts, a keep-alive socket is dead on the peer, and the first request to reuse it hangs until some outer layer gives up on it. A cache miss that has to fetch from a slow backend is the same shape of problem you met in Cache Invalidation Trade-offs — the fetch needs its own deadline too.
Interviewers ask this as "what happens when a dependency is slow rather than down?" Answer with thread-pool exhaustion and cascading failure, never with "we retry" — retries are chapter two's problem, and without a timeout they have nothing to retry on.
The Retry Storm: Why Identical Backoff Turns a 30-Second Blip Into a Fleet-Wide Outage
A dependency blips for 30 seconds. You have 5,000 instances with 50 in-flight calls each, all configured the same way: on failure, wait a fixed 500 ms and retry once. At t+500 ms, roughly 250,000 requests arrive at the struggling service in the same instant. Its recovery queue, which was draining nicely, is now deeper than the blip; those retries fail too; another synchronized wave follows. The 30-second fault became a multi-minute outage, entirely because of your retry policy.
Fixed delay is the classic mistake: retries must use exponential backoff with jitter, or the fleet retries in lockstep. Backoff grows the wait (base × 2^attempt, capped) so later attempts give the dependency more room. Jitter randomizes when each caller lands inside that window, which is what actually breaks the synchronization — every instance in your fleet shares the same fault, so without jitter they share the same retry instant too.
| Strategy | Delay formula | What it does under a fleet-wide fault |
|---|---|---|
| Fixed | base |
perfect synchronization — the herd |
| Exponential, no jitter | min(cap, base·2^n) |
spreads attempts only if failures are uncorrelated; they are not |
| Equal jitter | temp/2 + random(0, temp/2), temp = min(cap, base·2^n) |
partial spread, keeps a guaranteed minimum delay |
| Full jitter | random(0, min(cap, base·2^n)) |
maximum spread; the recommended default |
With base=100 ms and cap=20 s, full jitter gives attempt 3 a draw from [0, 800 ms] and attempt 5 a draw from [0, 3.2 s] — no two callers agree, yet the average load falls exactly as the backoff curve intends. The implementation is four lines:
import random, time
def backoff(attempt, base=0.1, cap=20.0):
# full jitter: sleep somewhere in [0, min(cap, base * 2**attempt)]
return random.uniform(0, min(cap, base * (2 ** attempt)))
Backoff alone still does not bound your load, and this is the part most candidates miss. If 1 % of traffic fails and every failed request makes three more attempts, the dependency sees up to 3 % extra traffic at the worst possible moment; if the failure rate is 50 %, it sees a multiple of its normal load while it is already sick. The bound you want is a retry budget: cap retries as a fraction of successful traffic — for example, allow retries totaling at most 10 % of the original request rate in any window, enforced by a token bucket shared per dependency. When the bucket is empty, fail fast and surface the error. Envoy implements exactly this with retry_budget { budget_ratio: 0.2; min_retry_concurrency: 3 } on a cluster; Resilience4j composes Retry with a RateLimiter for the same effect.
Two rules complete the policy. Honor Retry-After: if the server says 30 seconds and your deadline is 5, do not sleep — fail now, because the sleep is guaranteed to be wasted. And check the clock before sleeping: never start an attempt that cannot finish inside the caller's remaining deadline.
Measure what you ship: emit retry attempts as a metric broken out by dependency and outcome, and alert when the retry rate crosses a threshold rather than waiting for the success rate to fall. A rising retry ratio with a still-healthy success rate is the earliest warning that a dependency is degrading and your budget is about to be spent. Track retry success rate too: attempts that keep succeeding on the first retry but never on the third are telling you the backoff is too aggressive for that failure mode.
"How do you prevent a retry storm?" is a standard interview question, and a complete answer names both halves: exponential backoff with jitter spreads the attempts, and a retry budget bounds them. Neither alone is sufficient.
Retry Only What Won't Charge Twice: Idempotency Keys and the Safe-Error Map
You send POST /v1/charges for $49.99. The socket dies while you are waiting for the response. Did the charge happen? You cannot tell — and if you retry and the server had in fact committed, the customer pays twice. Retries are only free when the operation is idempotent: repeating it produces the same end state. HTTP builds this into the verbs — GET, HEAD, PUT, DELETE, OPTIONS are idempotent by definition, POST is not — but transport semantics are not enough on their own. What you actually need is a map of where an attempt failed and what that implies.
| Failure point | Did the request reach the server? | Safe to retry? |
|---|---|---|
| DNS, connect, or TLS failure | No | Always |
| Connection reset while writing the request | Unknown | Only if idempotent or a dedup key is attached |
| Timeout waiting for the response | Unknown — likely still executing | Only if idempotent or a dedup key is attached |
400 / 401 / 403 / 404 / 422 |
Yes, and it was rejected | Never — the result is deterministic |
429 or 503 with Retry-After |
Yes, throttled or overloaded | Yes, after the advertised delay |
502 / 504 returned by a gateway |
Ambiguous — upstream may have committed | Only if idempotent or a dedup key is attached |
500 from your own handler |
Ambiguous — the write may have committed before response serialization failed | Only if idempotent or a dedup key is attached |
The last three rows are where incident reports get written. A 500 is not automatically "safe to retry": handlers often commit the transaction and then blow up rendering the response.
For non-idempotent operations, the standard fix is an idempotency key, and it is worth stating precisely because interviewers probe it:
POST /v1/charges HTTP/1.1
Idempotency-Key: 2f8f6f1e-8a37-4b64-9d0e-0d1c8f5b1a44
Content-Type: application/json
{"amount": 4999, "currency": "USD", "order_id": "A-10241"}
The client generates one UUID per logical operation, not per attempt, and sends it on every attempt. The server records key → stored response atomically — a unique constraint so concurrent duplicates collide instead of double-charging — and replays the stored status and body for every subsequent arrival inside the TTL, which must exceed your longest retry window (24 hours is the usual contract). The record must live in the same durability class as the transaction itself: a Redis "cache" of keys is not good enough when real money is involved. The first attempt returns 201 Created; the replay returns the identical stored 201, not a second charge.
When the downstream API supports neither idempotent verbs nor keys, stop retrying automatically. Return an explicit ambiguous state — "payment submitted, status unknown" — and reconcile asynchronously by querying status with a client-side identifier. Reconciliation beats blind retrying: it resolves uncertainty instead of doubling side effects. Server-side, you can often make non-idempotent endpoints safe anyway: set-style operations (PUT /carts/7/items/9 with {"qty": 2} rather than "add one"), conditional writes on a version column, and upserts all converge under repetition.
Retries also inherit the deadline rules from the first chapter: an attempt that would start at 1.9 s under a 2 s deadline should never start. And they inherit a load ceiling from chapter two — which is why the natural next step is Circuit Breaker Pattern: when the dependency is genuinely down, every retry is guaranteed wasted work, and the breaker decides how many of them you are allowed to burn before failing fast.
Quiz
1. A handler allows 3 attempts with a 2-second per-attempt timeout and backoff sleeps of 0.5 s and 1 s. Its own caller abandons the request after 3 seconds. What actually happens when the dependency is slow?
2. 5,000 identical instances hit a dependency that fails for 30 seconds. Which retry policy minimizes the synchronized surge when the dependency recovers?
3. Your service issues 10,000 requests/second, 40% of calls fail, and each failure is retried 3 times. What does a retry budget of 10% enforce?
4. A read timeout fires after your POST /payments request was fully written to the socket. Which action is correct?
Flashcards
Question
Why should the server-side timeout be slightly shorter than the client per-attempt timeout?
Click to reveal answer
Answer
So the dependency answers 503 with a reason while the client is still listening. When the client gives up first the server still executes the request to completion, so you pay full cost and get no diagnosis in return.
Question
What is full jitter, and what failure does it prevent?
Click to reveal answer
Answer
sleep = random(0, min(cap, base * 2^attempt)). It prevents synchronized retry storms: every instance sees the same fault, so without randomization they retry at the same instant and turn a brief blip into a queue that outlives the fault.
Question
What is a retry budget, and why is backoff alone insufficient?
Click to reveal answer
Answer
A budget caps total retries as a fraction of successful traffic (commonly 10-20%), enforced by a token bucket per dependency; when empty, requests fail fast. Backoff spreads retries over time but never bounds their number, so a high failure rate can still multiply load on an already sick dependency.
Question
Which HTTP methods are idempotent, and what does that license a client to do?
Click to reveal answer
Answer
GET, HEAD, PUT, DELETE, OPTIONS. Idempotency means repetition converges on the same end state, so an ambiguous outcome (timeout, connection reset, 502/504) may be retried safely. POST is not idempotent and needs an idempotency key or reconciliation before any retry.
Question
Name the three failure classes a retry policy must separate.
Click to reveal answer
Answer
Always safe (DNS/connect/TLS failure - the request never arrived); never retryable (4xx like 400/401/404/422 - deterministic rejections); ambiguous (timeouts, resets, 500/502/504 - the server may have committed), which requires idempotency or a dedup key before repeating.
Revision Notes
Key Takeaways
- 1. Every wait - connect, TLS, pool borrow, headers, query, total deadline - needs its own ceiling; a call without one holds a thread and socket until the OS gives up, which can be minutes
- 2. Stack timeouts so the server answers before the client abandons, and make the total deadline cover attempts x per-attempt timeout plus backoff, or you pay for work you never observe
- 3. Exponential backoff spreads load over time, jitter desynchronizes the fleet, and only the combination prevents a thundering herd
- 4. Backoff does not bound volume: a retry budget (retries at 10-20% of request rate, token-bucketed per dependency) caps amplification and fails fast when exhausted
- 5. Retry only idempotent operations or ones carrying an idempotency key; 4xx never, transport failures always, 5xx after a possible commit only with dedup
Interview Tips
- • "What happens when a dependency is slow rather than down?" - lead with thread-pool exhaustion and cascading failure, then the stacked-timeout budget; answering only with retries loses the point
- • "How do you prevent retry storms?" - name both halves (full-jitter exponential backoff to desynchronize, retry budget to bound), plus honoring Retry-After and never sleeping past the deadline
- • "A payment request timed out - do you retry it?" - say the outcome is unknowable, walk the safe-error map, then design the idempotency key: client UUID per logical operation, atomic unique record in the same durability class, stored response replayed within a TTL longer than the retry window
- • "Give me concrete numbers" - have them ready: 2 s per attempt, 100 ms base / 20 s cap backoff, 3-5 attempts, 10% retry budget - and say which measurement would change each one
Cheat Sheet
Timeouts and Retries — Cheat Sheet
Stacked timeout budget (set every one of these):
| Layer | Where | Starting point |
|---|---|---|
| TCP connect | dialer | 200–500 ms |
| TLS handshake | transport | 1 s |
| Pool borrow | pool config | 100–300 ms (a full pool is a symptom) |
| Response headers | client, per attempt | 1–2 s |
| Server handler | server-side | slightly shorter than client per-attempt |
| DB statement | query timeout | 500 ms–2 s |
| Whole call incl. retries | request deadline | from the caller's SLO |
total_deadline ≥ attempts × per_attempt + Σ backoff sleeps. Never start an attempt that cannot finish inside it.
Backoff: full jitter — sleep = random(0, min(cap, base × 2^attempt)); base 100 ms, cap 20 s, 3–5 attempts. Fixed delay = fleet-wide thundering herd.
Retry budget: retries ≤ 10–20 % of request rate, enforced per dependency (Envoy retry_budget { budget_ratio: 0.2 }, Resilience4j Retry + RateLimiter). Empty budget → fail fast. Honor Retry-After; never sleep past the deadline.
Safe-error map:
| Outcome | Retry? |
|---|---|
| DNS / connect / TLS failure | always |
| Timeout, reset, 500, 502, 504 | only if idempotent or dedup key attached |
429 / 503 with Retry-After |
yes, after the advertised delay |
Other 4xx |
never — deterministic |
Idempotency key: one client UUID per logical operation → atomic key → stored response with a unique constraint, stored in the same durability class as the transaction → replay the identical response inside a TTL ≥ the longest retry window (24 h). Ambiguous outcome without key support → reconcile asynchronously, never blind-retry.