Hirestack
Hirestack
Chapter 5

Failure handling: timeouts, retries, idempotency

📖 11 min read · 13 sections · May 2026

Why this matters

Production systems fail in ways textbooks don't cover. Books talk about node crashes; production fails because of slow networks, partial degradation, cascading retries, half-dead processes, GC pauses, and a million small things. Senior engineers separate themselves from juniors here.

The AWS Builder's Library is the gold standard for this material. Read every post.

Timeouts — the most under-set value

Three timeouts every network call needs:

  1. Connection timeout: max time to establish the TCP connection. Should be aggressive — under 1 second. If a downstream is unreachable, you want to know quickly.
  2. Read timeout: max time waiting for response after request is sent. Size to the p99 of downstream's operation.
  3. Total deadline: end-to-end budget for the request including retries.
War story — the missing read timeout
A team's HTTP client library defaulted read timeout to infinity. A downstream service started taking 60+ seconds to respond under load. The calling service's HTTP connections piled up — 5000 goroutines per box waiting on a never-arriving response. Memory ballooned; GC pressure hit; the service started returning 503s for unrelated traffic. Cascading failure that bricked their entire fleet for 40 minutes. Fix: set explicit read timeout = 5 seconds. Took 1 line of code to prevent. This pattern has played out at dozens of companies. The default of "infinite" or "1 hour" in libraries (Java HttpClient, Python requests, Go net/http without explicit Client.Timeout) is one of the biggest production-availability issues in the wild.

Universal rule: every network call needs an explicit timeout. Default of "infinite" is a bug. Make your timeout shorter than the upstream caller's timeout (so you fail fast and they can retry intelligently). Pass the remaining deadline to downstreams via headers (X-Deadline, gRPC deadlines).

Retries with exponential backoff and jitter

The standard formula:

delay = min(max_cap, base × 2^attempt) × random(0.5, 1.0)

Why backoff: if a downstream is overloaded, retrying immediately makes it worse. Give it breathing room.

Why exponential: catches transient failures fast; backs off aggressively for persistent ones.

Why jitter: without it, all clients retry at exactly the same time after an outage. The downstream comes up, gets slammed by synchronised retries, falls over again. Jitter spreads the herd.

Jitter strategies

StrategyFormulaSpread
Full jitterrandom(0, base × 2^attempt)Most spread; on average half the wait
Equal jitterbase×2^attempt/2 + random(0, base×2^attempt/2)"Increasing delays" feel; more predictable
Decorrelated jitterrandom(base, min(cap, prev_delay × 3))Marc Brooker's preference; best in his tests

Read Marc Brooker's "Exponential Backoff and Jitter" for the data.

Retry budgets — more important than retry count

The single most-underappreciated concept in retry design.

Without retry budgets, a downstream outage causes traffic amplification: every failure → 4 requests (1 original + 3 retries) → 4× load on downstream → makes the outage worse. Retry storms are a real production failure mode.

When NOT to retry

Idempotency in depth

An operation is idempotent if performing it N times has the same effect as performing it once. set(x, 5) is idempotent; increment(x) is not. Idempotency is what makes retries safe.

Pattern 1: Idempotency keys (Stripe style)

Client generates a UUID for each logical operation. Server stores results keyed by UUID for a window (typically 24 hours). Duplicate requests return the cached result.

POST /v1/charges
Idempotency-Key: 7f2c1a-9b4d-4e8a-...
{ "amount": 1000, "currency": "INR", "source": "tok_..." }

Server pseudocode:
result = SELECT response FROM idempotency_keys WHERE key = $1 AND expires_at > now();
IF result IS NOT NULL:
  RETURN result;  // cached response

// New request — claim the key atomically
claimed = INSERT INTO idempotency_keys (key, request_hash, status)
          VALUES ($1, $2, 'pending')
          ON CONFLICT (key) DO NOTHING
          RETURNING id;

IF claimed IS NULL:
  // Lost the race — another request is processing this key right now
  // Either wait (poll) or return a "conflict, please retry" error

// We won — process the request
response = process_charge();
UPDATE idempotency_keys SET response = $1, status = 'complete' WHERE key = $2;
RETURN response;

Critical correctness detail: the ON CONFLICT DO NOTHING RETURNING pattern is what makes two concurrent first-time requests safe — only one wins the claim. A naive "SELECT, if not found, INSERT" pattern is broken under concurrency.

Pattern 2: Naturally idempotent operations

Some operations are intrinsically safe to retry:

Design pattern: prefer absolute values over deltas whenever possible. If you must use deltas (counters, accumulators), pair with idempotency keys or use deduplication at the consumer.

Pattern 3: Tombstones / processed-events tables

For consumers of an event stream: maintain a "processed" table. Before processing an event, check if event_id has been seen. After processing, mark it seen.

BEGIN;
  // Inside the same transaction as the side effect
  INSERT INTO processed_events (event_id) VALUES ($1)
  ON CONFLICT (event_id) DO NOTHING
  RETURNING event_id;
  
  // If RETURNING is null, event was already processed — skip
  // Else: do the side effect
  
COMMIT;

Circuit breakers

stateDiagram-v2
    [*] --> Closed
    Closed --> Closed: success
    Closed --> Open: failure rate > threshold
    Open --> Open: requests fail-fast
    Open --> HalfOpen: cooldown elapsed (e.g. 30s)
    HalfOpen --> Closed: trial request succeeds
    HalfOpen --> Open: trial request fails

Stop hammering a failing downstream. Three states with hysteresis (preventing flapping).

CLOSED (normal)
   |
   | failure rate > threshold for N seconds with min M requests
   ↓
OPEN (fail-fast — don't even try downstream)
   |
   | after cooldown_seconds
   ↓
HALF-OPEN (let one probe through)
   |
   | probe succeeds → CLOSED       | probe fails → OPEN (reset cooldown)
   ↓                                 ↓
CLOSED                              OPEN

Typical production parameters:

Where to put the breaker: client-side, per-downstream. Per-service might be too coarse if one endpoint of a service is broken but others are fine. Sidecars like Envoy can do this centrally.

Why circuit breakers matter: without them, a slow downstream causes thread/goroutine accumulation in your service → memory pressure → garbage collection → cascading failure across your whole fleet. The breaker turns "slow downstream" into "fast failure," letting your service stay healthy and serve degraded responses.

Bulkheads

Isolate failure domains. If your service calls downstreams A, B, and C, give each its own thread pool and connection pool.

[Your Service]
   ├── HTTP client pool A (for downstream B): 50 conns, 50 threads
   ├── HTTP client pool C (for downstream D): 50 conns, 50 threads
   └── DB connection pool: 30 conns

When downstream D becomes slow, only its pool saturates. Pool A (calls to B) is unaffected. Your service can still serve traffic that only depends on B.

Real production wins from bulkheads:

Load shedding

When overloaded, drop requests strategically rather than crash. Priority-aware:

PriorityExamplesShed order
1 — Critical infrastructureHealth checks, replication heartbeatsNever
2 — Premium / paying customerPaid API callsLast
3 — Free / normal customerFree-tier API callsMiddle
4 — Background / batch / retriesAsync jobs, retries from upstreamFirst

The principle: a 50% successful service is better than a 0% crashed service.

Implementation: a request priority header from upstream; a load-shedding layer (often at the edge / load balancer) that rejects requests above a configured priority when CPU/memory/queue depth crosses thresholds.

Hedged requests

Send the same request to two backends in parallel; take the first response that arrives. Hedges against tail latency — if one replica is slow, the other isn't.

Cost: 2× backend load. Only worth it for tail-sensitive workloads. Implement smartly: only hedge if first request takes longer than p95 of expected latency, so you don't double load for fast cases.

Used by: BigTable, Spanner, MongoDB drivers, gRPC retry policies.

The full pattern, end to end

1. Client makes request with:
   - Idempotency key (generated client-side)
   - Deadline (max total time)
   - Request ID (for tracing)

2. Service receives request. Checks circuit breaker for downstream A.
   - If OPEN: return cached or fallback response immediately.

3. Try the call to downstream:
   - Connection timeout: 1s
   - Read timeout: 3s
   - Pass deadline header to downstream

4. On failure:
   - Decide if retryable (5xx yes, 4xx no, timeout maybe)
   - Check retry budget: per-request (3 max) AND per-process (under 10% retry rate)
   - Check deadline (do we have time left?)
   - If yes to all: retry with backoff + jitter
   - Else: record failure on circuit breaker, return typed error

5. On success:
   - Cache response by idempotency key
   - Reset breaker failure count
   - Emit metrics

6. Emit metrics throughout:
   - Latency histogram
   - Success/failure count
   - Retry count
   - Circuit breaker state
   - Deadline-budget-remaining at each step
Further reading — failure handling

Interview drills

Q: A downstream service has intermittent 10% error rate. How do you handle it?
Per request: retry with exponential backoff + jitter, max 3 attempts, total deadline 30s. Per process: track retry budget (% of traffic that's retries); if > 10%, stop retrying — the downstream is degraded enough that retries aren't helping. Circuit breaker: if failure rate exceeds 50% over 30s, open the breaker; serve degraded responses (cached, default, or fail-fast error). Bulkhead: limit concurrent calls to this downstream so its slowness doesn't drain all my threads/goroutines for unrelated work. Most importantly: instrument all of this so I can observe what's happening.
Cross-Q: What's a degraded response for a checkout flow?
A: If the shipping-cost service is down, fall back to an estimated cost from cache or a default rate, mark the order "shipping cost estimated," and reconcile in a background job. The checkout completes; the user is happy; the discrepancy is fixed asynchronously. Much better than failing the checkout entirely.
Cross-Q: When should retries make things worse?
A: Any time you're causing retry amplification on an already-overloaded system. Sign: retry rate climbing, error rate climbing, downstream getting hammered with retries from many upstreams. Mitigation: retry budgets that throttle when retry rate exceeds 10%; client-side circuit breakers that stop retries when downstream is consistently failing.
Q: Design a transactional outbox.
Single Postgres table:
CREATE TABLE outbox (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id UUID,
  event_type TEXT,
  payload JSONB,
  partition_key TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  processed_at TIMESTAMPTZ
);
CREATE INDEX ON outbox (partition_key, id) WHERE processed_at IS NULL;
Business writes + outbox insert in same transaction → atomic. Workers poll:
SELECT * FROM outbox
WHERE processed_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
Workers publish to Kafka (key = partition_key for ordering), then mark processed_at = now().
Cross-Q: What if Kafka publish succeeds but UPDATE outbox fails?
A: Worker crashes between publish and mark-processed → next worker iteration re-processes the same row → Kafka publishes again. So Kafka consumers must dedupe by event_id. Include event_id in payload; consumer's "processed_events" table tracks seen ids. This is the at-least-once semantic.
Cross-Q: Why not 2PC between Postgres and Kafka?
A: Kafka has XA support but it's slow and operationally fragile. Outbox + at-least-once + idempotent consumer is simpler, faster, and more reliable.
Cross-Q: How do you preserve per-aggregate ordering across workers?
A: Partition the outbox by aggregate_id (or partition_key). Each partition has exactly one worker consuming it. Within a partition, the worker processes in id order. Across partitions, parallel. This is the same model as Kafka partitions.
Q: Your service calls 5 downstreams. Design timeouts.
My overall SLO is 5 seconds p99. I need to allocate this budget across my 5 calls plus fixed overhead. If I call them serially: each gets 1s. If in parallel: max(t1...t5) ≤ 4s, each gets 4s. In practice, mix: parallelise independent calls; serialise where outputs feed inputs. Critically, no call has an infinite timeout. Propagate the remaining deadline to each downstream via gRPC deadlines or X-Deadline header — they won't waste time on work the caller has given up on.
Cross-Q: What's the relationship between your timeout and your downstream's timeout?
A: Your timeout should be slightly less than what you'd otherwise wait — leave room for retries. Your downstream's timeout should be less than yours so they fail fast and you can react. Cascading deadlines: each layer subtracts a small fixed amount for its own overhead and passes the rest down.
Q: What's the difference between fail-open and fail-closed? When do you use each?
When a control mechanism can't perform its check, fail-open lets the request through; fail-closed blocks it. Example: rate limiter is unreachable. Fail-open: serve all traffic (risk: actual abuse goes through). Fail-closed: reject all traffic (risk: legitimate traffic blocked, possibly your whole service down). For rate limiters: usually fail-open (better to serve legit traffic and risk some abuse). For authentication: always fail-closed (never serve unauthenticated requests). For fraud checks on a $10K transaction: fail-closed with a fallback to manual review.
Cross-Q: How would you design a fail-open rate limiter that's still robust?
A: Local L1 cache of the rate-limit decision: even if central rate limiter is unreachable, last-known-good values cached locally allow rough enforcement. Stale-while-error pattern. Plus circuit breaker on the rate-limit service itself — when broken, fail-open globally and alert.

Red flags