Chapter 21 — Resilience between services: rate limits, retries, idempotency, and circuit breakers
In one sentence
Retries turn brief failures into successes, but only when operations are idempotent, retries are budgeted and jittered, overloaded servers can say "slow down", and clients stop calling a dependency that is clearly down.
Why this matters
In a system of services, every call can fail transiently. Without retries, users see those failures. With naive retries, a slow dependency receives 3×, 9×, or 27× its normal traffic exactly when it's least able to cope, and a retried "create order" creates two orders. Most cascading failures are retry policies working exactly as written.
How it actually works
| Mechanism | Side | What it prevents |
|---|---|---|
Rate limiting (token bucket) + 429 Too Many Requests + Retry-After |
Server | One client consuming capacity meant for all |
| Idempotency keys | Server | Duplicate side effects when a request is retried |
| Exponential backoff with jitter | Client | Synchronised retry waves |
| Retry budget | Client | Retries multiplying load during an outage |
| Circuit breaker | Client | Waiting on, and adding load to, a dependency that is down |
Deadlines propagated with context |
Both | Work continuing after the caller has given up |
Token bucket: a bucket holds up to burst tokens and refills at rate tokens per second. Each request takes one, and an empty bucket means 429. It allows short bursts while enforcing an average.
Idempotency key: the client generates a unique key per operation and sends the same key on every retry. The server records the key in the same transaction as the side effect. A retry finds the existing result instead of creating a new one.
Full jitter: sleep a random time between 0 and base × 2^attempt. Without
randomness, a thousand clients that failed together retry together, and
fail together again.
Circuit breaker: CLOSED (normal) → after N failures, OPEN (fail immediately, no calls) → after a cool-down, HALF-OPEN (let a trial request through) → CLOSED if it succeeds.
Build it in Go (60 min) — all four, measured
resilience runs a flaky "create order" server and a well-behaved client
against it. 30% of first attempts fail after the order has been committed,
the hardest case for retries, because the client can't tell the order exists:
// resilience: the four mechanisms that keep retries from becoming outages.
//
// server: per-client token-bucket rate limiting (429 + Retry-After)
// Idempotency-Key handling (a retried POST never creates two orders)
// client: retries with exponential backoff + full jitter, a retry budget,
// and a circuit breaker
//
// go run ./resilience
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
mrand "math/rand/v2"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"sync/atomic"
"time"
)
// ------------------------------------------------------------ server side --
// bucket is a token bucket: capacity `burst`, refilled at `rate` per second.
type bucket struct {
tokens float64
last time.Time
}
type limiter struct {
mu sync.Mutex
rate float64
burst float64
buckets map[string]*bucket
}
// allow reports whether the client may proceed, and if not, how long to wait.
func (l *limiter) allow(key string) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b := l.buckets[key]
if b == nil {
b = &bucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens = math.Min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
b.last = now
if b.tokens >= 1 {
b.tokens--
return true, 0
}
return false, time.Duration((1 - b.tokens) / l.rate * float64(time.Second))
}
func rateLimit(l *limiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key") // identity, not IP: NAT puts many users behind one IP
if ok, wait := l.allow(key); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
http.Error(w, "slow down", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// orders is the "database". The idempotency key is recorded IN THE SAME
// TRANSACTION as the side effect, so a retry -- even one whose first attempt
// committed and then lost its response -- finds the existing order instead
// of creating a second one. (Caching HTTP responses is NOT enough: a request
// that committed and then failed has no successful response to cache.)
type orders struct {
mu sync.Mutex
byKey map[string]int
next int
}
// create returns the order for this key, creating it only the first time.
func (o *orders) create(key string) (id int, replay bool) {
o.mu.Lock() // production: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING
defer o.mu.Unlock()
if id, ok := o.byKey[key]; ok {
return id, true
}
o.next++
o.byKey[key] = o.next
return o.next, false
}
// ------------------------------------------------------------ client side --
type breaker struct {
mu sync.Mutex
failures int
openTill time.Time
}
var errOpen = errors.New("circuit open: failing fast without calling the server")
func (b *breaker) before() error {
b.mu.Lock()
defer b.mu.Unlock()
if time.Now().Before(b.openTill) {
return errOpen // OPEN: don't even try
}
return nil // CLOSED, or HALF-OPEN after the cool-down (one trial gets through)
}
func (b *breaker) after(ok bool) {
b.mu.Lock()
defer b.mu.Unlock()
if ok {
b.failures = 0
return
}
b.failures++
if b.failures >= 5 {
b.openTill = time.Now().Add(2 * time.Second)
b.failures = 0
}
}
type client struct {
http *http.Client
brk breaker
requests atomic.Int64
retries atomic.Int64
}
// budgetOK caps retries at 20% of first attempts: under a real outage,
// retries can't multiply the load by more than 1.2x.
func (c *client) budgetOK() bool {
return float64(c.retries.Load()) < 0.2*float64(c.requests.Load())+5
}
func (c *client) post(url, key string) (string, error) {
c.requests.Add(1)
for attempt := 0; ; attempt++ {
if err := c.brk.before(); err != nil {
return "", err
}
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Idempotency-Key", key) // SAME key on every retry: that's the point
req.Header.Set("X-API-Key", "team-a")
resp, err := c.http.Do(req)
var body []byte
status := 0
if err == nil {
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
status = resp.StatusCode
}
c.brk.after(err == nil && status < 500)
if err == nil && status < 300 {
return string(body), nil
}
retryable := err != nil || status == 429 || status >= 500
if !retryable || attempt == 3 || !c.budgetOK() {
return "", fmt.Errorf("giving up after %d attempts (last status %d, err %v)", attempt+1, status, err)
}
c.retries.Add(1)
// Exponential backoff with FULL jitter: random in [0, base*2^attempt].
// Jitter spreads retries out so clients don't retry in lockstep.
backoff := time.Duration(mrand.Int64N(int64(50*time.Millisecond) << attempt))
if status == 429 && resp.Header.Get("Retry-After") != "" {
s, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
backoff = max(backoff, time.Duration(s)*time.Second)
}
time.Sleep(backoff)
}
}
func newKey() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// ------------------------------------------------------------------- demo --
func main() {
db := &orders{byKey: map[string]int{}}
var down atomic.Bool
flaky := func(w http.ResponseWriter, r *http.Request) {
if down.Load() {
http.Error(w, "down", http.StatusServiceUnavailable)
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
return
}
id, replay := db.create(key) // the side effect, deduplicated by key
if !replay && mrand.Float64() < 0.3 {
// The order WAS created, but the response is lost -- the worst case for retries.
http.Error(w, "oops after commit", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "order %d", id)
}
lim := &limiter{rate: 50, burst: 20, buckets: map[string]*bucket{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", flaky)
srv := httptest.NewServer(rateLimit(lim, mux))
defer srv.Close()
c := &client{http: &http.Client{Timeout: 2 * time.Second}}
ok, failed := 0, 0
for i := 0; i < 40; i++ {
if _, err := c.post(srv.URL+"/orders", newKey()); err != nil {
failed++
} else {
ok++
}
}
db.mu.Lock()
n := db.next
db.mu.Unlock()
fmt.Printf("phase 1, flaky server (30%% of first attempts fail AFTER committing):\n")
fmt.Printf(" 40 distinct orders sent, %d confirmed, %d gave up, %d retries\n", ok, failed, c.retries.Load())
fmt.Printf(" orders in the database: %d <- never more than 40: no duplicates\n", n)
// Same idempotency key twice: the server replays the first answer.
key := newKey()
for {
if a, err := c.post(srv.URL+"/orders", key); err == nil {
b, _ := c.post(srv.URL+"/orders", key)
fmt.Printf("\nsame key sent twice: %q then %q -> one order, two identical answers\n", a, b)
break
}
}
down.Store(true)
start := time.Now()
fast := 0
for i := 0; i < 20; i++ {
if _, err := c.post(srv.URL+"/orders", newKey()); errors.Is(err, errOpen) {
fast++
}
}
fmt.Printf("\nphase 2, server down: %d of 20 calls failed fast with the breaker open (%v total)\n",
fast, time.Since(start).Round(time.Millisecond))
}
$ go run ./resilience
phase 1, flaky server (30% of first attempts fail AFTER committing):
40 distinct orders sent, 38 confirmed, 2 gave up, 12 retries
orders in the database: 40 <- never more than 40: no duplicates
same key sent twice: "order 41" then "order 41" -> one order, two identical answers
phase 2, server down: 17 of 20 calls failed fast with the breaker open (1.141s total)
What to notice:
- No duplicates. 12 retries, and the database still has exactly one order per key. The "2 gave up" orders exist in the database even though the client saw an error. That's why the client must also be able to look an operation up by its key later.
- An earlier version of this lab was wrong in an instructive way. It
implemented idempotency as middleware that cached successful responses.
Because 30% of first attempts committed the order and then failed, there
was no successful response to cache, and the retry created a second order:
40 distinct keys produced 52 orders. The key must be recorded with the side
effect itself (in SQL, a unique constraint on
idempotency_keywithINSERT ... ON CONFLICT DO NOTHING), not in a response cache. - The breaker turned 20 doomed calls into 3 real attempts and 17 instant failures. The server got a rest, and callers got an immediate answer instead of waiting for timeouts.
- Rate limits are keyed by API key, not IP (NAT puts many users behind one
address; see the OSI guide, Layer 3). The response carries
Retry-After, and the client honours it.
Exercises:
- Lower the limiter to
rate: 5, burst: 5, rerun, and count the 429s. Does the client's backoff respectRetry-After? - Remove the jitter (sleep exactly
50ms << attempt), run 50 clients concurrently against a server that fails for 2 seconds, and plot when requests arrive. You'll see the synchronised waves. - Add deadline propagation: give each call a
context.WithTimeoutand have the server stop work whenr.Context()is cancelled. - Replace the in-memory
ordersmap with SQLite or Postgres and a unique index on the key, then run two clients that use the same key concurrently.
Real-world scenario
A payment provider has a 2-minute brownout. The checkout service retries 3 times, the API gateway in front of it retries 3 times, and the mobile app retries 3 times: up to 27 attempts per user action, all landing on the provider as it recovers, which knocks it over again. Two orders get charged twice because the retry carried no idempotency key. The fix: retry at one layer only, with a budget and jitter; idempotency keys on every payment call; and a breaker that sheds load while the provider recovers.
Common mistakes
- Retrying at every layer.
- Retrying non-idempotent operations without an idempotency key.
- Implementing idempotency as a response cache instead of in the transaction.
- Fixed backoff with no jitter.
- Rate limiting only by IP.
- Circuit breakers with no half-open state, which never recover on their own.
Check yourself
- Why must the idempotency key be stored with the side effect?
- What problem does jitter solve that exponential backoff alone doesn't?
- What is a retry budget, and what does it guarantee during an outage?
- Describe the three circuit-breaker states and the transitions between them.
Key Takeaways
- Retry only idempotent operations (or ones with idempotency keys), at one layer, with jittered backoff and a budget.
- Servers should say "slow down" explicitly (429 +
Retry-After), keyed by identity. - Breakers protect both the caller's latency and the dependency's recovery.
Across the series: Chapter 9 (rate limiting concepts); TCP/IP guide, Chapter 57 (retry traps at the load balancer); Go plan Day 76 (token-bucket middleware), Day 92 (five rate-limiting algorithms), and Day 110 (chaos middleware and circuit breaker); the OSI guide's idempotency discussion.