Code a resilient HTTP client
Open in the interactive app: practice, quiz, mock interview →In one minute
- A resilient client is a stack of small wrappers around one
send(request, timeout)interface: a retry layer, a circuit breaker and the raw transport. Each wrapper adds one behaviour, and callers never know how many layers there are. That is the Decorator pattern. - Retries are a loan against the server's capacity. Bound them three ways: a cap on attempts per request, a retry budget shared by all requests, and the caller's overall deadline. Without the second, a brief outage multiplies the load on a server that is already struggling.
- Space retries with exponential backoff and full jitter, and treat the server's
Retry-Afteras a floor. Jitter spreads clients out in time; backoff gives the server room to recover. - Retry only what is safe to repeat. A timeout does not tell you whether the server did the work, so retrying a bare
POSTcan charge a card twice. Idempotent methods and requests carrying an idempotency key are retried; everything else is sent once. - Inject the clock, the sleeper, the random source and the transport. Then every backoff schedule, deadline cut-off and breaker transition is a plain assertion in a unit test, with no sleeps.
Requirements
Functional:
send(request, timeoutMs)returns a response or raises one of a small set of errors: a transport error (connection failure or per-attempt timeout), an open-circuit error, or a deadline error.- Retry connection failures, per-attempt timeouts and the statuses 429, 502, 503 and 504, up to a maximum number of attempts.
- Delay between attempts by exponential backoff with full jitter, capped, and never shorter than the server's
Retry-After. - Treat
timeoutMsas one deadline for the whole call, retries and sleeps included. Each attempt gets the smaller of a per-try timeout and the time left. - Retry only idempotent requests: methods that HTTP defines as idempotent (GET, HEAD, OPTIONS, PUT, DELETE, TRACE), or any request that carries an idempotency key.
- A circuit breaker with closed, open and half-open states stops calls to a dependency that keeps failing, and lets a single probe through after a cool-down.
- A retry budget limits retries across all calls, not just within one.
Non-functional:
- Deterministic and testable: no real sleeps, no direct clock or random reads.
- Safe under concurrent callers. Locks protect state and are never held across the network call.
- Cheap: O(1) work and a few numbers of state per client.
Out of scope: connection pooling, TLS, request bodies and streaming, hedged requests, and per-host breaker registries. Extensibility returns to the last two.
Entities and relationships
- Client (interface):
send(request, timeoutMs). The only thing callers depend on. - Transport: the innermost
Client. It does the real I/O and honours the timeout it is given. In tests it is a scripted fake. - RetryingClient: a
Clientthat wraps anotherClient. It owns the attempt loop, backoff, deadline arithmetic and the idempotency check. - CircuitBreaker: a
Clientthat wraps anotherClient. It owns the state machine and fails fast while open. - RetryBudget: a small token pool shared by every call through one
RetryingClient. Successes add fractional tokens and retries spend whole ones. - Clock:
now()andsleep(ms). Production uses a monotonic clock and a real sleep. Tests use a fake that advances only when told to. - Random source: a function returning a float in [0, 1). Tests pass a constant.
- Request and Response: value objects. A request has a method, a URL and an optional idempotency key. A response has a status and an optional
Retry-Afterin milliseconds.
Class diagram
classDiagram
class Client {
<<interface>>
+send(request, timeoutMs) Response
}
class Transport {
+send(request, timeoutMs) Response
}
class RetryingClient {
-int maxAttempts
-long baseMs
-long capMs
-long perTryMs
+send(request, timeoutMs) Response
}
class CircuitBreaker {
-State state
-int failures
-long openedAt
-bool probing
+send(request, timeoutMs) Response
}
class RetryBudget {
-double tokens
-double maxTokens
-double tokenRatio
+onSuccess()
+tryWithdraw() bool
}
class Clock {
<<interface>>
+now() long
+sleep(ms)
}
Client <|.. Transport
Client <|.. RetryingClient
Client <|.. CircuitBreaker
RetryingClient o-- Client : wraps
CircuitBreaker o-- Client : wraps
RetryingClient --> RetryBudget
RetryingClient --> Clock
CircuitBreaker --> ClockKey flows
One call through RetryingClient(CircuitBreaker(Transport)), where the first attempt gets a 503:
sequenceDiagram
participant App
participant R as RetryingClient
participant B as CircuitBreaker
participant T as Transport
participant C as Clock
App->>R: send(GET /items, 2000)
R->>R: deadline = now + 2000
R->>B: send(req, min(perTry, left))
B->>T: send(req, timeout)
T-->>B: 503
B->>B: record failure
B-->>R: 503
R->>R: idempotent, attempts left, delay in deadline
R->>R: budget.tryWithdraw
R->>C: sleep(random x min(cap, base x 2^n))
R->>B: send(req, min(perTry, left))
B->>T: send(req, timeout)
T-->>B: 200
B->>B: record success
B-->>R: 200
R->>R: budget.onSuccess
R-->>App: 200The breaker sits inside the retry loop. Each attempt is one observation of the dependency's health, so the breaker sees real failures and not whole calls. An open breaker raises an open-circuit error, and the retry layer passes that error straight up: retrying a fast failure only burns attempts and sleeps.
The breaker is a small state machine:
stateDiagram-v2
[*] --> Closed
Closed --> Open : failures reach threshold
Open --> HalfOpen : cool-down elapsed
HalfOpen --> Closed : probe succeeds
HalfOpen --> Open : probe failsIn Closed the breaker counts consecutive failures and resets the count on any success. At the threshold it moves to Open and records the time. While Open it rejects calls without touching the network. After the cool-down the next call moves it to HalfOpen and goes through as a probe; every other call is rejected until the probe finishes. A successful probe closes the circuit and a failed one reopens it with a fresh cool-down. Martin Fowler's description of the pattern has the same shape: the breaker trips once failures reach a threshold, and further calls return an error without reaching the protected call.
Design patterns used and why
- Decorator.
RetryingClientandCircuitBreakerimplementClientand hold aClient. Order is configuration: wrap the breaker in the retrier to count attempts, or the retrier in the breaker to count whole calls. New concerns such as metrics, auth headers or a deadline clamp are new wrappers, with no change to existing ones. Go'shttp.RoundTripperand Envoy's filter chain use the same idea. - State. The breaker has three behaviours chosen by one field. In a language with cheap objects you could model each state as a class; for three states a small enum and a switch is clearer.
- Strategy. Backoff policy (full jitter, equal jitter, decorrelated jitter) and the retryable-status set are injectable decisions. The practice code hard-codes full jitter, but the seam is obvious.
- Dependency injection for time and randomness. The clock, the sleeper and the random source are constructor arguments. A client that calls
time.sleepinside the loop can only be tested by waiting. - Token bucket, reused. The retry budget is the rate limiter's idea pointed at retries instead of requests: deposits proportional to successes, withdrawals per retry.
Concurrency and edge cases
The client is shared by every thread in a process, so its mutable state needs care. The breaker's counters and the budget's token count are small read-modify-write sequences, like a rate limiter's bucket: guard each with a mutex. The rule that matters is never hold the lock across inner.send. Take the lock to decide whether to admit the call, release it, make the network call, then take it again to record the outcome. Holding it across the call serialises every request behind the slowest one.
The half-open probe needs one extra flag. Without it, a hundred threads waking after the cool-down all see "half-open" and all send traffic to a dependency that may still be down. A probing flag, set under the lock when a thread claims the probe, lets exactly one through.
Edge cases worth naming:
- A timeout is not a failure of the work. The server may have processed the request. This is why the retry decision looks at idempotency and not just at the error.
- Retry-After in the past or huge. Take the larger of the jittered delay and the hint, but if the sleep would cross the deadline, give up now. Waiting 60 seconds to make a call that must finish in 2 helps nobody.
- Deadline arithmetic. Compute the deadline once from the clock at entry. Each attempt's timeout is
min(perTry, deadline - now). A fixed per-attempt timeout alone lets three retries of a 10-second timeout turn a "2-second" call into 30 seconds. - Mixed signals from the breaker. Count only failures that say something about the dependency: transport errors and 5xx. A 404 or 400 is the dependency working correctly, and counting it would let one buggy caller open the circuit for everyone.
- Retries at several layers. If service A retries 3 times against B, and B retries 3 times against C, one user request can become nine calls to C. Decide which layer owns retries.
- Clock that moves backwards. Use a monotonic clock for cool-downs and deadlines. A wall-clock step would otherwise hold the breaker open or shorten a deadline.
In the wild
- AWS, 2021: connection storm in us-east-1. An automated scaling activity on AWS's main network triggered a surge of connections from internal clients. Retries kept the congestion going, and a latent bug in client back-off stopped clients from backing off. Network devices took about 7 hours to recover, and AWS fixed the back-off bug afterwards (AWS post-event summary).
- Google Cloud, 2025: a blank policy row and a retry herd. A policy record with blank fields crashed the Service Control component in every region. Restarting tasks without randomised backoff then overloaded a regional database, so recovery in us-central1 took about 7.5 hours against about 3 elsewhere. Google's remediation list included auditing backoff (Google Cloud incident report).
- GitHub, 2022: retries of impossible webhook jobs. Webhook jobs whose data had already been deleted were retried by the delivery workers, which blocked new events for 31 minutes. GitHub changed the workers to skip retries for jobs whose data no longer exists (GitHub availability report).
Down the stack
Layer 1The contract
send makes between one and maxAttempts calls to the inner client. It returns the first response whose status is not retryable, or the last response or error when attempts, budget or deadline run out. It never returns later than timeoutMs after entry, to within one inner call that honours its own timeout. It never retries a non-idempotent request, and never retries an open-circuit error. The breaker guarantees that while Open it makes zero inner calls, and that in HalfOpen at most one call is in flight.
Layer 2The mechanism
Backoff for attempt n (counting from 1) is random() × min(cap, base × 2^(n-1)). This is "full jitter" from the AWS Architecture Blog's analysis of backoff and jitter: the delay is uniform from zero to the exponential ceiling, which removes the correlated waves that plain exponential backoff keeps producing when many clients fail together. The cap bounds worst-case sleep and prevents integer overflow of the exponent.
The retry budget follows the same accounting as gRPC's client retry design (A6): a per-server token count starts at maxTokens, each failure removes one, each success adds tokenRatio (0.1 in the example config), and retries stop while the count is at or below half of maxTokens. With a ratio of 0.1, retries are limited to about 10% of the successful traffic in the long run. The Google SRE book's chapter on cascading failures recommends a similar server-wide retry budget and gives 60 retries per minute per process as an example. Envoy's circuit-breaking docs likewise prefer retry budgets over static limits. The practice version is a simplified form that withdraws a whole token per retry and refuses when less than one remains.
Deadlines are absolute. A deadline computed once at entry survives retries and sleeps, and in a multi-service call it should be propagated: the SRE book's example has server A, after spending 7 of its 30 seconds, call B with a 23-second deadline. gRPC propagates deadlines for exactly this reason. An HTTP client can pass the remaining time in a header, and the server should drop work whose caller has already given up.
Idempotency is defined by RFC 9110: PUT, DELETE and the safe methods have the same intended effect when repeated. For POST, the client sends a unique key and the server stores the first outcome, as in Stripe's idempotent requests. An in-progress IETF draft for an Idempotency-Key header standardises this.
Layer 3The machine
A timeout is a timer in the networking stack. The client library arms a deadline on a socket (in Go, http.Client.Timeout covers dial, redirects and reading the body), and when it fires the client closes the connection. The server may still be running the handler: the TCP FIN or RST arrives after the work started, and some servers only notice on their next write. That is the physical reason a timeout leaves the outcome unknown.
Connecting costs one round trip for TCP (about 0.2 ms in a data centre, 20-150 ms across regions) plus one or two more for TLS, so a retry on a fresh connection is not free; a retry on a kept-alive connection avoids that. Under the retry loop, a sleep is a parked thread or goroutine. A goroutine parked in time.Sleep costs about 2-8 KB of stack and no CPU; a blocked OS thread costs around 1 MB of reserved stack and a scheduler slot, which is why high retry volume exhausts thread pools in thread-per-request servers. The breaker's state is a few words, and the check on the hot path is one mutex acquire (about 20 ns uncontended) and a comparison.
Why it's built this way
- Networks fail briefly and often, so a failed call is worth repeating. Repeating adds load.
- Added load is most dangerous exactly when the server is already slow, so retries are capped per request and metered against a shared budget that shrinks when success shrinks.
- Synchronised retries make the load arrive in waves, so delays are randomised, and exponential so the total load falls as the outage lasts.
- A dependency that is down gets nothing from more attempts, and each attempt ties up a caller's thread for the full timeout, so the breaker fails fast and probes sparingly.
- A caller only has so long to wait, so one deadline bounds everything, and the retry layer has to know what is safe to repeat, since a timeout hides whether the work happened.
Extensibility
- Hedged requests. Send a second copy after a delay near the p95 latency and take the first response. gRPC specifies this alongside retries; the same budget and idempotency rules apply, because a hedge is a retry sent early.
- Per-host breakers. Keep a map from host to breaker so one bad backend does not open the circuit for healthy ones. Evict idle entries as the rate limiter does.
- Failure-rate breakers. Replace consecutive failures with a sliding window of the last N calls and a failure-rate threshold, as Resilience4j does, plus a slow-call threshold so latency alone can trip it.
- Observability. Wrap the stack in a metrics decorator that counts attempts, retries, budget refusals and breaker transitions. A circuit that opens silently looks like a mystery outage.
- Server pushback. Honour a header that tells clients not to retry, the way gRPC defines pushback, so an overloaded server can shut off retries centrally.
Leadership lens
- Ask in design review who owns retries. Exactly one layer per call path should retry; the AWS Builders' Library shows that five stacked layers of 3 attempts mean up to 243 times the load on the bottom one.
- Make idempotency a platform rule, not a client decision. Mutating endpoints should accept an idempotency key, and the shared client should refuse to retry mutations that lack one.
- Ship one well-tested client library with sane defaults rather than letting every team hand-roll retries. Defaults to review: attempts (3), per-try timeout derived from the dependency's p99.9, and a budget of about 10%.
- Track retry ratio (retries divided by requests) per dependency as a first-class metric, and alert when it climbs. It is often the earliest sign that a dependency is degrading.
- Rehearse the failure modes: inject latency and 503s in staging and confirm that the breaker opens, the budget empties and the system recovers when the fault is removed.
Check yourself
A client retries a failed
POST /paymentsafter a timeout, and the customer is charged twice. What went wrong, and what are two fixes?Show answer
The timeout did not mean the server had not processed the request, and
POSTis not idempotent, so the retry repeated a side effect. Fix it by sending a unique idempotency key that the server uses to replay the first outcome, or by not retrying non-idempotent requests automatically.Why does full jitter use
random() × ceilingand notceiling ± a small random amount?Show answer
A small random offset leaves clients clustered near the same delay, so retries still arrive in waves. Drawing uniformly from zero to the ceiling spreads them across the whole window, which flattens the load on the recovering server.
A service calls a dependency with 3 attempts per call, and the dependency's own client also makes 3 attempts to a database. The database slows down. How much extra load can it see, and how do you reduce it?
Show answer
Up to 9 calls per user request instead of 1, since attempts multiply across layers. Retry at one layer only, and add a retry budget so retries cannot exceed a fraction of successful traffic.
Why must the breaker release its lock before calling the inner client, and what extra state does the half-open phase need?
Show answer
Holding the lock across a network call makes every request wait behind the slowest one. Half-open needs a
probingflag set under the lock so that exactly one caller sends the probe and all others are rejected until it finishes.
Sources and further study
Primary
- RFC 9110, HTTP Semantics: the definitions of safe and idempotent methods and of the
Retry-Afterfield. - Exponential Backoff And Jitter (AWS Architecture Blog): the simulation that compares no-jitter, full, equal and decorrelated jitter.
- gRPC A6: client retries: retry and hedging policy fields, and the token-count throttling rule.
Systems docs
- Timeouts, retries and backoff with jitter (AWS Builders' Library): choosing timeouts from latency percentiles, retrying at one layer, and local token-bucket limits on retries.
- Addressing Cascading Failures (Google SRE book): retry amplification, per-request and server-wide retry budgets, and deadline propagation.
- Circuit breaking (Envoy docs): how a production proxy limits connections, requests and retries.
- Circuit Breaker pattern (Azure Architecture Center): the three states and how the pattern differs from a plain retry.
Books
- Release It! Design and Deploy Production-Ready Software, Michael T. Nygard (2nd edition): chapter 4 "Stability Antipatterns" and chapter 5 "Stability Patterns", where timeouts and circuit breakers are named and motivated.
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 4 (Structural), the Decorator pattern.
Other curricula
- Refactoring.Guru: Decorator: presents Decorator as wrappers that share one interface with the wrapped object, which is how this lesson stacks retry and breaker layers.
Watch
- Decorator Pattern, Design Patterns ep 3 (Christopher Okhravi): a careful treatment of the pattern behind the stacked client layers.
- The Circuit Breaker Pattern | Resilient Microservices (Nick Chapsas): the closed, open and half-open states demonstrated in code.
- Every engineer should know this (retries with jitter) (Software Developer Diaries): why retries without jitter synchronise, and what jitter changes.
Free resources
- Retry pattern (Azure Architecture Center): when to retry, transient fault handling and the cost of aggressive retries.
- Circuit Breaker (Martin Fowler): the short canonical description of the pattern.
- Idempotent requests (Stripe docs): how a payment API lets clients retry a POST safely.
- Deadlines (gRPC guides): deadlines versus timeouts and how they propagate across calls.
- Using load shedding to avoid overload (AWS Builders' Library): the server side of the same problem, rejecting work early so retries do not pile on.
- Resilience4j CircuitBreaker: a widely used implementation with sliding windows and slow-call thresholds.