Chapter 17: Rate Limiting and Flow Control

You have written four limiters already and the book never called them that.

Chapter 2 built a semaphore out of a channel to cap how many goroutines ran at once. Chapter 5 used a buffered channel as a counting semaphore, then spent a section on why a bigger buffer is usually a worse answer. Chapter 7 sized a worker pool’s queue. Chapter 14 replaced all of it with one line, g.SetLimit(10), and measured what the goroutines you no longer create had been costing you.

So this chapter does not introduce limiting. It introduces the question those four all dodged: when work arrives faster than you can do it, who waits, where do they wait, and what does waiting cost them there?

That question has exactly four answers — wait, reject, drop, degrade — and every mechanism in this chapter is one of them wearing a different name. A token bucket is a queue with a clock. A semaphore is a queue with a counter. A buffered channel is a queue whose size you can read. A circuit breaker is a queue that refuses to form. They are one chapter because they are one decision.

The reflex, when a service is struggling, is to reach for a limiter. That reflex is right about half the time, and the other half it makes things worse in a way that is invisible on every dashboard you own.

Here are three limiters. All three compile, go vet is happy with all three, and go test -race reports nothing on any of them.

handler_17_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: perfect rate compliance, unbounded memory
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    h.limiter.Wait(r.Context()) // every arrival parks here
    h.forward(r)                // arrivals are set by the internet
}
r_17_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: throttles you against requests you never made
r := lim.Reserve()
if !shouldStillSend() {
    return nil // the tokens are gone anyway
}
time.Sleep(r.Delay())
send()
err_17_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: converts your callers' impatience into your outage
err := downstream.Call(ctx)
breaker.Record(err) // ctx.Err() is an error too
return err

The first is the most common shape in production Go. It obeys the configured rate exactly, and the goroutines waiting their turn grow without limit, because nothing bounds arrivals (§17.3.2). The second gives away rate budget for work that was never done — measured, ten abandoned reservations put the limiter ten tokens in debt and made the next caller wait eleven times longer than one who had arrived alone (§17.3.4). The third is the chapter’s centrepiece.

Measured two hundred impatient callers opened the breaker and the next two hundred perfectly ordinary requests were all refused, against a downstream that never returned a single error (§17.6.5).

None of the three fails loudly. That is the shape §14.1 named and §15.4 met again at the process boundary, arriving here a third time — and this time the toolchain has nothing to say at all. Chapter 15's cold open had three bugs of which go vet caught one. There is no analyzer for “correct rate, wrong outcome”, which is why this chapter leans harder on measurement than any before it.

EVERY LIMITER IS A QUEUE

Three columns comparing what happens to a request under no limiter, a limiter, and a circuit breaker. With no limiter the caller reaches the server’s queue, waits until its deadline, and the work is started and then abandoned, so capacity is spent on nobody. With a limiter the caller waits at the limiter and then reaches the server, so the work that is done is work someone is still waiting for. With a breaker the caller is refused immediately. The caption notes that the work does not go away; you choose only where it waits and what waiting costs in that place.

What you’ll learn
  • Why rate and concurrency are different units, and what Little’s Law decides for you once you have picked two of the three numbers
  • The measured condition under which a limiter is worth roughly 8× — and the one under which it does nothing whatsoever
  • Why a fixed-window counter is still wrong after you fix its races, which is where Chapter 11's answer stopped one step short
  • Allow, Reserve and Wait as three answers to what happens to the one who cannot be served now, including the one that fails before it waits and the one that cleans up after itself
  • What semaphore.Weighted buys over the channel semaphore you already have, and the four ways it surprises you
  • Why buffer capacity is a latency budget, and the measured point where more of it destroys goodput while appearing to increase throughput
  • What retries actually amplify — measured, and it is not what you would guess
  • What a circuit breaker must not count as a failure, and why that one predicate is the difference between protection and a self-inflicted outage
  • Where the limit lives: per-replica multiplication, keyed maps that never evict, and one adaptive algorithm with both of its failure modes measured
What we’re not covering
  • Distributed rate limiting — Redis token buckets, sliding windows shared across replicas, coordinated quota. §17.7 names the per-replica problem and hands the solution to the distributed systems course
  • Queueing theory beyond Little’s Law. One equation earns its place here; the rest is a different book
  • Load balancing and request routing, which decide which replica gets the work rather than whether the work happens at all
  • Testing technique as a subject — Chapter 16. This chapter leans on testing/synctest and says so once
  • The bug catalogue — Chapter 18 collects the leaks and misclassifications this chapter warns about
Building toward

Chapter 15 gave the process a bounded, orderly exit on a budget it did not set. Chapter 16 made that exit provable rather than hoped-for. This chapter gives the process a bounded, orderly entrance — the same discipline at the other end of a request’s life, against a budget you do not set either, because it belongs to your dependency. Chapter 18 turns the misclassifications here into a review checklist.

Prerequisites

The channel semaphore from §2.5 and §5.4, because §17.4 is an argument about when to replace it. §5.3's case against large buffers, which §17.5 turns into a latency measurement. Worker-pool sizing from §7.3. Mutex contention from §9.3, since §17.3 measures a limiter behaving exactly like the shared state it is. CompareAndSwap from §11.4.6, which is the breaker’s half-open probe — and §11's own rate-limiter self-check, which §17.2 opens by finishing. errgroup.SetLimit from §14.4.4, whose memory figures this chapter cites rather than re-derives. Context deadlines and cancellation from §13.3 and §13.5 throughout — every mechanism here takes a ctx and the interesting behaviour is what it does with it. §15.5's in-flight-versus-queued distinction, which §17.5 asks a question Chapter 15 did not. And Chapter 16's testing/synctest, which is how most of the figures below were made exact.

Which Go are we on?

Every listing and figure was run on Go 1.25 or later, against golang.org/x/time v0.15.0 and golang.org/x/sync v0.22.0 (v0.16.0 and v0.23.0 at the time of this revision; the exercise module pins those, and the rate.Limiter and errgroup APIs used here are unchanged). Four things matter. Go 1.23 rewrote the runtime timers (and Go 1.27 removed the last opt-out), so an unstopped time.Ticker is now garbage-collectable once unreachable — which retires defer t.Stop() as a leak fix and makes older writing about ticker leaks misleading rather than merely dated (§17.2.3). Go 1.25 stabilised testing/synctest, which is why this chapter asserts exact equality on timings where Chapter 15 had to quote ranges. x/time/rate is not frozen: TokensAt, SetLimitAt and SetBurstAt are recent additions that exist to make a limiter testable against a clock you control. And golang.org/x/time sits outside the Go 1 compatibility promise in exactly the way §14.4's callout describes for errgroup — maintained by the Go team, no transitive dependencies, and an API that still grows.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, golang.org/x/time v0.15.0, golang.org/x/sync v0.22.0. This chapter’s evidence comes in two kinds, and they are tagged differently because they deserve different trust.

Contention benchmarks are real wall-clock measurements on a machine that is doing other things, reported as the minimum of five passes at -benchtime 300ms. Treat their ratios, not their absolute values.

Everything with a queue in it — the value matrix, the buffer latency table, the retry spiral, the throttle recovery curve — was run inside a testing/synctest bubble on virtual time. That removes the wall clock, and it is worth being precise about what it does not remove: synctest virtualises time, not goroutine scheduling order. So a figure is exact when its outcome is decided by a clock or by a serialised admission point, and only stable-to-a-range when scheduling gets a vote — either because several hundred goroutines are racing for a free slot, or because two cases of a select come ready in the same instant and the runtime picks between them uniformly at random.

Both kinds appear below and are marked. Where a run says 990 ms it means 990 ms and not “about a second”; where a row is a range, the range is the spread across five runs. The distinction turns out to teach something, and §17.1.5 is where it does.

Each block states its parameters inside the block rather than in the surrounding prose. That rule exists because this chapter is almost entirely numbers-under-load, and a figure that travels separately from its conditions eventually gets paired with the wrong ones.

17.1 A Limiter Is a Queue

17.1.1 The Four You Already Own

The limiters you already own:
What you have
Channel as counting semaphore
Bounded queue with backpressure
Worker pool with a sized intake
errgroup.SetLimit

Four mechanisms, one shape: a fixed number of permits, handed out and returned. Chapter 5 built it from a channel, Chapter 14 wrapped it in a method, and both were right for the problem in front of them.

What none of them can express is a bound on events per unit time.

17.1.2 Rate and Concurrency Are Different Units

A semaphore of ten says “ten at once” and is completely silent about whether that is ten per second or ten thousand. If the work is fast enough, a concurrency limit permits an unbounded rate.

The reverse fails just as badly. A rate limit of 100/s says nothing about how many requests are in flight, because that depends on how long each one takes — and how long each one takes belongs to your dependency, not to you.

Neither limit implies the other, and using one where the other was needed is the whole bug class this chapter is about. It produces a system that is provably compliant with a limit nobody cared about.

17.1.3 Little’s Law Decides the Third Number

The two units are related, and the relationship is one line:

Terminal
L = λ W
  L items in the system (concurrency)
  λ arrival rate (rate)
  W time each item spends (latency)
Derived pick any two and the third is decided for you. A service holding a concurrency limit of 100 against work that takes 50 ms is a 2,000/s rate limit, whether or not anyone wrote that number down. When its dependency slows to 500 ms, the same service is a 200/s rate limit — the limit did not move, and the throughput fell by ten.

Run it the other way and the failure is sharper. Bound the rate at 2,000/s, let latency go from 50 ms to 500 ms, and L climbs from 100 to 1,000. You bounded arrivals and the in-flight count grew tenfold, because W was never yours. That is the first listing in the cold open: perfect compliance, climbing memory.

Chapters 5 and 7 both asked how big a buffer should be and answered with heuristics. This is the arithmetic those heuristics were approximating.

A queue you sized by feel is a latency number you chose by accident.

L = λW rearranges to W = L/λ: a 1,000-slot queue draining at 500/s is two seconds of waiting, sitting inside your process, invisible until someone measures the p99. You did not choose 1,000 slots. You chose two seconds.

17.1.4 The Four Dispositions

Once the work does not fit, there are exactly four things to do with the excess. They are independent choices, not points on a scale.

Wait. Hold the caller until a permit is free. Costs latency, preserves the work, and is the only disposition that requires the caller to have slack.

Reject. Refuse immediately and say so, in a way the caller can act on — a 429, a Retry-After, an error it can route elsewhere. Costs the work, preserves latency, and is the only disposition that hands the caller a decision.

Drop. Discard silently. Distinct from rejection because nobody is told, which is occasionally correct — a metrics sample, a cache warm, a frame of telemetry — and catastrophic everywhere else.

Degrade. Serve a cheaper answer: a stale cache, a partial result, the answer without the recommendations. §14.7 built the machinery; this is where you decide to use it under load rather than under failure.

Chapter 15 named four promises so a given bug could be attributed to one. These work the same way. “The service got slow” is not a diagnosis. “We chose to wait when we should have rejected, and the callers had no slack” is.

In code they are four different returns from one place, which is the point — the decision is made once, at admission, not scattered through the handler:

server_171.go
// Illustrative snippet — not a complete program
// One admission point, four dispositions. Which one you pick is a
// product decision; making it explicit is an engineering one.
func (s *Server) admit(
    ctx context.Context, r *Request,
) (*Reply, error) {
    switch {
    case s.lim.Allow():
        return s.serve(ctx, r)          // there was room

    case r.CanWait && ctx.Err() == nil:
        if err := s.lim.Wait(ctx); err != nil {
            return nil, err             // WAIT: caller had slack
        }
        return s.serve(ctx, r)

    case r.Sheddable:
        s.dropped.Add(1)                // DROP: nobody is told
        return nil, nil

    case s.cache.Has(r.Key):
        return s.cache.Stale(r.Key), nil // DEGRADE: cheaper answer

    default:
        return nil, ErrTooManyRequests   // REJECT: caller decides
    }
}

Notice that three of the four are one line. The expensive one is Wait, and it is expensive precisely because it is the only branch that keeps the caller.

17.1.5 Measured: When a Limiter Is Worth Nothing

Here is the measurement that should decide whether you install any of this, and it is the one most writing on the subject omits.

The model: a downstream with fixed concurrency and a fixed per-request cost, behind an unbounded intake queue. Both arms are given the same courtesy — a worker checks the request’s context before spending time on it and drops it for free if the caller has already gone — because withholding that from the unlimited arm would rig the comparison. The limited arm puts a semaphore in front, where waiting is free and cancellable and no downstream capacity is committed until a caller is through it.

The only thing that varies is whether the caller has slack: whether its total budget exceeds the deadline it puts on a single attempt.

Terminal
300 clients, downstream 10 concurrent x 20ms (500 req/s)
served = client got a result inside its total budget
wasted = worker-time spent on callers who had left
[1] budget == attempt deadline (250ms) -- no slack
   1 try limit=none served 120/300 wasted 8% wall 250ms
   1 try limit=10 served 120/300 wasted 8% wall 250ms
   3 try limit=none served 120/300 wasted 8% wall 250ms
   3 try limit=10 served 120/300 wasted 8% wall 250ms
  10 try limit=none served 120/300 wasted 8% wall 250ms
  10 try limit=10 served 120/300 wasted 8% wall 250ms
[2] budget 800ms, attempt deadline 80ms -- slack to queue
   1 try limit=none served 33-39 wasted 20-28% wall 80ms
   1 try limit=10 served 300/300 wasted 0% wall 600ms
   3 try limit=none served 82-90 wasted 32-42% wall 240ms
   3 try limit=10 served 300/300 wasted 0% wall 600ms
  10 try limit=none served 259-273 wasted 34-40% wall 800ms
  10 try limit=10 served 300/300 wasted 0% wall 600ms
  limit=none rows: range across five runs
  limit=10 rows: identical on every run
Measured on virtual time. Note which rows carry ranges and which do not — that pattern is itself a result, and the last paragraph of this section returns to it.

Block [1] is six identical rows. When the caller has no slack, the limiter is inert. A client population whose total budget equals its per-attempt deadline is already self-limiting: it waits 250 ms, leaves, and leaving costs the server nothing beyond the work already in flight. Admission control changes nothing at all, because there was never anything to gain by making those callers wait — they had no waiting left to give.

Block [2] is where the limiter earns its place, and the mechanism is in the waste column. Without it, a fifth to a third of the downstream’s capacity goes to requests whose caller had already gone. With it, that number is zero, because nobody is admitted unless there is a slot free right now.

The 1-try rows are the headline: around 36 served becomes exactly 300, which is roughly . But read the wall column before celebrating. The limiter did not make anything faster. It made the system finish: 600 ms rather than 80 ms, which is exactly 300 ÷ 500 per second — Little’s Law again, and the best possible time for that much work through that much capacity. What it converted was a fast failure into a slow success.

The 10-try rows are the more honest comparison, because retrying is what a real client does. Ten attempts without a limiter eventually push 259–273 of 300 through — but they burn 34–40% of the downstream doing it, take 800 ms rather than 600 ms, and still lose thirty or more callers. Retries are a way of rediscovering admission control badly, which §17.6 measures properly.

That gives the chapter its spine, and it is the sentence to hold for everything that follows:

The spine of this chapter

A limiter is a queue that converts failure into latency — and it can only do that if the caller has latency to spend. Every section from here answers one half of that sentence: where the queue is, what the conversion costs, or whether the caller can pay.

One more thing is visible in that table, and it is not about throughput at all. Every limit=none row is a range; every limit=10 row is a single number, identical on all five runs. The unlimited arm’s outcome depends on which of three hundred goroutines reaches a free slot first, so it moves. The limited arm’s outcome is decided at a serialised admission point and by Little’s Law — 300 requests ÷ 500 per second is 600 ms, every time.

That is worth naming, because it generalises past the measurement: admission control makes a system reproducible as well as efficient. The unlimited arm is unpredictable for exactly the reason it is wasteful — nothing decides the order in which work is admitted, so nothing decides which callers win. A service without a bound does not merely perform worse; it performs differently on every run, which is what makes overload incidents so hard to reproduce afterwards.

The condition, stated plainly.

A limiter helps when abandoned work still costs the server. If your callers give up and their work stops costing you — because you propagate deadlines and check them, as §17.5.2 describes — a large part of the benefit is already yours, and the limiter is a smaller change than it looks. If abandoned work keeps running, it is the difference between about 36 and exactly 300.

17.1.6 Where the Queue Goes When You Do Not Build One

The corollary matters as much as the theorem. Not installing a limiter does not mean there is no queue. It means the queue is somewhere you did not choose:

Every one of those has a capacity, a drain rate and a latency consequence you did not pick, and none of them checks a deadline or exports a metric. The choice is never queue or no queue. It is a queue you can see and bound, or one you cannot.

A wait you did not bound is a queue you did not know you had.

This line recurs for the rest of the chapter, because every remaining section is a specific instance of it.

17.1.7 Common Mistakes

Concurrency limit used to bound a rate
Problem

Unbounded rate when the work is fast

Fix

Decide which unit you meant; L = λW converts

Rate limit used to bound memory
Problem

In-flight count grows as the dependency slows

Fix

Bound concurrency too; rate alone does not cap L

Installing a limiter for callers with no slack
Problem

No measurable change, and a new failure mode

Fix

Measure first; block [1] is a real shape

Sizing a queue by memory
Problem

A p99 nobody chose and nobody can explain

Fix

Size by W = L/λ and state the latency

Treating the four dispositions as a scale
Problem

“Somewhat rejected” work that is really just late

Fix

Choose one per class of work, deliberately

Removing the limiter to remove the queue
Problem

The queue moves somewhere with no deadline check

Fix

Own the queue you can see

Expecting a limiter to create capacity
Problem

Throughput unchanged, latency worse

Fix

It converts failure into latency; that is all

Summary: A Limiter Is a Queue

You already own four limiters, and all four bound concurrency. None bounds a rate, and the two are different units: a concurrency limit permits an unbounded rate when the work is fast enough, and a rate limit leaves the in-flight count free to grow as your dependency slows.

L = λW is the only bridge, and it runs in the direction that happens to you rather than the one you configure.

Whether a limiter helps at all is measurable and conditional. With no caller slack it was inert across six rows; with slack it was worth roughly 8×, and the price was named in the wall column — 80 ms became 600 ms, which is Little’s Law’s best possible time for that work through that capacity. Ten retries without a limiter eventually pushed 259–273 of 300 through while burning a third of the downstream, which is admission control rediscovered badly.

The four dispositions — wait, reject, drop, degrade — are independent choices, and naming them separately is what makes a bug describable.

And not building a queue does not remove it. It relocates it to the kernel, the load balancer, or the client’s retry loop, none of which checks a deadline or exports a metric.

Self-Check Questions: A Limiter Is a Queue

A service holds a concurrency limit of 200. Its dependency’s p50 latency rises from 20 ms to 200 ms. What happened to the rate it offers that dependency, and did anyone change a limit?

The rate fell by a factor of ten, from 10,000/s to 1,000/s, and nobody changed anything.

L = λW with L pinned at 200: at W = 20 ms, λ = 200/0.02 = 10,000/s. At W = 200 ms, λ = 200/0.2 = 1,000/s.

This is the useful direction of the law, because it is the one that happens to you. A concurrency limit is a rate limit whose value is set by somebody else’s latency, and it is renegotiated every time that latency moves. If a downstream team ships a slower version, your offered rate drops by the same factor, silently, and the only symptom is that your own queue grows.

The mirror case is the cold open’s first listing: pin λ instead, let W grow, and L is what moves. A rate limiter holding perfectly at 100/s against a dependency that slowed tenfold now holds 1,000 requests in flight where it held 100 — and each of those is a parked goroutine.

Your load test shows a limiter makes no difference at all. Name two conditions under which that is the correct result rather than a misconfiguration.

First, the callers have no slack. If each client’s total budget equals its per-attempt deadline it cannot afford to wait at your permit, so the permit never changes who is served — block [1], six identical rows at 120/300. The population limits itself by leaving, and leaving is free.

Second, abandoned work already costs the server nothing. The limiter’s value comes from preventing capacity being spent on callers who have gone. If your downstream checks the request context before starting work, queueing inside it is nearly as cheap as queueing at the permit, and there is little left for the limiter to recover. That is why the measurement gave both arms the deadline check: withholding it from the unlimited arm would have manufactured the result.

Both are worth checking before tuning, because both produce the same graph as a limit set too high, and only one of them is fixed by changing a number.

The load generator itself is the most likely thing to mislead you here. One that fires everything at once under a single generous deadline manufactures slack the real callers do not have, and will show you a benefit that does not exist in production.

Why is “drop” a separate disposition from “reject”, when both refuse the work?

Because the caller learns something in one case and not the other, and that difference decides what it does next.

A rejection is a decision handed back: a 429 with a Retry-After, or an error the caller can route to a fallback, a different replica, or a queue for later. The work is refused but the information is preserved.

A drop discards both. Nobody is told, so nobody retries, nobody fails over, and nothing appears on a dashboard unless you counted it on the way out. That is occasionally exactly right — a telemetry sample, a cache warm, a frame of a video feed — because for that class of work a retry is worth less than the capacity it would consume.

For everything else it is the worst available outcome, and §17.6 says why: a caller that is not told it was refused behaves as though the request is still in flight, waits out its own deadline, and then retries anyway. You paid the full cost of refusing and got none of the benefit — and in the meantime the work you dropped was replaced by a copy you have to drop again.

Key Takeaways

  • You already own four concurrency limiters; none of them bounds a rate, and the two are different units
  • L = λW is the only bridge, and it runs in the direction that happens to you rather than the one you configure
  • A queue sized by memory is a latency number chosen by accident
  • measured, with no caller slack the limiter was inert across six rows; with slack it was worth roughly 8×, at 80 ms → 600 ms
  • The mechanism is where the queueing happens — at the permit, where waiting is free, rather than inside the downstream, where a timeout strands admitted work
  • Ten retries without a limiter pushed 259–273 of 300 through while burning 34–40% of the downstream: admission control, rediscovered badly
  • Four dispositions, chosen deliberately per class of work: wait, reject, drop, degrade
  • Deleting your queue relocates it to a layer with no deadline check and no metric
Section 17.1 — in one line

A limiter is a queue that converts failure into latency, and it can only do that if the caller has latency to spend.

17.2 Token Buckets, and Why Not a Ticker

Chapter 11 asked you to find the bugs in a rate limiter, and you found them. This section begins by pointing out that the answer it gave you is still wrong.

17.2.1 The Debt Chapter 11 Left

The self-check in §11's atomic anti-patterns presents a fixed-window counter with three defects — a window-reset race, a check-then-act race, and an invariant spanning two values — and offers this as the fix:

rate_limiter_172.go
// Illustrative snippet — not a complete program
func (r *RateLimiter) Allow() bool {
    r.mu.Lock()
    defer r.mu.Unlock()

    now := time.Now().Unix()
    if now > r.window {
        r.window = now
        r.requests = 0
    }
    if r.requests < r.limit {
        r.requests++
        return true
    }
    return false
}

Every claim Chapter 11 made about that code is true. The races are gone, -race is clean, the invariant holds. As an answer to “where are the data races?” it is complete.

As a rate limiter it is broken, in exactly the way a rate limiter must not be.

Measured limit = 100 per second, starting just before a window boundary:
Terminal
limit 100/s, sampled across one window boundary
admitted 200 requests, first to last: 20ms
configured 100/s observed 200 in 0.02s = 2x

A fixed window resets on a wall-clock boundary. Nothing stops a caller spending its full allowance at t = 0.99 and its next full allowance at t = 1.01. Across those twenty milliseconds the downstream sees two hundred requests — twice the configured rate, delivered in a fiftieth of the configured interval, which is precisely the burst the limiter was installed to prevent.

A race and a rate are different bugs.

The mutex fixed a synchronization problem, and there was one to fix. What it cannot touch is that the algorithm permits the burst. Locking makes a decision consistent; it never makes it correct. This is worth sitting with, because “add a mutex” is the reflex Chapter 9 trained and Chapter 11 reinforced, and it is a complete answer only when the bug is two goroutines disagreeing.

That distinction is why this chapter exists as something other than a tour of libraries. Chapters 8 through 12 taught you to make concurrent code agree. Agreement is not correctness, and a limiter is the cleanest case in the book where you can have one without the other.

17.2.2 The Token Bucket

The fixed window’s problem is that it forgets everything at the boundary. A token bucket never forgets; it accrues.

TOKEN BUCKET

A bucket holding tokens, refilled continuously at r tokens per second up to a capacity of b, the burst. The illustration shows five slots with tokens present and the parameters b equals 5 and r equals 2 per second. Each request takes one token; an empty bucket means the caller waits or is refused. The caption works an example: idle for three seconds at two per second adds six tokens but the bucket caps at b, and that cap is the whole design, because history is forgiven only up to b and never further.

Two parameters answering different questions. Rate is the sustained throughput you permit. Burst is how much unused allowance you remember — not slack, not a safety margin, but a bounded quantity of forgiven history.

The fixed window is this same idea with an unbounded burst at every boundary, which is why 2× is its floor rather than its ceiling.

Derived the worst-case boundary burst of a fixed window is 2 × limit delivered within one window’s width, for any window length. Halving the window halves the absolute burst and doubles how often the opportunity arrives.

17.2.3 Measured: A Ticker Degrades to the Receiver’s Rate

time.Ticker looks purpose-built for this. It delivers a value at a fixed interval; a limiter delivers a permit at a fixed interval.

tk_172.go
// Illustrative snippet — not a complete program
tk := time.NewTicker(10 * time.Millisecond) // "100 per second"
defer tk.Stop()
for req := range requests {
    <-tk.C
    handle(req)
}

The channel is unbuffered — it has been since the Go 1.23 timer rewrite, and since Go 1.27 there is no go.mod or GODEBUG setting that brings the old one-element buffer back — and the documentation is explicit: the ticker “will adjust the time interval or drop ticks to make up for slow receivers.” If nobody is receiving when a tick fires, the tick is discarded rather than queued.

Measured a 10 ms ticker driving a loop whose body takes 50 ms, run for 500 ms:
Terminal
10ms ticker, 50ms body, 500ms run
ticks due: 50 ticks received: 10-15

Fifty were due; ten to fifteen arrived across twenty-four runs. Derived: ten is the floor and it is not a coincidence — 500 ms of runtime divided by a 50 ms body is ten iterations, so the loop ran at the rate of its own body and the ticker contributed nothing but a name. (This is a range rather than an exact number for the second reason §17.2.8 gives: when a tick and the run deadline come ready in the same instant, select picks between them at random.) You configured a limit that was never reached, and the number in your config file has no relationship to the behaviour of the program.

That is dangerous rather than merely wrong, and the reason is that it never pages anyone. It surfaces weeks later as a throughput ceiling nobody can explain.

17.2.4 Three Reasons Not to Ship It

It cannot express a burst. One permit exists at any moment and nothing accrues. A client idle for a minute is treated exactly like one that has been hammering you, which is the opposite of what a burst parameter is for.

It enforces the receiver’s rate, silently. §17.2.3.

Its leak is now a ghost, which is its own hazard. time.Tick returns a channel you cannot stop, and before Go 1.23 an unreachable ticker was never collected, so time.Tick in a request path was an unbounded leak. That is no longer true.

defer tk.Stop() is now intent, not a leak fix

the Go 1.23 timer rewrite made unreachable tickers collectable — gated on your go.mod’s go line until Go 1.27 removed the asynctimerchan opt-out, so it now holds for every module the toolchain will build. Stop still stops the ticker promptly rather than at the collector’s convenience, and still documents that this ticker’s life ends here — so keep writing it. Just stop believing it stands between you and an unbounded leak, and treat pre-1.23 writing on ticker leaks as misleading rather than merely dated.

17.2.5 What rate.Limiter Does Instead

The natural assumption is that golang.org/x/time/rate is a well-tested version of the ticker loop — a goroutine, a timer, a channel. It is none of those. It has no goroutine, and no timer exists until you call Wait.

The mechanism is arithmetic over time.Now():

limiter_172.go
// Illustrative snippet — not a complete program
// from x/time/rate, condensed
func (lim *Limiter) advance(t time.Time) (newTokens float64) {
    elapsed := t.Sub(lim.last)
    delta := lim.limit.tokensFromDuration(elapsed)
    tokens := lim.tokens + delta
    if burst := float64(lim.burst); tokens > burst {
        tokens = burst // the cap, and the whole burst design
    }
    return tokens
}

A call computes how many tokens accrued since the last call, caps at the burst, subtracts what you asked for, records the time. Tokens are float64, so a rate of 2.5/s is exactly representable rather than rounded to a tick interval. The bucket refills lazily, at the moment somebody looks at it.

Two consequences worth carrying. An idle limiter costs nothing — no goroutine to leak, no ticker to stop — which is why you can hold thousands of them, one per tenant, and why §17.7.2 is about what that costs instead. And everything happens under one mutex, which makes a shared limiter shared mutable state in the precise Chapter 9 sense; §17.3.6 measures the bill.

17.2.6 Choosing the Burst

Burst is the parameter people set carelessly, usually by making it equal to the rate because the numbers look like they belong together. They are different units — burst is in requests, rate is in requests per second — and equality means nothing.

The question burst answers is: how much simultaneous arrival can the thing I am protecting absorb? That is a concurrency question, not a rate question (§17.1.2), and its answer comes from the downstream.

17.2.7 The Algorithms You Will Hear Named

Rate-limiting algorithms:
Algorithm
Fixed window
Sliding window
Token bucket
Leaky bucket

Token bucket and leaky bucket are usually described as opposites and are better understood as the same accrual with different outputs: a token bucket lets you spend what has accrued, a leaky bucket paces output regardless. rate.Limiter with burst 1 is close enough to a leaky bucket to be the practical way to get strict pacing.

17.2.8 Why This Chapter’s Figures Are Exact

Everything here is time-dependent, which normally means every test of it is slow, flaky, or both. §15.7.7 measured the limit of testing/synctest: a real net/http server inside a bubble never advances, because a socket is not a state the runtime can see every goroutine blocked on (Go 1.27’s httptest.NewTestServer moves HTTP onto an in-memory network for exactly this reason — §16.4.8 — but the point stands for anything holding a real socket).

A limiter has no socket. It is pure time and pure channels — exactly what the bubble was built for.

Measured inside synctest.Test, with rate.NewLimiter(10, 1):
Terminal
11 events at 10/s burst 1 -> EXACTLY 1s virtual, 0.00s real
blocked Acquire, 250ms ctx -> returned at EXACTLY 250ms

Not “about a second”. Exactly a second, in zero real time, on every run and on any machine.

This is the precise inverse of Chapter 15's finding, and it is why the load figures in this chapter are stated as exact numbers rather than ranges: they are computed on a virtual clock, so the program that produced the value matrix in §17.1.5 prints those same numbers wherever it runs. Chapter 16 covers the machinery; what matters here is the licence it grants.

17.2.9 Common Mistakes

Fixed window because it is easy to write
Problem

2× the rate across every boundary

Fix

Token bucket; the burst is explicit and capped

Adding a mutex and calling the limiter fixed
Problem

Race-free, still bursty

Fix

Ask whether the bug was agreement or algorithm

Building a limiter on time.Ticker
Problem

Silently enforces the body’s rate

Fix

rate.Limiter; it needs no receiver

Expecting Ticker to queue missed ticks
Problem

Fewer permits than configured, no error

Fix

It drops them by documented design

Burst set equal to the rate
Problem

Bursts the downstream cannot absorb

Fix

Burst is a concurrency answer; ask the downstream

defer tk.Stop() believed to prevent a leak
Problem

Advice that outlived Go 1.23

Fix

Keep it for promptness and intent

Assuming rate.Limiter runs a goroutine
Problem

Phantom worries about leaking limiters

Fix

Lazy arithmetic under a mutex

Summary: Token Buckets, and Why Not a Ticker

Chapter 11's rate limiter is race-free and still wrong. A fixed window forgets its counter at a wall-clock boundary, so a caller spends a full allowance either side of it — measured at 200 requests in 100 ms against a configured 100/s. The mutex fixed a synchronization bug; the algorithm was never a synchronization bug.

A token bucket accrues instead of forgetting. Rate is sustained throughput, burst is bounded forgiven history, and burst is a question about what the downstream can absorb at once — a concurrency answer, not a rate one.

time.Ticker cannot express a burst and drops ticks for slow receivers by design: 10 to 13 arrived where 50 were due, so the limiter silently enforced its own loop body’s rate. Since Go 1.23 an unstopped ticker is collectable, which makes defer tk.Stop() a statement of intent rather than a leak fix.

rate.Limiter runs no goroutine and no timer — lazy float arithmetic under one mutex, free when idle and contended when shared.

And because a limiter is pure time and pure channels, it is fully deterministic under synctest: 11 events at 10/s took exactly one second of virtual time. That is the inverse of §15.7.7, and it is why this chapter’s load figures are exact wherever a clock or a serialised admission point decides the outcome — and honest ranges wherever scheduling gets a vote.

Self-Check Questions: Token Buckets, and Why Not a Ticker

Chapter 11's fixed-window limiter passes -race. Explain to a reviewer why that is not an argument that it is correct.

Because the race detector answers a question about agreement, and the bug is about the decision.

-race reports unsynchronized access to shared memory. After the mutex there is none: every goroutine sees a consistent window and requests, and every increment is observed by the next reader. The tool has nothing left to say, and it is right.

What the tool cannot evaluate is whether the rule the code implements is the rule you wanted. “Reset the counter when the wall-clock second changes” is a perfectly synchronized rule that admits limit requests at t = 0.99 and limit more at t = 1.01 — measured at 2× the configured rate. Every one of those admissions was correctly serialized.

This is the general shape: static and dynamic analysis catch classes of bug defined in terms of the execution, never in terms of the specification. Chapter 15's opening had the same structure from the other side — vet caught one bug of three, and the two it missed were the ones about what the program meant to do.

You set rate.NewLimiter(100, 100) because 100/s felt right and the burst should “match”. A client idle for ten seconds sends a spike. What does the downstream see, and what should the burst have been?

The downstream sees 100 requests as fast as the client can send them, then a strict 100/s.

Ten seconds of idleness accrues 1,000 tokens at 100/s, capped at the burst of 100. A full bucket is spent instantly: the rate parameter constrains refill, never the spending of what has already accrued.

Whether that is wrong depends entirely on the downstream. If it can absorb 100 concurrent requests, the burst is doing its job — forgiving ten seconds of unused allowance, which is often exactly right for bursty batch clients.

If it cannot — and a downstream sized for 100 requests per second frequently cannot absorb 100 at once, because that is a concurrency claim rather than a rate claim (§17.1.2) — then the burst is the bug, and it has nothing to do with the rate. Size it by what the downstream tolerates simultaneously, or use burst 1 for strict pacing.

The trap is the intuition that burst should “match” the rate. Burst is measured in requests and rate in requests per second; the numbers being equal is a coincidence of notation.

Why can rate.Limiter be tested with exact-equality timing assertions when Chapter 15's HTTP server could not?

Because synctest's clock advances only when every goroutine in the bubble is durably blocked, and a socket is not a state the runtime can classify that way.

Chapter 15's shutdown tests involved a real net/http server on a loopback connection — the case Go 1.27’s in-memory httptest.NewTestServer exists to avoid. A goroutine blocked reading a socket is blocked on the operating system; the bubble cannot know it will stay blocked, so virtual time never moves and the test hangs rather than completing.

A limiter has nothing outside the runtime in it. rate.Limiter.Wait computes a delay and blocks on a timer; semaphore.Weighted.Acquire blocks on a channel. Both are states the runtime owns completely, so the bubble sees every goroutine parked, jumps the clock to the next timer, and continues.

The consequence goes beyond the tests, but it has a boundary worth stating. Removing the wall clock makes a figure machine-independent; it does not make every figure single-valued, because synctest virtualises time and not scheduling. So the rows of §17.1.5 where a serialised permit decides the outcome print the same number on your laptop as on the machine that produced them, and the rows where three hundred goroutines race for a free slot print a stable range instead.

Chapter 15 had to report ranges everywhere and say which digits were load-bearing. This chapter reports them only where scheduling genuinely gets a vote — which turns out to be a smaller set, and a legible one.

Key Takeaways

  • Chapter 11's mutex fixed the race and left the rate bug: 200 admitted in 100 ms against a configured 100/s
  • A race and a rate are different bugs; a lock makes a decision consistent, never correct
  • Burst is bounded forgiven history and answers a concurrency question — ask what the downstream absorbs at once
  • measured, a ticker delivered 10–15 permits where 50 were due, silently enforcing its own body’s rate
  • Since Go 1.23 an unstopped ticker is collectable; defer tk.Stop() is promptness and intent
  • rate.Limiter runs no goroutine and no timer — lazy float arithmetic under one mutex
  • Limiters are fully deterministic under synctest, which is why this chapter’s load figures are exact wherever a clock or a serialised admission point decides the outcome
Section 17.2 — in one line

A fixed window forgets at the boundary and a ticker forgets you asked, which is why the limiter worth using is the one that only does arithmetic.

17.3 Allow, Reserve, Wait: Choosing How to Fail

rate.Limiter has three ways to ask for a permit. They are usually presented as convenience variants of one operation. They are three different answers to the question this chapter keeps returning to — what happens to the one who cannot be served now? — and choosing between them is the most consequential decision in this section.

17.3.1 Three Answers to One Question

WHAT EACH CALL DOES WHEN THERE IS NO PERMIT

A decision table branching on whether a permit is available. When one is available, Allow returns true, Reserve returns a reservation with zero delay, and Wait returns nil. When none is available, Allow returns false so the caller rejects, Reserve returns a reservation with a delay greater than zero so the caller decides, and Wait blocks until a permit arrives. The three calls are three answers to the same question: what happens to the caller who cannot be served right now.

Allow is the rejection disposition. It asks, gets an answer, returns. Nothing waits. If you are shedding load this is the call, and it is the only one of the three that cannot make a caller slow.

The three are the same handler with one line changed, which is the clearest way to see that the choice is about the caller and not about the limiter:

res_173.go
// Illustrative snippet — not a complete program
// REJECT. Never blocks, never keeps the caller.
if !lim.Allow() {
    http.Error(w, "rate limited", http.StatusTooManyRequests)
    return
}
serve(w, r)

// WAIT. Blocks until a permit or the caller's deadline, whichever
// comes first. Bounded only by ctx -- see §17.3.2.
if err := lim.Wait(r.Context()); err != nil {
    return // caller gave up, or a permit would arrive too late
}
serve(w, r)

// DECIDE. Quote a price, then choose. The only one that can put a
// number in the refusal.
res := lim.Reserve()
if d := res.Delay(); d > budget {
    res.Cancel()
    w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())+1))
    http.Error(w, "rate limited", http.StatusTooManyRequests)
    return
}
time.Sleep(res.Delay())
serve(w, r)

Wait is the waiting disposition. It blocks until a permit is free or the context is done. It is the shortest to write, which is why it is the one nearly everyone reaches for first.

Reserve is neither. It takes a permit, tells you how long until you may use it, and hands the decision back:

r_173.go
// Illustrative snippet — not a complete program
r := lim.Reserve()
if !r.OK() {
    return errTooBig // n exceeds burst; never satisfiable
}
if d := r.Delay(); d > budget {
    r.Cancel() // give the tokens back — §17.3.4
    return errWouldBeTooSlow
}
time.Sleep(r.Delay())

Quote me a price and I will decide is the right shape whenever the caller has a deadline it can reason about, and it is the least used of the three.

It also has a use the other two cannot serve. r.Delay() is the one number in this chapter that answers “how long until this would work?” — which is exactly the number a Retry-After header carries (§17.6.2). Refusing with Retry-After: <the delay you just cancelled> turns a bare rejection into a scheduling instruction, and it costs one line on a path you are already taking.

17.3.2 Wait Is an Unbounded Queue

§14.4.4 measured what a parked goroutine costs: 2,081 bytes of stack and 606 bytes of heap, 2.7 KB apiece, and ten thousand of them holding 25.6 MiB where a limited group needed 27 KiB. That measurement is cited, not repeated. It is the same goroutines and the same bill.

What differs is who decides how many there are.

In Chapter 14 the count came from a slice you chose to submit. Ten thousand items meant ten thousand goroutines because you wrote the loop. The number was yours, knowable before the loop started, and SetLimit capped it.

limiter.Wait(ctx) in a request handler has no such bound. The count is set by arrivals, and arrivals are set by the internet.

At an offered load below the limit this is indistinguishable from correct. Above it, every excess request becomes a parked goroutine holding 2.7 KB plus its request context plus whatever the handler allocated before the call — and the limiter reports, accurately, the whole time, that it is holding the configured rate.

Derived at 2.7 KB per parked goroutine, a limiter holding 100/s against 500/s of offered load accumulates 400 goroutines per second, about 1 MB per second of pure waiting. Ten seconds of spike is 10 MB and four thousand goroutines; a minute is 60 MB and twenty-four thousand. None of that is a bug in rate.Limiter. It did what Wait means.

Wait converts a rate problem into a memory problem, and memory problems page later and louder. The rate problem was visible at the limiter; the memory problem surfaces as an OOM kill with a heap profile full of runtime.gopark.

Wait is correct when the caller population is bounded and known.

A worker pool of 32 goroutines pacing itself against an API quota is exactly right: queue depth can never exceed 32, and you chose 32. The failure mode is Wait on a path whose concurrency is set by inbound traffic. Ask how many goroutines can be inside this call at once? — if the answer is a number you chose, Wait is fine. If it is “as many as arrive”, it is the cold open’s first listing.

17.3.3 Measured: The Two Ways Wait Fails Without Waiting

Wait has two paths that return an error immediately rather than blocking, and they are easy to confuse.

The deadline short-circuit. Given a context with a deadline, WaitN computes how long the permit will take and compares it against the time remaining. If the permit would arrive too late it fails now rather than sleeping until the deadline and failing then:

Terminal
rate: Wait(n=1) would exceed context deadline

That is the right behaviour, and it is worth reading as a design principle rather than a detail: if the work cannot finish in time, refusing now is strictly better than refusing later. Refusing now returns the caller’s remaining budget so it can try a fallback, a different replica, or a degraded path while it still has time. It is §17.5.2's deadline-as-shed-signal, implemented inside a library.

It also means Wait under load does not necessarily park. Callers with tight deadlines get fast errors; only callers with slack accumulate — §17.1.5's condition, appearing again as a mechanism.

The dead limiter.

Measured rate.NewLimiter(100, 0).Allow() returns false. Always, every call, forever. A burst of zero means the bucket’s capacity is zero, so no token can be held and no request is ever satisfiable. Wait fails the same way through the n > burst check. The documented exception is rate.Inf, which ignores the burst entirely.
Terminal
NewLimiter(100, 0).Allow() → false (forever)
NewLimiter(rate.Inf, 0).Allow() → true (documented exception)

Nobody writes 0 deliberately. It arrives as a config value that failed to parse, a struct field nobody set, or arithmetic — burst := rps / 10 with rps of 5. The limiter then rejects one hundred percent of traffic and reports it only through a return value on the shed path, which is the path least likely to have an alert on it. The service is fully available, entirely idle, and refusing everything.

Validate limiter construction like any other config

a limit of zero and a burst of zero are both legal, both silently total, and neither is what anyone meant. Check them where you build the limiter, not where you use it.

17.3.4 Reserve: The Promise You Have Already Paid For

Reserve hands you the decision and attaches an obligation that is easy to miss: the tokens are consumed when you reserve them, not when you act. Decide not to proceed and they are gone unless you give them back.

Measured rate.NewLimiter(100, 1) — one token per 10 ms — burst already spent, then ten reservations made and abandoned:
Terminal
                        tokens next caller waited
  Cancel() called +0.00 10ms
  Cancel() forgotten -10.00 110ms

The limiter is ten tokens in debt for ten requests that were never sent, and the next caller waits eleven times longer than one arriving at an idle limiter. Scale that to a path where reservations are abandoned routinely — a cache hit, a validation failure, a disconnected client — and the limiter enforces a rate materially below the one you configured, with no error anywhere and no metric that would show it.

Two caveats keep this honest. Cancel restores tokens only “as much as possible”: if another reservation has been issued behind yours, yours cannot be cleanly withdrawn without moving somebody else’s slot. Cancel promptly and restoration is full. And there is no ReserveContext — for a reservation bounded by a deadline you compare r.Delay() against your own budget yourself.

17.3.5 Measured: Wait Cleans Up After Itself

That hedge in CancelAt invites an obvious worry. Wait cancels its reservation when the context is done — so does a workload with many cancelled waits leave holes in the schedule and achieve less than the configured rate?

Measured the answer is no. One hundred successful Wait calls at 100 per second, with zero, one and three additional callers reserving and being cancelled mid-wait between each one:
Cost of cancelled Wait calls:
cancelled mid-wait, between each success
0
1 (99 cancelled)
3 (299 cancelled)

Three hundred cancelled reservations cost nothing measurable. The mechanism explains it: CancelAt declines to restore only the tokens reserved after yours, and in a workload where each cancellation resolves before the next reservation is made, there are none. The hedge protects a case requiring reservations to overlap and outlive each other, which a Wait-driven workload does not produce.

Worth noting how the test had to be built, because the obvious version answers a different question. Cancelling with an already-expired context does not exercise the path at all: Wait short-circuits on §17.3.3's deadline check and never makes a reservation, so nothing is ever cancelled. The reservation has to be made and then cancelled, from elsewhere, while the caller is parked.

So the rule is not “cancellation is dangerous”. It is narrower and more useful:

Wait cleans up after itself; Reserve makes you do it.

Wait calls r.Cancel() on the context path for you, and it measurably costs nothing. Reserve hands you a reservation and a Cancel method, and every early return that does not call it is rate budget spent on work that never happened. That asymmetry is the whole reason to prefer Wait unless you specifically need the delay value.

While the schedule holds, so does the ordering.

Measured twelve goroutines calling Wait one millisecond apart on a 20/s limiter were served in exactly call order, [0 1 2 … 11], on every run. reserveN assigns each caller a distinct timeToAct synchronously at the moment of the call, so callers never race on a timer when a token frees. Wait is FIFO: the caller who has waited longest goes next, and a stream of new arrivals cannot starve an old one. §17.4.3 shows the semaphore making the same guarantee and charging for it.

17.3.6 Measured: The Limiter Is One Mutex

Every Allow, Wait and Reserve takes lim.mu. A shared limiter is shared mutable state in the exact Chapter 9 sense.

Measured Allow() under a 16-way parallel benchmark, rate set high enough that every call succeeds, so this measures the mutex and the arithmetic rather than the throttling:
Limiter contention, 16-way parallel Allow(), min of five passes
Limiter arrangement
One shared rate.Limiter
Keyed map, sync.Mutex, 8 keys
Keyed map, sync.Mutex, 64 keys
Keyed map, sync.Mutex, 512 keys
Keyed map, sync.RWMutex, 64 keys
One limiter per goroutine

16.5× under contention. At high fan-in the limiter becomes the bottleneck it was installed to prevent — a serialization point on the hot path of every request, doing arithmetic that costs 10.9 ns and mutex handoff that costs the rest.

Sharding by key helps and keeps helping: 137 ns at eight keys down to 128 at sixty-four. It never inverts. But look where it stops. From 64 keys to 512 the figure barely moves, and it never comes close to the unshared 10.9 — because past a certain point you are no longer measuring the limiters at all. The floor is the map’s own lock, taken by every request regardless of key, and adding shards cannot break a floor that does not shard.

Which is why the last row matters more than the sharding rows. Taking a write lock to perform a read is the actual cost, and a keyed limiter map is read-mostly by construction: the entry is created once and read forever after. Swapping sync.Mutex for sync.RWMutex takes 128.5 ns to 49.6 — a bigger win than every sharding decision combined, from a one-line change. §17.7.2 is where that map’s other problem lives.

17.3.7 rate.Sometimes

One more type ships in the same package, and it answers a question people keep solving badly with a limiter: not how often may this happen, but how often should I bother mentioning it. Log sampling, first-N-then-periodic error reporting, and “tell me when this starts and when it stops but not ten thousand times in between” are all this shape, and a token bucket is the wrong tool for every one of them.

The same package ships a type nobody knows about.

rate.Sometimes solves the adjacent problem — “do this the first N times, then once per interval, then stop” — which is what you actually want for log sampling and for “report this error, but not ten thousand times a second”. It is not a limiter and shares no code with one.

17.3.8 Choosing

Choosing between the three
Can the caller wait, and is its count bounded by you?
Wait
Is the caller count set by inbound traffic?
Allow, or Reserve
Does the caller have a deadline it can reason about?
Reserve, and compare Delay()
Are you shedding load?
Allow — the only one that cannot make a caller slow
Do you need the delay value itself?
Reserve, and cancel on every early return

17.3.9 Common Mistakes

Wait where concurrency is inbound traffic
Problem

Perfect rate, unbounded goroutines, OOM later

Fix

Allow to shed, or Reserve to decide

Assuming a Wait failure means the budget is spent
Problem

Fallbacks never fire; the caller has time left

Fix

It short-circuits; use the returned slack

NewLimiter(r, 0) from unset config
Problem

100% rejection, reported only on the shed path

Fix

Validate limit and burst at construction

Reserve() on a path that can return early
Problem

Achieved rate below configured, silently

Fix

defer the Cancel, cleared on the used path

Expecting Cancel to always restore fully
Problem

Drift under heavy reservation churn

Fix

It restores “as much as possible”; cancel promptly

Looking for ReserveContext
Problem

There isn’t one

Fix

Compare r.Delay() to your budget yourself

Fearing cancelled Wait calls leak budget
Problem

Complexity added for a non-problem

Fix

Measured: 300 cancellations cost nothing

One shared limiter on a high-fan-in path
Problem

The limiter is the bottleneck

Fix

Shard by key; measure, because cardinality decides

Summary: Allow, Reserve, Wait: Choosing How to Fail

Three methods, three dispositions: Allow rejects, Wait waits, Reserve quotes a price and hands the decision back. Reserve is the least used and often the right one when the caller has a deadline it can reason about.

Wait is an unbounded queue whenever the caller population is unbounded. Chapter 14 measured a parked goroutine at 2.7 KB; the difference here is that arrivals decide the count. It converts a rate problem into a memory problem.

Wait fails without waiting in two ways: the deadline short-circuit, which returns the caller’s budget so a fallback can use it, and the dead limiter — NewLimiter(r, 0) refuses everything forever, reported only on the shed path.

Reserve consumes on reservation. Measured, ten abandoned reservations put the limiter ten tokens in debt and made the next caller wait 110 ms instead of 10 ms.

But Wait cleans up after itself, measured: three hundred cancelled waits cost nothing. That asymmetry — Wait cancels for you, Reserve makes you do it — is the reason to prefer Wait unless you need the delay value. Wait is also strictly FIFO, on every run.

And a shared limiter is one mutex: 180.3 ns/op against 10.9 unshared, 16.5× for the privilege of being shared — and a keyed map that takes a write lock to do a read gives most of that back for one line (§17.3.6).

Self-Check Questions: Allow, Reserve, Wait: Choosing How to Fail

A handler calls limiter.Wait(r.Context()). Traffic triples. The limiter’s metrics show the configured rate held exactly, and the service is OOM-killed twenty minutes later. Reconcile those facts.

Both are true and they are the same event seen from two places.

Wait blocks until a permit is free. The limiter’s job is to hand out permits at the configured rate and it did that flawlessly — the rate metric measures the output of the limiter, which is what it was asked to hold.

What no metric on the limiter measures is the input: how many callers are currently blocked inside Wait. At 3× offered load two-thirds of arrivals park. Each costs about 2.7 KB (§14.4.4) plus its request context plus anything the handler allocated first, and the count grows for as long as the overload lasts. Twenty minutes of that is the OOM.

The fix is a different disposition, not a different rate. Allow sheds and the excess never becomes a goroutine. Reserve lets the handler compare the quoted delay against the request’s own deadline and return a 429, which is strictly more useful to the caller than being held.

The missing metric is §17.7.5's: queue depth, not just permitted rate. A limiter that reports only what it allowed cannot show you what it is holding.

Why is an abandoned Reserve() worse than calling Allow() and ignoring the result?

Because Allow returning false costs nothing, and an abandoned reservation costs the next caller.

Allow is a question. If the answer is false, no token moved and nothing is scheduled; the limiter is exactly as it was.

Reserve is a claim. It removes tokens and books a slot at a specific future time — that is what lets it quote a delay. Abandon it without cancelling and the slot stays booked for work nobody will do.

Measured: ten abandoned reservations against a 100/s limiter left it at −10 tokens, and the next caller waited 110 ms where one arriving alone waits 10 ms. The limiter is in debt for requests that were never sent.

What makes this worse than the arithmetic suggests is where it happens. The paths that abandon reservations are early returns — a cache hit, a validation failure, a disconnected client — and those are the common paths on a healthy service. So the drift is proportional to how well things are going, it lowers the achieved rate below the configured one, and there is no error and no metric to attribute it to.

defer r.Cancel() immediately after a successful reserve, cleared on the path that actually uses it, is the discipline — and §17.3.5 is why Wait does not need it.

You shard your keyed limiter map from 64 keys to 512 and throughput barely moves — 128.5 ns to 127.6. An unshared limiter costs 10.9. Where is the missing time going, and what would you change?

Into the map’s own lock, which every request takes regardless of which key it wants.

Sharding by key relieves contention on the limiters. It does nothing about the lookup that precedes them, and that lookup is a single sync.Mutex acquired by every request in the service. Once per-limiter contention is low — which it already is at 64 keys — the remaining 127 ns is almost entirely the lookup, and adding shards cannot reduce a cost that does not shard. That is the floor the measurements walk into.

The change is to stop taking a write lock to do a read. A keyed limiter map is read-mostly by construction: an entry is created once on first sight of a key and read on every request after that. sync.RWMutex with a read-locked fast path and a double-checked write path for creation measured 49.6 ns against 128.5 — better than every sharding decision combined, from one line.

The follow-ups, in order of value: hoist the limiter pointer out once per request rather than looking it up repeatedly; shard the map itself so the lookup lock shards too; and only then worry about limiter contention.

Note the shape of the mistake, because it recurs. Sharding was not wrong — it monotonically improved. It was simply solving the part of the problem that was no longer the expensive part, which is the failure mode of optimising anything without measuring where the time actually is.

Key Takeaways

  • Allow, Reserve and Wait are the reject, decide and wait dispositions; Reserve is least used and often right
  • Wait is an unbounded queue whenever arrivals decide the caller count — ask whether the number is one you chose
  • Wait fails without waiting in two ways: the deadline short-circuit returns your budget; NewLimiter(r, 0) refuses everything forever
  • measured, ten abandoned reservations left the limiter 10 tokens in debt and cost the next caller 110 ms against 10 ms
  • measured, cancelled Wait calls cost nothing — 300 of them moved the schedule not at all
  • Wait cleans up after itself; Reserve makes you do it, which is the reason to prefer Wait
  • Wait is strictly FIFO, on every run
  • A shared limiter is one mutex: 180.3 ns/op against 10.9, 16.5× under 16-way load; RWMutex on the keyed map beats every sharding decision
Section 17.3 — in one line

The limiter’s three methods are three answers to who waits, and the shortest one to write is the one that turns overload into an out-of-memory kill.

17.4 The Semaphores You Already Have, and the One You Don’t

§17.1.1 counted four limiters already in your hands. This section is about when to reach past them, and the honest answer is: less often than the existence of a library suggests.

17.4.1 What the Channel Semaphore Cannot Say

Chapter 5 built this and it remains correct:

sem_174.go
// Illustrative snippet — not a complete program
sem := make(chan struct{}, 10)

sem <- struct{}{}        // acquire
defer func() { <-sem }() // release
doWork()

Ten permits, held by whoever is inside. It allocates once, costs a channel operation per acquire, and has no dependency. For most concurrency limiting it is the right answer, and reaching for a library instead is a step backwards.

It cannot say three things.

It cannot be cancelled. sem <- struct{}{} blocks until a permit frees, and nothing else. Under a context you write the select yourself — and then you must remember that the release belongs only on the path where the acquire succeeded:

snippet_174.go
// Illustrative snippet — not a complete program
select {
case sem <- struct{}{}:
    defer func() { <-sem }() // only after a successful acquire
case <-ctx.Done():
    return ctx.Err()         // no release — nothing was taken
}

Releasing a permit you never held quietly raises your limit by one for the life of the process, which is a bug that looks like a capacity-planning mystery.

It cannot weight. Every permit is identical, so a request that will use eight units of something and one that will use one are charged the same. When the protected resource is memory or bandwidth rather than a slot, that is the wrong model.

It does not promise fairness. The language specification guarantees nothing about the order in which blocked senders are woken. In practice the gc runtime queues them and wakes them FIFO — measured, twelve goroutines blocked on a full channel in a known arrival order woke as [0 1 2 … 11] on every trial. So the honest objection is not that callers get starved; it is that you would be depending on an implementation detail the spec does not owe you, on a toolchain that is free to change it.

§14.4.4 covers the fourth inventory item and its measurements stand: errgroup.SetLimit blocks the caller rather than the goroutine, so the goroutine is never created until there is room — 27 KiB against 25.6 MiB for ten thousand items. Not re-derived here. If you are limiting a group of tasks you launch yourself, SetLimit is still the answer and this section is not about your problem.

17.4.2 What Weight Buys

golang.org/x/sync/semaphore addresses the first two gaps. Acquire(ctx, n) takes a context and a weight, TryAcquire(n) is the non-blocking form, Release(n) returns the weight.

sem_174_2.go
// Illustrative snippet — not a complete program
sem := semaphore.NewWeighted(maxBytes)

if err := sem.Acquire(ctx, req.Size()); err != nil {
    return err // cancelled or deadline exceeded
}
defer sem.Release(req.Size())

That is a genuine improvement when the protected thing is divisible — a memory budget, a bandwidth allowance, a pool where some operations cost more than others. It is not an improvement when you are counting slots, and using it there buys the next four behaviours for nothing.

17.4.3 Measured: TryAcquire Is Starved by a Single Waiter

TryAcquire is what you reach for to shed load: ask, get an immediate yes or no, refuse if no. It is the Allow of the semaphore world.

It returns false whenever any waiter is queued, regardless of how much capacity is free.

Measured capacity 8, two units held, so six free. One goroutine queued waiting for eight:
Terminal
6 units free, a queued waiter wants 8
  a later Acquire(1) → BLOCKED (deadline exceeded)
  TryAcquire(1) → false

Six units are available. The request needs one. It is refused.

This is deliberate — it is §17.4.4's FIFO guarantee doing its job — but the consequence for load shedding is specific and severe: one blocked Acquire disables your entire fast path. Every subsequent TryAcquire returns false, so every request is shed, while the resource sits two-thirds idle. The service reports itself overloaded and its utilisation graph disagrees — and you will not find it from the outside, because Weighted exposes no accessor for its current utilisation or its waiter count. The starvation is undiagnosable unless you counted acquisitions and releases yourself, which is why §17.7.5 asks for queue depth as one of the four numbers rather than as a nicety.

TryAcquire is not “acquire if there is room”

it is “acquire if there is room and nobody is waiting.” On a semaphore mixing large and small acquisitions the second clause is the one that fires, and it fires exactly when you most wanted to shed cleanly.

17.4.4 Measured: Strict FIFO, and What It Costs

The behaviour above is not a bug, and the source says why. semaphore.Weighted maintains a FIFO waiter list, and notifyWaiters stops at the first waiter it cannot satisfy rather than stepping past it. The comment gives the reason: skipping would let a waiter with a large n be starved indefinitely by a stream of small ones.

So the package makes a choice, and it is the opposite of Chapter 5's channel:

Semaphore fairness trade-offs:
Property
Order
Large acquire
Small acquire behind a large one
Throughput under mixed sizes
HEAD-OF-LINE BLOCKING, capacity 8

A semaphore of capacity eight with two units held and six free, drawn as eight slots of which the first two are shaded. Below it a strictly first-in-first-out waiter queue: the first waiter wants eight units and cannot be satisfied, and the second wants only one and could be satisfied, but is blocked behind it. The caption explains that notifyWaiters stops at the first waiter it cannot serve, because skipping it would starve the large acquire forever.

Neither column is correct in general. If every acquisition is the same size, FIFO costs nothing and buys nothing, and you should use the channel because it is simpler. If sizes vary and the large ones matter, Weighted is buying a documented starvation guarantee — which is the real difference, since the channel’s FIFO behaviour is a property of today’s runtime rather than a promise — and charging idle capacity for it. What you must not do is choose without knowing which trade you took, because both failure modes present as “the limiter is too small”.

17.4.5 Measured: The Same Condition, Opposite Behaviour

Two packages, the same condition — a request larger than total capacity — and opposite answers.

Terminal
semaphore.Acquire(ctx, 8) on a size-4 semaphore
    → STILL PARKED after 300ms of wall clock
    → STILL PARKED after 1 HOUR of virtual time
       (synctest, §17.2.8, in zero real seconds)
rate.WaitN(ctx, 8) with burst 4
    → error, immediately: "exceeds limiter's burst 4"

rate checks and refuses. semaphore blocks until the context is done, and the source is explicit:

mu_174.go
// Illustrative snippet — not a complete program
// from x/sync/semaphore
if n > s.size {
    // Don't make other Acquire calls block on one that's
    // doomed to fail.
    s.mu.Unlock()
    <-done
    return ctx.Err()
}

The reasoning is sound: a doomed acquire must not sit at the head of the FIFO queue blocking everyone behind it, so it is parked outside the queue entirely. But read the last two lines again. Under context.Background(), done is nil, <-done blocks forever, and the goroutine is gone permanently.

That is Chapter 2's goroutine leak, inside a library you had no reason to audit, on a path requiring no mistake beyond a weight that is too large — which is what happens when the weight is a request’s byte count and somebody uploads a file bigger than your budget.

Validate the weight against the capacity yourself.

Weighted exposes no accessor for its size, so keep the number you constructed it with. Compare before acquiring and return a clean “too large” error — which is the answer the caller needed anyway. The request is not slow, it is impossible, and those deserve different errors.

17.4.6 Release Panics

Release panics with "semaphore: released more than held" on over-release. Unlike the channel semaphore, which would simply block or silently inflate the limit, this one tells you loudly. That is the better design, and one more reason to pair every acquire with exactly one deferred release on the success path only.

17.4.7 Sizing the Limit

Chapter 14 gave the starting heuristic and it stands: runtime.NumCPU() for CPU-bound work, roughly double for I/O-bound, then measure. What §17.1.3 adds is a way to derive rather than guess when you know two of the three quantities.

Derived a dependency sustains 500 requests per second and its p50 latency is 40 ms. L = λW gives L = 500 × 0.04 = 20. A concurrency limit of 20 saturates it exactly. Twenty-five queues inside it; fifteen leaves it idle.

That arithmetic converts a limit you cannot observe into two numbers you can. You rarely know the right concurrency for somebody else’s service. You frequently know its published rate limit and its latency.

The number that actually binds is almost never yours: the connection pool, the file-descriptor ceiling, the downstream’s own limit, or total memory divided by per-item footprint. And when the dependency slows, a limit derived from yesterday’s latency is wrong in the direction that hurts — §17.7.4's argument for measuring rather than configuring.

17.4.8 Choosing a Concurrency Limiter

Which concurrency limiter
Uniform slots, no cancellation needed
Chapter 5's channel
Uniform slots, cancellable
Channel plus a select on ctx.Done()
Tasks you launch yourself, in a group
errgroup.SetLimit (§14.4.4)
Divisible resource — bytes, bandwidth
semaphore.Weighted
Large acquisitions that must not starve
semaphore.Weighted, accepting the FIFO cost

17.4.9 Common Mistakes

semaphore.Weighted for uniform slots
Problem

Head-of-line blocking bought for nothing

Fix

Chapter 5's channel; simpler and faster

Releasing after a failed acquire
Problem

Limit silently grows by one per failure

Fix

defer the release inside the success branch

TryAcquire assumed to mean “if there is room”
Problem

Everything shed while capacity sits idle

Fix

It also requires an empty waiter queue

Acquire(ctx, n) with n from user input
Problem

Permanent goroutine leak under Background

Fix

Validate n against the capacity you chose

Assuming a doomed acquire errors
Problem

It blocks; only the context ends it

Fix

rate and semaphore differ here

Over-releasing
Problem

Panic: released more than held

Fix

One release per successful acquire

Guessing the concurrency limit
Problem

Dependency idle, or queueing internally

Fix

L = λW from its rate and its latency

Sizing from the p99 rather than the p50
Problem

Offered rate far above the published limit

Fix

The limit multiplies against the rate

Summary: The Semaphores You Already Have, and the One You Don’t

Chapter 5's six-line channel semaphore is still right for counting slots. It cannot be cancelled without a hand-written select, cannot weight, and is not fair — and only the last two are reasons to reach for a library.

semaphore.Weighted buys weighted acquisition and strict FIFO, and the FIFO is the source of two of its four surprises. TryAcquire returns false whenever any waiter is queued, so one blocked Acquire disables load shedding entirely while capacity sits free — measured, six of eight units available and a one-unit request refused. Head-of-line blocking is deliberate and documented: the price of a starvation guarantee Chapter 5's channel does not offer.

An Acquire for more than total capacity does not error. It parks until the context is done, which under context.Background() is a permanent goroutine leak — where rate.WaitN in the identical situation returns immediately. Two packages, one condition, opposite answers.

Release panics on over-release, which is the better failure. And the limit itself is derivable rather than guessable: L = λW from a dependency’s published rate and its sustained latency.

Self-Check Questions: The Semaphores You Already Have, and the One You Don’t

Your upload service uses semaphore.Weighted sized in bytes, acquiring len(body) per request. Nothing has changed for months. One morning every upload times out and the memory graph is flat. What happened?

Somebody uploaded a file larger than the total budget, and that goroutine is now parked forever, disabling every subsequent request.

Two mechanisms in order. First, Acquire(ctx, n) with n greater than the semaphore’s size does not return an error — it parks on the context’s done channel. With a request deadline it eventually unparks; under context.Background() it never does, and the goroutine is leaked.

Second, and this is what breaks everyone else: that oversized acquire is waiting, so the waiter queue is non-empty. Every later TryAcquire now returns false regardless of free capacity, and every later Acquire queues behind it. The semaphore is fully available and refusing everything.

The flat memory graph is the diagnostic and the reason this is worth recognising. An overloaded service shows memory pressure; this one shows none, because it is not doing any work. Saturation symptoms without resource use should send you to the waiter queue.

The fix is to validate n against the capacity before acquiring and return a clean “too large” error. The request is not slow, it is impossible, and the caller needs to know the difference.

Six of eight permits are free and TryAcquire(1) returns false. Is this a bug? Justify the design.

Not a bug — it is the visible cost of a guarantee, and worth understanding before you decide you did not want it.

Weighted maintains a strict FIFO waiter queue, and notifyWaiters stops at the first waiter it cannot satisfy rather than stepping over it. A waiter needing eight with only six free blocks everything behind it, including a request needing one.

The alternative — skip the blocked waiter, satisfy the small one — is higher throughput and starves the large acquisition indefinitely under any steady stream of small ones. On a semaphore protecting a memory budget that means the biggest request never runs, which is usually the one that matters most.

So the package chose the starvation guarantee and pays in idle capacity. Chapter 5's channel makes the opposite trade — unspecified wake order, higher throughput, no guarantee for large waiters — except it cannot express a large waiter at all, since every permit is one unit.

The decision rule follows. Uniform sizes: the FIFO costs nothing and buys nothing, so use the channel. Mixed sizes where the large ones matter: Weighted, knowing TryAcquire refuses while capacity is free, and that load shedding therefore needs a different signal — a queue-depth check, or an Allow on a separate rate limiter.

You need a concurrency limit for a dependency published at 500/s. Its p50 latency is 40 ms and its p99 is 400 ms. What limit do you set, and what breaks if you use the p99?

Twenty, from the p50: L = λW = 500 × 0.04 = 20.

Using the p99 gives 500 × 0.4 = 200, and it breaks in the direction that is hard to see. A limit of 200 allows 200 in flight, but the dependency sustains only 500/s — so at p50 latency, 200 in flight is an offered rate of 5,000/s, ten times its published limit. You will be throttled or you will overload it, and either way the excess queues inside somebody else’s service where you can neither see it nor shed it.

You size for the latency you expect to sustain, not the worst case, because the concurrency limit multiplies against the rate. Sizing from the p99 is like sizing a thread pool by the slowest request ever recorded.

The harder half is that the p50 moves. When the dependency degrades to a 400 ms p50, your limit of 20 becomes an offered rate of 50/s and your own queue grows — §17.1.3's failure, now on your side. A static limit derived from a static latency is correct only while the latency holds, which is §17.7.4's argument for measuring the limit continuously instead of deriving it once.

Key Takeaways

  • Chapter 5's channel semaphore is still right for uniform slots; reach past it only for weights or fairness
  • Cancellable acquire is a hand-written select, and the release belongs only on the success path
  • measured, TryAcquire means “room and no waiters” — one blocked Acquire disables load shedding with six of eight units free
  • Head-of-line blocking is deliberate: FIFO buys a starvation guarantee and pays in idle capacity
  • measured, Acquire(ctx, n>size) parks instead of erroring — a permanent leak under Background, where rate.WaitN errors immediately
  • Release panics on over-release, which is the better failure
  • Derive the limit with L = λW from the dependency’s published rate and its sustained latency, never its p99
Section 17.4 — in one line

The library semaphore buys you weights and fairness, and charges you a fast path that stops working the moment anyone is waiting.

17.5 Backpressure: Whose Queue Is It

§17.1.6 established that the queue exists whether or not you build one. This section is about what its size actually buys.

17.5.1 Measured: Capacity Is a Latency Budget

Chapters 5 and 7 both said to bound the queue and both left the number to judgement. Here is what the number does.

Measured a producer offering 500 requests per second to a consumer serving 400 per second — sustained overload, which no buffer can fix. Callers give up after 250 ms. Four seconds of load, varying only the buffer capacity. served is what the consumer finished; useful is what it finished while a caller was still there.
Terminal
offered 500/s, drained 400/s, caller deadline 250ms, 4s run
  cap served useful rejected depth reached wait at depth
   10 1610 1610 390 10 (full) 25ms
   50 1650 1650 350 50 (full) 125ms
  100 1700 496 300 100 (full) 250ms
  500 2000 496 0 400 1.0s
THROUGHPUT UP, GOODPUT DOWN

A bar chart of four buffer capacities against work served and work that was still useful when it completed. At capacity ten, 1610 served and all 1610 useful; at fifty, 1650 and all useful; at one hundred, 1700 served but only 496 useful; at five hundred, 2000 served and still only 496 useful. The caption states that the consumer got busier while the service got worse, and locates the cliff at capacity equal to the deadline multiplied by the drain rate, which is one hundred.

Read the two middle columns against each other, because they move in opposite directions.

Throughput rises and goodput collapses. Raising capacity from 10 to 500 increased finished work by 24% — 1,610 to 2,000 — and cut useful work by 69%, from 1,610 to 496. The consumer got busier and the service got worse. Every additional unit of work it completed was for a caller who had already gone.

Zero rejections is the alarming row, not the good one. At capacity 500 nothing is refused, which is what a naive dashboard calls success. What actually happened is that the shedding decision moved from your code, where it was explicit and countable, to the caller’s timeout, where it is neither.

The wait is depth ÷ drain rate, exactly. 25 ms, 125 ms, 250 ms, 1.0 s. That is W = L/λ from §17.1.3, and it is not an approximation — it is what a queue draining at a fixed rate does.

Two details keep that table honest. The bottom row never filled: offered 500/s against a drain of 400/s is a net fill of 100/s, so 500 slots would need five seconds and the run is four. It reached depth 400, which is why rejected is 0 — not because 500 was enough, but because the window ended first. And the identical useful figure in the last two rows is not a transcription error: both runs cross the 250 ms deadline at the same instant, because both fill at the same 100/s and pass depth 100 at t = 1 s. After that moment every completion in either run is for a caller who has gone, so both harvest exactly the same useful window.

Which makes the crossover derivable rather than empirical. Derived: goodput collapses once queue wait reaches the caller’s deadline, which happens at a depth of deadline × drain rate. Here that is 0.25 × 400 = 100 — and 100 is the row where useful has already fallen off the cliff, because a queue at exactly that depth is serving requests whose callers are timing out as they are served.

So 100 is the break-even point, not the target: it is the ceiling you must stay under, and the largest capacity that still works in this run is 50. Size below deadline × drain rate, not at it. Beyond it, added capacity is not absorbing bursts; it is manufacturing work nobody will be waiting for.

A buffer absorbs bursts; it cannot absorb overload.

The distinction is whether the excess is temporary. A burst is a period where arrivals exceed service and then stop — the queue drains and the buffer did its job. Sustained overload never drains, so the queue sits at its capacity and every request pays the full depth as latency. Both look identical for the first few seconds, which is why “increase the buffer” so often appears to work.

17.5.2 The Deadline Is Already a Shed Signal

The cheapest rejection available costs one comparison and requires no configuration, because the caller already told you:

worker_175.go
// Illustrative snippet — not a complete program
func (w *Worker) handle(job Job) error {
    if job.Ctx.Err() != nil {
        droppedExpired.Inc()
        return job.Ctx.Err() // never started; nobody is waiting
    }
    return w.do(job)
}

Checking the context when work is dequeued — rather than when it is enqueued — is what turns a queue from a liability into an asset under overload. Every item that expired while waiting is discarded for the cost of an atomic load, and the capacity it would have consumed goes to an item whose caller is still there.

That single check is what would have saved the 1,504 wasted completions in §17.5.1's bottom row, and it is the mechanism behind §17.1.5's waste column. It is also why that measurement gave both arms the check: it is cheap enough that withholding it would have been rigging the comparison.

It composes with §17.3.3. rate.Limiter.Wait already refuses a permit that would arrive after the caller’s deadline. The same rule at the dequeue point, at handler entry, and before each expensive stage is the cheapest load shedding a service can do — and it requires only that deadlines are actually propagated, which is Chapter 13's argument arriving with a payoff.

17.5.3 What to Drop, and From Which End

Once the queue is full, four policies are available and they are not interchangeable.

Block the producer. Backpressure in the strict sense: the caller waits, and if it is itself serving somebody the pressure propagates upstream. Correct when the producer can afford to slow down.

Reject the newest. The default in most systems and the right default when requests are equivalent. select with a default clause is exactly this.

Drop the oldest. Counter-intuitive and frequently correct: the oldest item in a full queue is the one most likely to have exceeded its caller’s deadline already. For live data — sensor readings, price ticks, position updates — it is also the least valuable by definition.

Serve newest-first. Under sustained overload, LIFO beats FIFO on the metric that matters. FIFO serves in arrival order, so once the queue wait exceeds the client timeout, everyone is late and the observed completion rate goes to zero while the server stays fully busy — which is §17.5.1's bottom row. LIFO lets recent arrivals through at full speed and starves the backlog.

LIFO under overload is not a trick.

It is unfair, deliberately, and it converts a total outage into a partial one. In a queue that is not draining, FIFO guarantees that nobody is served in time; LIFO guarantees that somebody is. That is a §14.7 degradation choice, made when the alternative is serving nobody at all — and it is one of the few places where the fair policy is the one that fails completely.

The first two are one channel operation apart, which is worth seeing side by side because the difference is a single default clause:

snippet_175.go
// Illustrative snippet — not a complete program
// REJECT THE NEWEST. The default clause is the whole policy.
select {
case q <- item:
default:
    rejected.Add(1) // the caller finds out
}

// DROP THE OLDEST. Make room, then take the slot. The second send
// cannot block: this goroutine is the only producer, and it just
// freed a slot.
select {
case q <- item:
default:
    select {
    case <-q: // discard the head -- most likely already too late
        dropped.Add(1)
    default:  // drained by the consumer in between; room now
    }
    q <- item
}

Drop-the-oldest needs a single producer, or the freed slot is a race: two producers can both free one slot and then both try to take it, and one of them blocks on a queue it believed had room.

17.5.4 The Queue at Shutdown

§15.5.3 drew a line between work that is in flight and work that is queued, and §15.5.5 argued that abandoning the backlog is sometimes correct. This chapter’s waiters sit exactly on that line, and the book has not yet asked which side they belong to.

A goroutine parked in limiter.Wait(ctx) or sem.Acquire(ctx, n) has been admitted by your admission control and not started by your handler. It holds a promise but no resource. When SIGTERM arrives, is it in-flight work a graceful shutdown must finish, or backlog to abandon?

The answer comes from §15.1's four promises rather than from the mechanism. The promise is that accepted work finishes — and a request blocked at a limiter has been accepted in the only sense the client can observe: it is holding a connection and waiting for an answer. So it is in-flight from the client’s side and queued from yours, and the two genuinely disagree.

Three consequences, in the order they bite:

The waiters must be cancelled, not merely the handlers. If shutdown cancels the request tree, Wait(ctx) and Acquire(ctx, n) return immediately with ctx.Err() and the goroutines unwind. If the limiter was given a context that is not derived from the request — a common shape, since limiters are long-lived objects and reaching for a long-lived context feels consistent — nothing cancels them, and Shutdown waits for handlers that are waiting for permits a draining service will never issue.

A drain stalled at a limiter looks exactly like a deadlock. It is §10.2's shape with a deadline attached, and kill -QUIT reads it identically: every stack ending in rate.(*Limiter).WaitN or semaphore.(*Weighted).Acquire names the culprit immediately. §15.7.5 recommends the technique for precisely this.

Whether to drain them is a policy decision with a right default. Cancel them, and answer each with a 503. A client that has been waiting at your limiter through a shutdown has almost certainly exceeded its own deadline, and §15.5.5's test applies unchanged: abandon the backlog when the work has another copy — and a queued request behind a load balancer always does.

17.5.5 Where Backpressure Stops

Backpressure propagates by making the producer wait. That works while the producer is a component you control, and it stops working entirely at the edge.

You cannot make the internet wait. A browser, a mobile client, a third-party integration — none of them respond to a slow server by sending less. Most respond by sending more, because a timeout triggers a retry, which is §17.6.

So the boundary rule is: internally, block. A pipeline stage, a worker pool, a batch reader can all be slowed by a full channel, and that propagation is the whole value of an unbuffered channel — Chapter 7's argument. At the edge, reject. Return a status the client can act on, with a Retry-After if you can estimate one.

Holding an inbound request to apply backpressure to something that will not feel it converts a fast rejection into a slow one and adds a parked goroutine for the privilege. The service boundary is where the four dispositions stop being interchangeable: inside, wait is usually right; outside, reject almost always is.

17.5.6 Common Mistakes

Sizing a buffer to stop rejections
Problem

Zero rejections, goodput collapsed

Fix

Cap at deadline × drain rate

Reading throughput as health
Problem

Consumer busy, callers all timing out

Fix

Measure goodput: finished for someone

Growing the buffer under sustained overload
Problem

Latency grows, useful work falls

Fix

Buffers absorb bursts, never overload

Checking the deadline only on enqueue
Problem

Capacity spent on callers who left

Fix

Check again at dequeue; it is an atomic load

FIFO under sustained overload
Problem

Server busy, observed completion near zero

Fix

Serve newest-first as a deliberate degradation

Blocking an inbound request for backpressure
Problem

Slow rejections plus parked goroutines

Fix

Reject at the edge; block only internally

Limiter context not derived from the request
Problem

Shutdown waits for permits nobody will issue

Fix

Derive it, or cancel waiters explicitly

Summary: Backpressure: Whose Queue Is It

Capacity is a latency budget, and the measurement is unambiguous. Raising a buffer from 10 to 500 slots against sustained overload raised finished work 24% and cut useful work by 69%, while rejections fell to zero — the row that looks like success is the one where the shedding decision moved from your code to the caller’s timeout. Wait is depth ÷ drain rate exactly, which makes deadline × drain rate a break-even ceiling to stay under rather than a target — here the largest capacity that still worked was 50 against a break-even of 100.

The cheapest shed signal is the caller’s own deadline, checked at dequeue rather than enqueue — one atomic load, and it is what turns a queue into an asset under overload.

Four policies decide who loses. Drop-oldest and serve-newest-first are the counter-intuitive ones that are frequently correct, because under a queue that is not draining, the fair policy is the one where nobody is served in time.

Waiters parked at a limiter sit on §15.5.3's in-flight/queued line: accepted by the client’s reckoning, queued by yours. Derive their context from the request, cancel them at shutdown, answer with a 503 — and know that a drain stalled at a limiter reads as a Chapter 10 deadlock in a goroutine dump.

And backpressure stops at the service boundary: block internally, reject at the edge, because the internet does not slow down when you do.

Self-Check Questions: Backpressure: Whose Queue Is It

Your queue’s capacity is 500, the consumer drains 400/s, and callers time out at 250 ms. Without running anything, what fraction of completed work is useful, and what capacity should you have chosen?

Almost none of it is useful, and the capacity should have been 100.

Queue wait is capacity ÷ drain rate = 500/400 = 1.25 seconds. Callers give up at 250 ms. So a request that enters a full queue waits five times its caller’s deadline before the consumer even starts it — and every one of those completions is work finished for somebody who left a second earlier.

Break-even is deadline × drain rate = 0.25 × 400 = 100, the depth at which queue wait exactly equals the caller’s deadline — so that is a ceiling to stay under rather than a target to hit. The largest capacity that still worked here was 50, which delivered 1,650 useful completions out of 1,650 finished.

At capacity 100 the cliff has already happened: 496 useful out of 1,700 finished. At 500 it is the same 496 out of 2,000, because both runs cross the deadline at the same instant. The extra 300 completions were pure waste.

The reason this is worth being able to do on paper is that the symptom is invisible from throughput alone. The consumer looks busier at capacity 500 — 2,000 finished against 1,610 — and a dashboard measuring completions rather than completions-with-a-caller will show the larger buffer as an improvement.

Under sustained overload, why can serving newest-first be better than serving in arrival order?

Because in a queue that is not draining, FIFO guarantees that nobody is served in time, and LIFO guarantees that somebody is.

FIFO serves the item that has waited longest. Once the queue wait exceeds the client timeout, that item’s caller has always already left — so the server runs at 100% utilisation and its observed completion rate is zero. It is maximally busy and completely useless, which is §17.5.1's bottom row.

LIFO serves the freshest arrival, which is the one most likely to still have a caller attached. The backlog starves, permanently and unfairly, and that is the actual cost: some requests never get served at all rather than all requests getting served late.

So it is a deliberate choice to convert a total outage into a partial one, which is §14.7's degradation lens applied to ordering. It is right when late work is worth nothing — a request whose caller has gone — and wrong when late work still has value, like a job queue whose results are written to storage and read tomorrow.

The honest framing is that it is unfair by design. That is not a side effect to apologise for; it is the mechanism.

Your shutdown hangs. kill -QUIT shows two hundred goroutines whose stacks all end in semaphore.(*Weighted).Acquire. What went wrong, and which chapter’s rule was broken?

The semaphore’s context is not derived from anything the shutdown cancels, so nothing will ever release those goroutines.

The sequence: SIGTERM arrives, the server stops accepting and calls Shutdown, which waits for in-flight handlers. Those handlers are blocked in Acquire. If the context came from the request, cancelling the request tree unblocks them immediately and the drain completes. If it came from context.Background() — easy to write, because the semaphore is a long-lived object and a long-lived context feels consistent with it — then the only thing that can release them is a permit, and permits come from work completing, and no work is completing because the service is draining.

The broken rule is Chapter 13's: a context is derived from the operation whose lifetime it belongs to, and that operation is the request, not the semaphore.

The goroutine dump identifies it instantly for §10.2's reason — two hundred stacks converging on one line is not ambiguous. A drain stalled at a limiter is a deadlock with a deadline attached, which is exactly why §15.7.5 reaches for kill -QUIT in this situation.

Key Takeaways

  • measured, raising capacity 10 → 500 raised finished work 24% and cut useful work 69%, with rejections falling to zero
  • Zero rejections means the shedding decision moved from your code to the caller’s timeout
  • Wait is depth ÷ drain rate exactly, so deadline × drain rate is the break-even ceiling — stay below it rather than at it
  • Measure goodput, not throughput: work finished for someone still waiting
  • The caller’s deadline checked at dequeue is the cheapest rejection there is
  • Under a queue that is not draining, FIFO serves nobody in time; LIFO is a deliberate, unfair degradation
  • Waiters at a limiter are accepted by the client and queued by you — derive their context from the request and cancel them at shutdown
  • Block internally, reject at the edge: the internet does not slow down when you do
Section 17.5 — in one line

You never choose whether to queue, only where — and the capacity you type is a latency you will meet later without recognising it as yours.

17.6 Retries, Amplification, and the Breaker That Stops Them

§17.1.5 showed ten retries pushing 259–273 of 300 requests through while burning a third of the downstream — admission control rediscovered badly. This section measures what retries actually do, and then builds the mechanism that refuses before it waits.

17.6.1 Measured: What Retries Actually Amplify

The usual claim is that retries cause collapse. The measurement says something narrower and more useful.

Measured 1,200 clients arriving at 600/s, against a downstream with no admission control of its own, whose service time degrades linearly once more than ten calls are in flight — the shape of a thread-per-request server, a connection pool that starts swapping, or any resource with contention. Per-attempt deadline 200 ms, total budget 800 ms.
Terminal
  admission tries served offered wasted peak in flight
  none 1 510 1200 57% 219
  none 3 511 1724 70% 653
  cap 10 1 540 1100 51% 10
  cap 10 3 540 1311 59% 10
WHERE THE EXTRA ATTEMPTS GO

Two columns comparing peak concurrency with and without admission control. Without it, one attempt per request peaks at 219 concurrent and three attempts peaks at 653 -- three times the concurrency for one extra request served. With admission control both peak at ten, because the retries are absorbed at the permit. The caption states that retries multiply offered load, and that the bound is what decides whether the multiplied load costs anything.

Read the first two rows against each other. Three attempts instead of one raised offered load by 44% and raised served work by one request. What it actually changed was peak concurrency: from 219 to 653, three times the in-flight count, for no additional goodput at all. That is amplification with nothing to absorb it — and on a downstream whose service time degrades with concurrency, tripling the in-flight count is how a slowdown becomes an outage.

Now read the bottom two rows. With a cap of ten, retries change peak in-flight not at all: it is pinned at exactly 10, in both rows. The extra attempts wait at the permit instead of piling into the resource that degrades.

The waste column here does not go to zero, and it is worth saying why, because §17.1.5's limited arm did. These are different overload regimes. There, the offered load fitted inside the budget once admission was serialised, so every caller was eventually served and nothing was wasted. Here, 600 arrivals per second against roughly 500 per second of capacity is overload the limiter cannot resolve — callers still time out, and work still completes for callers who have left. A concurrency cap bounds concurrency; it does not create capacity, and when offered load genuinely exceeds capacity the residual waste is the price of the overload rather than of the retries. What the cap removed is the amplification: the peak, and with it the degradation that made each call slower than the last.

So the honest statement is narrower than “retries cause collapse”:

Retries multiply offered load; admission control decides whether that costs anything.

Against a bounded downstream the extra attempts are refused cheaply and concurrency does not move. Against an unbounded one they triple the in-flight count and buy nothing. The retries were never the disease or the cure — the missing bound was.

That reframing matters because it points at the fix. Telling clients to retry less is asking other people to protect you, and they will not. Bounding your own concurrency works regardless of what clients do.

17.6.2 Three Things That Make Retries Survivable

You will still retry, because a network that drops one packet should not fail a request. Three things make it safe.

A budget, not a count. “Retry three times” is a per-request rule that says nothing about aggregate load. A retry budget caps retries as a fraction of total requests — conventionally 10% — over a rolling window. Healthy, the budget is never reached and retries work normally. Under widespread failure it is exhausted immediately and retries stop, which is exactly the behaviour you want and the one a per-request count cannot express.

Full jitter, not just backoff. Exponential backoff without randomness synchronises clients. A thousand clients failing together retry together, then again at 2×, 4×, 8× — load arriving in spikes that each re-trigger the failure:

backoff_176.go
// Illustrative snippet — not a complete program
// Full jitter: sleep uniformly in [0, backoff), not backoff.
backoff := min(base<<attempt, maxBackoff)
time.Sleep(time.Duration(rand.Int63n(int64(backoff))))

A uniform draw over the whole interval, rather than a small perturbation of it, is what actually decorrelates the herd.

A way to be told no. Retry-After on a 429 or 503 is the server telling the client what it knows and the client does not: how long the condition will last. Honouring it is cheaper than any backoff algorithm because it is not a guess.

And the retry must be safe to send twice. Chapter 15 named this from the other end: a client seeing a connection reset knows the request failed and does not know whether the work failed. If the operation is not idempotent, a retry is a correctness bug rather than a performance one, and no budget fixes it.

17.6.3 The Breaker: Refusing Before You Wait

Every mechanism so far decides admission from capacity. A circuit breaker decides from history: it stops calling a dependency that has been failing, on the theory that the next call will fail too and the attempt is pure cost.

That makes it the only thing in this chapter that refuses before it waits.

CIRCUIT BREAKER

The three states of a circuit breaker and the transitions between them. Closed passes all calls and counts failures; when failures exceed the threshold it moves to Open, which fails all calls immediately. After a cooldown it moves to Half-Open, which admits exactly one call. If that probe succeeds the breaker returns to Closed; if it fails the breaker returns to Open.

Closed is normal operation with accounting. Open is the useful state: calls fail instantly, without a connection, a timeout, or a permit. Half-open is the recovery test.

There is no circuit breaker in the standard library and none in golang.org/x/. You assemble this one, which makes the three design decisions yours — and each is where implementations go wrong.

17.6.4 Half-Open Must Admit Exactly One

Half-open exists to answer one question: has the dependency recovered? Answering it takes exactly one request. Admitting more re-hammers a service that is, by hypothesis, fragile — and a dependency emerging from an outage with a cold cache and an empty connection pool is at its most fragile precisely then.

The naive implementation admits everything that arrives during half-open, which under load is thousands of requests, which knocks the recovering service over, which reopens the breaker. That oscillation is stable and can outlast the original fault by hours.

Admitting exactly one is a compare-and-swap:

breaker_176.go
// Illustrative snippet — not a complete program
const (
    stateClosed int32 = iota
    stateOpen
    stateProbing // exactly one caller holds this
)

// state is an atomic.Int32 (§11.1.2), not a bare int32 behind the
// legacy package-level functions.
func (b *Breaker) allow() bool {
    switch b.state.Load() {
    case stateClosed:
        return true
    case stateOpen:
        if time.Since(b.openedAt()) < b.cooldown {
            return false
        }
        // cooldown elapsed: try to become the prober
        return b.state.CompareAndSwap(stateOpen, stateProbing)
    default:
        return false // somebody else is probing
    }
}

Chapter 11 said CompareAndSwap is the tool when the operation is “change this value if it still looks like that”, and this is the cleanest example in the book: many goroutines observe the expired cooldown in the same instant, exactly one wins the swap, and the losers are refused without a lock.

Chapter 11 also named what atomics cannot express — a state change carrying a side effect — and this breaker is that shape too. The transition from probing back to closed must reset the failure counters, and resetting counters does not fit inside the swap. So the state machine is a CAS for the admission decision and a mutex for the transition with bookkeeping attached. That is not a compromise; it is §11's rule read correctly.

17.6.5 Measured: What Counts as a Failure

This is the most important paragraph in the chapter.

A breaker needs a predicate: given the outcome of a call, was that a failure? The obvious answer is the wrong one.

err_176_x_1.go
// Illustrative snippet — not a complete program
err := downstream.Call(ctx)
b.Record(err) // ✗ ctx.Err() is an error too

The context passed to Call belongs to your caller. If they gave up — a mobile client on a bad network, an upstream service shutting down, a user closing a tab — then Call returns context.Canceled or context.DeadlineExceeded, and that is an error. It is not a downstream error. Nothing whatsoever has been learned about the downstream’s health.

Feed it to the breaker anyway and you have built a device that converts other people’s impatience into your own outage.

Measured a downstream taking 20 ms that never fails, throughout. Two hundred callers with 5 ms deadlines — impatient, but perfectly ordinary — followed by two hundred callers with no deadline at all. Breaker: minimum 20 requests, 50% threshold.
What the predicate costs, against a downstream that never fails:
failure predicate
err != nil
downstream errors only

A 100% availability loss against a downstream that did not return a single error. The first wave never even reached a failure — those two hundred requests were still running happily when their callers walked away. The breaker recorded two hundred “failures”, opened, and refused every subsequent request for a full cooldown.

The fix is one condition, and the interesting part is what it tests:

err_176_2.go
// Illustrative snippet — not a complete program
err := c.Call(ctx)
// Two questions, and one check cannot answer both.
// 1. Did we refuse this ourselves? Then it is not evidence
//    about them -- and ctx.Err() is still nil when our own
//    limiter short-circuits (§17.3.3), so this must come first.
if errors.Is(err, ErrShed) || errors.Is(err, ErrBreakerOpen) {
    return err
}
// 2. Was our caller already gone? Then the error is theirs.
if ctx.Err() == nil {
    b.Record(err)
}
return err

Two clauses, and the order matters. The second is the one people miss, so it gets the emphasis: the test is on ctx, not on err. The near-miss version — if !errors.Is(err, context.Canceled) — is close and wrong in a case that matters: a downstream enforcing its own internal deadline and returning DeadlineExceeded from it really has failed, and you want that counted. Testing ctx.Err() asks the right question, which is “was this our caller’s fault?” rather than “does this error look like a cancellation?”.

The first clause is easy to skip and §17.3.3 is the trap that punishes it. rate.Limiter.Wait refuses a permit that would arrive after the caller’s deadline — and it refuses before that deadline passes, so ctx.Err() is still nil at the moment the error is returned.

Measured Wait returned "would exceed context deadline" with ctx.Err() == nil. A predicate that checks only the context therefore records your own admission control as a downstream failure, which is precisely the row the classification table below forbids. ctx.Err() answers “was our caller already gone?”; nothing about the context can answer “did we refuse this ourselves?”

And you can do this to yourself twice over. §15.4 cancels the request tree during a graceful shutdown; §15.2 cancels it when a signal arrives. Every in-flight call returns context.Canceled at once. A breaker whose state outlives the process — persisted, or shared across a pool not being torn down — opens everything it owns as its last act.

This is §14.1's thesis arriving for the third time. Chapter 14 said the failure mode of concurrent error handling is silence rather than a crash. §15.4.4 found it at the process boundary as a 200 OK with a truncated body. Here it is at the dependency boundary, as a breaker opening against a healthy service — and in all three cases the system reports success at every layer that has a dashboard.

The classification table is the design.

Write it down and keep it next to the breaker: caller cancelled → not a failure; caller deadline exceeded → not a failure; your own limiter refused → not a failure; downstream 5xx → failure; downstream timeout on its own budget → failure; connection refused → failure; downstream 4xx → not a failure, because your request was wrong and neither retrying nor tripping helps anybody. That last row surprises people, and a breaker that opens because clients are sending malformed input is the same bug in a different costume.

17.6.6 Trip on Rate, With a Minimum

The second decision is what opens the breaker. The obvious answer — a count of consecutive failures — is wrong in both directions at once.

Consecutive-failure counting is traffic-dependent. At five failures, an endpoint receiving one request a minute needs five minutes of total failure to trip. An endpoint receiving ten thousand requests a second trips in half a millisecond, during a blip that would have cleared, from a dependency at 99.95% success.

The fix is to trip on error rate over a rolling window, which is traffic-independent by construction. But a rate alone has a hole at the bottom: the first request after a deploy fails, the rate is 100%, and the breaker opens on a sample of one.

So a usable predicate has two clauses:

Terminal
open when: requests_in_window >= minRequests
       and: failures / requests >= threshold
Illustrative typical production values are a 10-second rolling window, minRequests of 20, and a threshold of 50%. The window must be long enough to accumulate minRequests at your quietest traffic level — otherwise the breaker never trips on low-traffic endpoints, which is usually fine, and never trips on endpoints that are quiet because they are already broken, which is not.

17.6.7 What a Breaker Does Not Fix

It does not reduce load on a healthy service. A closed breaker is a counter. If your dependency is slow rather than failing, the breaker never opens and never helps; that is §17.3's job.

It makes an optional dependency worse. §14.7 distinguished critical from optional. Wrapping an optional dependency in a breaker converts occasional slow responses into a guaranteed absence for the whole cooldown. If the right behaviour on failure is to degrade, degrade — a breaker adds a state machine and removes the chance that the next call might work.

It hides the fault. An open breaker means your error rate is generated locally, the dependency’s dashboards go quiet, and the incident looks like it is in your service. Emit a distinct metric and error type for breaker-refused calls, or you will spend the outage debugging the wrong system.

It cannot be your only protection. A breaker responds to failures that have already happened. Everything else in this chapter is about not causing them.

17.6.8 Common Mistakes

b.Record(err) with no classification
Problem

Cancellation storm opens a healthy dependency

Fix

Test ctx.Err() == nil, not the error’s shape

errors.Is(err, context.Canceled) as the test
Problem

Downstream’s own deadline stops counting

Fix

Test the context; it asks whose fault it was

Counting your own limiter’s refusals
Problem

Load opens the breaker; dependency is fine

Fix

Exclude admission-control errors explicitly

Counting downstream 4xx
Problem

Malformed client input opens your breaker

Fix

Your request was wrong; tripping helps nobody

Half-open admits every waiting request
Problem

Recovering service knocked over; oscillation

Fix

One probe, via CompareAndSwap

Tripping on consecutive failures
Problem

Quiet endpoints never trip; busy ones trip on blips

Fix

Error rate over a window, plus minRequests

No minimum request floor
Problem

Opens on the first failure after a deploy

Fix

Require a sample before the ratio means anything

Retry count instead of a retry budget
Problem

Aggregate load unbounded under wide failure

Fix

Cap retries as a fraction of requests

Backoff without full jitter
Problem

Synchronised spikes that re-trigger the failure

Fix

Uniform draw over the whole interval

Breaker on an optional dependency
Problem

Guaranteed absence where degradation was fine

Fix

Degrade (§14.7); skip the state machine

Retrying a non-idempotent operation
Problem

Duplicated side effects, not a latency problem

Fix

Idempotency keys, or do not retry

Summary: Retries, Amplification, and the Breaker That Stops Them

Retries do not cause collapse by themselves.

Measured three attempts instead of one raised offered load 44% and served work by a single request, while tripling peak in-flight from 219 to 653. With a cap of ten, the same retries moved peak concurrency not at all — it stayed pinned at exactly 10. Retries multiply offered load; admission control decides whether that costs anything, which means the fix is your bound rather than your clients' manners.

Safe retrying needs a budget rather than a count, full jitter rather than plain backoff, a Retry-After you honour, and an operation that is safe to send twice.

A circuit breaker is the only mechanism here that refuses before it waits, and you assemble it yourself. Half-open must admit exactly one probe — CompareAndSwap, Chapter 11's cleanest use — because admitting the backlog knocks a recovering dependency over and the oscillation outlasts the fault. Trip on error rate over a window with a minimum-request floor, never on consecutive failures.

And the predicate is where breakers become outages.

Measured with err != nil, two hundred impatient callers opened the breaker and the next two hundred ordinary requests were all refused — a 100% availability loss against a downstream that never returned an error. The fix tests ctx.Err(), not the error, because the question is whose fault it was rather than what the error looks like.

Self-Check Questions: Retries, Amplification, and the Breaker That Stops Them

Your breaker opens during every traffic spike. The dependency’s dashboards show no errors and normal latency throughout. Name the two most likely causes and how to tell them apart.

Both are misclassification, and they differ in whose error is being counted.

The first is caller cancellation. A spike raises latency, callers hit their own deadlines, their contexts are cancelled, and the calls return context.Canceled or DeadlineExceeded. With a predicate of err != nil, every one is recorded as a downstream failure. The dependency answered fine, or would have — measured, that mechanism cost 200 of 200 requests against a downstream with a zero error rate.

The second is your own admission control. Under a spike, rate.Limiter.Wait starts refusing permits whose delay exceeds the caller’s deadline (§17.3.3), returning an error your code generated about your own configuration. Counted as a failure, the busier you get the more convinced the breaker becomes that the dependency is broken — and it never touched the dependency at all.

To separate them, count the classes before you count the total. Export a counter keyed by error class: dependency_error, caller_cancelled, limiter_refused, breaker_refused. If the spike shows caller_cancelled climbing it is the first; if limiter_refused, the second. Without that breakdown both look identical, which is exactly why it is worth having before the incident rather than during it.

The fix for both is the same shape: the predicate needs access to the context and to your own sentinel errors, not just to error.

Why must half-open admit exactly one request rather than, say, 10% of traffic?

Because the question half-open asks needs a sample size of one, and any larger sample is load applied to the least robust thing in the system.

A dependency emerging from an outage is at its most fragile: caches cold, connection pools empty, JITs unwarmed, and whatever caused the failure possibly only partly resolved. Ten percent of production traffic is not a probe, it is a load test — and a load test is what knocked it over in the first place.

The failure mode is a stable oscillation. Cooldown expires, 10% floods in, the dependency falls over, the breaker reopens, cooldown expires. Each cycle re-injures the thing it is testing, so it persists long after the original fault is gone — and from outside it looks like a flapping dependency rather than a caller preventing recovery.

One request answers the question at minimum cost. Succeed: close, and let real traffic ramp naturally. Fail: reopen and wait, having spent exactly one request to learn it.

The implementation detail matters as much as the policy. “One at a time” under concurrency is a CompareAndSwap into a probing state, not a check followed by a set — many goroutines observe the expired cooldown in the same instant, and only the atomic swap makes exactly one of them the prober.

Three retries raised offered load by 44% and served work by one request. Where did the extra load actually go?

Into concurrency, which is the column that matters and the one nobody watches.

Peak in-flight went from 219 to 653 — roughly tripled — while served stayed flat at about 510. The extra attempts did not add goodput because goodput was already bounded by the downstream’s capacity, and no client behaviour adds capacity. What they added was simultaneous pressure on a resource whose service time degrades with concurrency.

That is what makes it a spiral rather than merely waste. Higher concurrency means slower service, slower service means more attempts time out, more timeouts mean more retries, and the in-flight count climbs again. Each turn of the loop makes the next turn worse, which is why an overloaded service with retrying clients does not settle at a degraded equilibrium — it keeps going.

The measurement also shows the fix, and it is not the retries. With a cap of ten, the identical retry behaviour left peak in-flight at exactly 10 in both the 1-try and 3-try rows. The extra attempts were refused at the permit, cheaply, and never reached the thing that degrades.

Which is the section’s point: you cannot make your clients retry politely, and you do not need to. Bounding your own concurrency makes their behaviour stop mattering.

Key Takeaways

  • measured, three retries raised offered load 44% and served work by one request, while tripling peak in-flight from 219 to 653
  • measured, with a cap of ten, the same retries left peak concurrency at exactly 10 — the bound, not the retry policy, is the fix
  • Budgets not counts, full jitter not plain backoff, honour Retry-After, and never retry a non-idempotent operation
  • A breaker is the only mechanism that refuses before waiting, and you assemble it yourself
  • Half-open admits exactly one probe via CompareAndSwap; admitting the backlog produces an oscillation that outlasts the fault
  • Trip on error rate over a window with a minimum-request floor; consecutive counts are traffic-dependent in both directions
  • measured, err != nil cost 200 of 200 requests against a downstream that never failed once
  • Test ctx.Err(), not the error’s shape — the question is whose fault it was, not what the error looks like
Section 17.6 — in one line

Retries multiply load and admission control decides whether that costs anything — and a breaker built on err != nil spends your availability punishing a service that never failed.

17.7 Where the Limit Lives

Every number so far has been treated as though writing it down were the end of the job. It is not. A limit is enforced somewhere, by some number of processes, over some population of callers, and each of those choices changes what the number means.

17.7.1 The Limit You Configured Is Not the Limit

limiter_177.go
// Illustrative snippet — not a complete program
limiter := rate.NewLimiter(100, 10) // "100 requests per second"

That comment is true of one process. Deploy to eight replicas and the downstream sees 800 requests per second, because each replica holds its own bucket and none knows about the others.

WHAT THE DEPENDENCY ACTUALLY SEES

A per-process limiter of one hundred per second with a burst of ten, multiplied by replica count. Eight replicas present 800 per second with a burst of 80; forty replicas present 4000 per second with a burst of 400, against a documented dependency limit of 1000 per second. The annotation adds that you scaled to forty replicas precisely because the system was under strain. The caption concludes that a protective limit which scales with the thing it protects you from is not a limit.

Derived with N replicas each holding rate.NewLimiter(r, b), the aggregate enforced rate is N × r and the aggregate burst is N × b. Autoscale from 8 to 40 during a spike — precisely when the downstream is already struggling — and the limit you set to protect it becomes 4,000/s, with an instantaneous burst of 400 from the fresh buckets of the new pods.

Three honest responses.

Divide by the replica count. rate.NewLimiter(total/replicas, burst/replicas) is correct while the count is known and stable. It fails during deploys, when old and new pods overlap, and under autoscaling — which is when it matters.

Enforce at a chokepoint. If all traffic to the dependency passes through a proxy, a sidecar or a mesh, the limit belongs there, where there is exactly one of it. Usually the right answer and usually not available.

Enforce at the dependency. A server-side limit is the only inherently correct one, because the server is the thing being protected and it can count. Client-side limits are always an approximation; their value is refusing work before it costs a network round trip.

A shared bucket in Redis is the fourth option and a distributed systems problem with its own failure modes — the store’s latency on every request, its availability becoming yours, and what to do when it is unreachable. That is beyond this book. What matters here is knowing that a per-process limiter is a local approximation of a global intent, and writing the aggregate in the comment:

limiter_177_2.go
// Illustrative snippet — not a complete program
// 100/s per replica. At 8 replicas that is 800/s at the
// dependency, whose documented limit is 1000/s.
limiter := rate.NewLimiter(100, 10)

17.7.2 Keyed Limiters and the Map That Never Evicts

The standard production shape is one limiter per user, tenant or IP:

limiters_177_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: every key ever seen is retained forever
func (l *Limiters) For(key string) *rate.Limiter {
    l.mu.Lock()
    defer l.mu.Unlock()
    lim, ok := l.m[key]
    if !ok {
        lim = rate.NewLimiter(10, 20)
        l.m[key] = lim
    }
    return lim
}

Correct, and it leaks. Every distinct key ever observed is retained for the life of the process, and the key space is chosen by your callers. For user IDs that is bounded by your user count, which may be acceptable. For IP addresses it is bounded by the internet, and a scan across a /16 creates 65,536 limiters that will never be used again and never be freed.

§12.3 said this exactly: rate-limiter state cannot live in a sync.Pool, because the collector empties it and the state must survive. That leaves eviction to you, and there are three workable answers.

Sweep by last use. Record a timestamp on each access and periodically delete entries idle longer than a few multiples of the refill interval.

Bound the map. An LRU with fixed capacity gives a hard memory ceiling. The cost is that an evicted key gets a fresh full bucket on its next request, so size it well above your active-key count.

Do not key at all. For abuse prevention specifically, per-IP limiters are often the wrong tool, because the attacker chooses the key. A global limit plus a cheap per-request cost is more robust.

The reason sweeping is safe is worth stating, because it is what makes eviction free rather than a trade:

An idle limiter is indistinguishable from a fresh one.

A token bucket’s entire state is a token count and a timestamp. After burst / rate seconds of idleness it has refilled to capacity — and a newly constructed limiter also starts at capacity. Deleting it discards nothing. Delete an active limiter and you hand its owner a full bucket, which is an escape a caller can force deliberately.

The sweeper is where the deadlock goes.

A background goroutine holding the map’s write lock while it iterates blocks every request in the service for the duration of the sweep. Collect the keys to delete under a read lock, release, then delete in small batches — or shard the map and sweep one shard at a time. This is §9's lock-granularity argument arriving on a path every request takes.

In code the two phases are the whole discipline:

limiters_177_2.go
// Illustrative snippet — not a complete program
// ✓ Two phases. The scan holds a READ lock, so requests still
// proceed; the delete holds the write lock for a handful of keys
// at a time rather than for the length of the map.
func (l *Limiters) sweep(idle time.Duration) {
    cutoff := time.Now().Add(-idle)

    l.mu.RLock()
    var stale []string
    for k, e := range l.m {
        if e.last.Load() < cutoff.UnixNano() {
            stale = append(stale, k)
        }
    }
    l.mu.RUnlock() // released BEFORE any deletion

    for i := 0; i < len(stale); i += 128 {
        l.mu.Lock()
        for _, k := range stale[i:min(i+128, len(stale))] {
            // Re-check under the write lock: the key may have been
            // used again while we were not holding it, and evicting
            // an ACTIVE limiter hands its owner a full bucket.
            if e, ok := l.m[k]; ok &&
                e.last.Load() < cutoff.UnixNano() {
                delete(l.m, k)
            }
        }
        l.mu.Unlock()
    }
}

The re-check is not belt and braces. Between the two phases the key can be used again, and the callout above says what evicting a live limiter costs: a full burst, on demand, to a caller who can force it.

And §17.3.6's measurement applies here: sharding across keys spread limiter contention and concentrated a new bottleneck in the map’s own lock. The eviction strategy and the contention strategy are the same decision.

17.7.3 Client-Side, Server-Side, Per-Key

Where a limit can live:
Location
Client, per process
Client, at a chokepoint
Server, global
Server, per key

The pairing that works in practice is a server-side global limit for self-protection, a server-side per-key limit for fairness between tenants, and client-side limits as a cheap first filter that avoids a round trip. They are not alternatives; each catches something the others cannot.

17.7.4 Measured: One Adaptive Algorithm, and Both Its Failure Modes

Every limit so far has been a constant somebody picked. The alternative is deriving it from what is actually happening, and the simplest algorithm that works in production is Google’s client-side adaptive throttle:

Terminal
p(reject) = max(0, (requests − K·accepts) / (requests + 1))

requests and accepts are counted over a decaying window. While everything is accepted, requests ≈ accepts, so with K = 2 the numerator is negative and p is zero — the throttle is invisible. As acceptances fall, p rises and the client throttles itself before the downstream has to. K is how much rejection you tolerate before backing off; the +1 keeps a trickle flowing so the client can discover recovery.

It is twenty lines, and this chapter shows every other mechanism it recommends:

throttle_177.go
// Illustrative snippet — not a complete program
type Throttle struct {
    mu                sync.Mutex
    requests, accepts float64
    K, Decay          float64 // K=2.0, Decay=0.5 per window
}

// Allow reports whether to send. Call Record after the attempt.
func (t *Throttle) Allow() bool {
    t.mu.Lock()
    defer t.mu.Unlock()
    t.requests++
    p := (t.requests - t.K*t.accepts) / (t.requests + 1)
    return p <= 0 || rand.Float64() >= min(p, maxReject)
}

// Record files whether the backend accepted the request.
func (t *Throttle) Record(accepted bool) {
    t.mu.Lock()
    defer t.mu.Unlock()
    if accepted {
        t.accepts++
    }
}

// Tick decays both counters; call it once per window.
func (t *Throttle) Tick() {
    t.mu.Lock()
    defer t.mu.Unlock()
    t.requests *= t.Decay
    t.accepts *= t.Decay
}

maxReject is the floor the callout below insists on: clamp the rejection probability strictly below 1 so a trickle always escapes, whatever the ratio says.

Measured 500 requests per second offered, against a downstream that is healthy, then rejects everything for four seconds, then recovers. The throttle draws from a PRNG, so unlike the rest of this chapter’s tables this one is seeded — rand.NewSource(1) — and the seed is a parameter like any other. Independent runs reproduce the shape and both verdicts; the individual digits move by a few points.
Terminal
  configuration sent during recovery, % served/s
                         outage after the downstream healed
  K=2.0, 1s half-life 48% 11 → 28 → 53 → 95 → 100
  K=1.1, 1s half-life 30% 3 → 3 → 3 → 4 → 5
  K=2.0, no decay 91% 71 → 83 → 93 → 100 → 100

The first row is the algorithm working. Outbound load halves during the outage with no coordination at all, and recovery climbs back to full over about five seconds, driven entirely by the trickle of probes the +1 guarantees.

The other two rows are the failure modes, and they fail in opposite directions.

K too aggressive collapses. K = 1.1 protects the downstream better during the outage — 30% against 48% — and then never comes back. Five seconds after the downstream is completely healthy the client is still sending single-digit percentages. Because it throttles so hard, too few probes get through to rebuild accepts, and decay erodes the successes faster than the trickle replaces them. The client has talked itself into a permanent outage, and no downstream metric shows anything wrong.

No decay is inert. With counters that never age, the throttle remembers a healthy past forever: after thousands of successful requests, four seconds of total failure barely moves the ratio, so 91% of the flood still goes out. This is the more common configuration mistake, because it looks conservative — “don’t throw away data” — and produces a throttle that does nothing when it is needed.

Any adaptive limit needs a floor, and this is why.

A limit that can reach zero, or that approaches it faster than probes can recover it, has no path back. Clamp the rejection probability below 1, keep a minimum probe rate independent of the algorithm, and alarm on a limit that has sat at its floor longer than any real outage would last. The failure is silent from every direction: your dashboard shows you sending almost nothing and no errors, the downstream shows no traffic and no errors, and the only symptom is that nothing works.

Do not adapt a limit that is a contract.

A vendor’s published 100 requests per second is not an estimate to be improved by measurement; exceeding it gets you throttled or billed however healthy the responses look. Adaptive limiting is for limits that are guesses about capacity. Contractual limits stay constant, and the adaptive layer sits underneath them.

17.7.5 The Four Numbers

None of this is tunable without instrumentation, and the instrumentation that ships by default is the wrong half. A limiter exporting only its permitted rate tells you what it allowed and nothing about what it is holding — §17.3.2's OOM is invisible on that metric.

The four numbers:
Metric
Admitted
Rejected, by reason
Queue depth
Wait time, p99

Rejected must be split by reason — limiter, breaker, deadline-expired, queue-full — because that breakdown turns §17.6.5's bug into a seconds-long diagnosis and its absence makes it nearly invisible.

Queue depth is the one most often missing and the one this chapter argues hardest for. It is the difference between a service at its limit and a service accumulating an unbounded backlog, and those have identical admitted-rate graphs.

semaphore.Weighted exposes no accessor for its utilisation, so if you want that number you count acquisitions and releases yourself. That is two atomic increments on a path that already takes a mutex, and it is worth it.

17.7.6 Common Mistakes

Per-process limit treated as a fleet limit
Problem

Dependency sees N × the configured rate

Fix

Divide by replicas, or enforce at a chokepoint

Limit that scales with autoscaling
Problem

Protection weakens exactly during a spike

Fix

Put it where there is one of it

Per-key limiter map with no eviction
Problem

Unbounded memory, keyed by caller-chosen input

Fix

Sweep by last use, or bound with an LRU

Sweeping under the map’s write lock
Problem

Every request blocked for the sweep

Fix

Collect keys, release, delete in batches

Evicting active limiters
Problem

Callers reset their own limit by forcing eviction

Fix

Only delete entries idle past a full refill

Adapting a contractual limit
Problem

Throttled or billed by the vendor anyway

Fix

Adapt capacity guesses; contracts are constants

Adaptive limiter with no floor
Problem

Collapses and cannot prove recovery

Fix

Always let a small fraction through

Counters that never decay
Problem

Throttle inert when it is finally needed

Fix

Decay the window; measure the recovery curve

Exporting only the permitted rate
Problem

Backlog growth and memory climb invisible

Fix

Admitted, rejected-by-reason, depth, wait p99

Summary: Where the Limit Lives

rate.NewLimiter(100, 10) is 100/s per process, so eight replicas enforce 800/s and autoscaling to forty makes it 4,000/s — the protection weakening exactly when the dependency needs it. Divide by the replica count, enforce at a chokepoint, or enforce at the dependency; write the aggregate in the comment either way.

Per-key limiters are the standard shape and they leak, because the key space belongs to your callers. Sweeping by last use is free rather than a trade, because an idle limiter is indistinguishable from a fresh one — but evicting an active one hands its owner a full bucket, and doing the sweep under the write lock blocks every request in the service.

Static limits go stale. The Google SRE throttle adapts in twenty lines, and both of its failure modes are measurable: K too aggressive collapsed to single-digit throughput and never recovered, and counters that never decay left 91% of the flood going out during a total outage. Give any adaptive limit a floor, and never adapt a limit that is a contract.

And none of it is tunable without four numbers: admitted, rejected split by reason, queue depth, and wait-time p99. Depth is the one usually missing and the one that separates a service at its limit from a service accumulating a backlog.

Self-Check Questions: Where the Limit Lives

Your service holds rate.NewLimiter(100, 10) against a dependency documented at 1,000/s. It runs on 8 replicas and autoscales to 40 under load. Walk through what the dependency experiences during a spike.

At 8 replicas the dependency sees up to 800/s with a burst of 80 — comfortably inside its 1,000/s limit. In steady state the configuration looks correct and has probably never caused an incident.

During the spike your service scales to 40. Each new pod constructs its own limiter with a full bucket, so the aggregate is 4,000/s sustained with an instantaneous burst of 400 from the fresh buckets alone — arriving as the new pods become ready, which is to say all at once. The dependency is at four times its documented limit.

The compounding failure is that the spike is why you scaled. The dependency is already under elevated load from every other client, and your protection weakened by a factor of five at exactly that moment. A limit that scales with your replica count is not a limit on the dependency; it is a limit per pod that happens to have a rate in its name.

Then the failure modes compose. If the dependency defends itself by returning 429s and your breaker’s predicate is §17.6.5's naive one, those 429s open your circuit breaker against a dependency that is behaving correctly — three of this chapter’s failure modes in one incident.

The immediate fix is to derive the per-pod rate from the replica count and re-derive it on change. The durable fix is a chokepoint, or the dependency itself, which is the only place the number is inherently correct.

Why is deleting an idle per-key limiter safe when deleting an active one is not?

Because a limiter idle long enough is bit-for-bit equivalent to a new one, so deleting it discards no information.

A token bucket’s entire state is its token count and its last-update time. Tokens accrue at the configured rate and cap at the burst. Once idle for burst / rate seconds it has refilled to capacity — and a freshly constructed limiter also starts at capacity. There is nothing to lose.

Deleting an active limiter throws away a partially drained bucket and hands the caller a full one on its next request. That is a real escape: a client able to force eviction — by pausing just long enough, or by flooding the map with distinct keys to trigger LRU pressure — resets its own limit at will.

So the sweep threshold is not arbitrary. Delete only entries idle for at least the full refill time, and preferably a few multiples of it to leave margin for clock granularity and sweep interval. That is what turns eviction from a memory-versus-correctness trade into a free one.

For an LRU the same reasoning gives the sizing rule: the cache must be comfortably larger than the number of keys active within one refill window, or eviction starts hitting buckets that still held state worth keeping.

The SRE throttle sent 91% of its traffic during a total outage in one configuration and collapsed to 3% in another. Which is worse, and what single change fixes both?

The collapse is worse, and decay with a floor fixes both — but they are different bugs and it is worth saying why one change covers them.

The 91% row has counters that never decay. A long healthy history dominates the ratio, so four seconds of total failure barely moves it and the throttle stays invisible when it is needed. That is a failure to act: bad, but the downstream is already failing and the throttle is merely not helping.

The 3% row is a K so aggressive that the client throttles nearly everything, which starves the probes that would rebuild accepts, so decay erodes the successes faster than the trickle replaces them. That is self-inflicted and permanent: the downstream is completely healthy and the client has talked itself into an outage no downstream metric will ever show. Nobody is paged, because from every dashboard’s point of view there are no errors — there is simply no traffic.

Both are failures of the memory the algorithm keeps. No decay means the window remembers forever; aggressive K plus decay means it forgets successes faster than it can earn them. Tuning the decay so the window is a few multiples of the downstream’s failure-detection time, with a floor on the send rate that decay cannot erode, addresses both — the floor guarantees a probe stream regardless of how bad the ratio gets, which is precisely what the collapsed configuration lacked.

And it is why the section insists on alarming when a limit sits at its floor. It is the only symptom that is externally visible.

Key Takeaways

  • A per-process limit is a fleet limit multiplied by the replica count, and autoscaling weakens it exactly during a spike
  • Write the aggregate number in the comment; enforce at a chokepoint or at the dependency where you can
  • Per-key limiter maps leak, and the key space belongs to your callers
  • An idle limiter is indistinguishable from a fresh one, so sweeping by last use is free — evicting an active one is an escape hatch
  • Never iterate the map under the write lock every request needs
  • measured, K too aggressive collapsed to 3% and never recovered; counters that never decay left 91% of the flood going out
  • Give every adaptive limit a floor, and never adapt a limit that is a contract
  • Four numbers or you are guessing: admitted, rejected by reason, queue depth, wait p99 — and depth is the one usually missing
Section 17.7 — in one line

The limit you configured is enforced once per process, forgotten never, and correct only where there is exactly one of it.

Chapter Summary

A limiter is a queue that converts failure into latency, and it can only do that if the caller has latency to spend. That sentence carries the chapter. The queue framing is why a token bucket, a semaphore, a channel buffer and a circuit breaker belong together — they are one decision wearing four names — and the condition is why installing one is a measurement question rather than a default.

You began holding four limiters already: Chapter 2's channel semaphore, Chapter 5's bounded queue, Chapter 7's sized worker pool, Chapter 14's SetLimit. All four bound concurrency and none bounds a rate, and L = λW is the only bridge — running in the direction that happens to you rather than the one you configure.

Whether any of it helps was measurable and conditional.

Measured with no caller slack the limiter was inert across six identical rows; with slack it was worth roughly , at a price named in the wall column — 80 ms became 600 ms, which is Little’s Law’s best possible time for that work through that capacity. It did not make the system faster. It made it finish. Ten retries without a limiter eventually pushed 259–273 of 300 through while burning a third of the downstream: admission control rediscovered badly.

Chapter 11 was the way in. Its mutex fixed three real races and left the algorithm admitting 2× the configured rate across a window boundary — because a race and a rate are different bugs, and locking makes a decision consistent rather than correct. time.Ticker fails more quietly still: it dropped ticks for a slow receiver and delivered 10 to 13 permits where 50 were due, silently enforcing its own loop body’s rate.

rate.Limiter runs no goroutine and no timer, and its three methods are three dispositions. Wait is the one everybody writes and an unbounded queue whenever arrivals decide the caller count. Reserve consumes on reservation: measured, ten abandoned reservations left the limiter ten tokens in debt and cost the next caller 110 ms against 10 ms. But Wait cleans up after itself — measured, three hundred cancelled waits moved the schedule not at all — and that asymmetry is the reason to prefer it. And the limiter is one mutex: 180.3 ns/op shared against 10.9 unshared, and the keyed map in front of it should never take a write lock to do a read.

semaphore.Weighted buys weights and strict FIFO, and two of its four surprises come from that FIFO — the other two are the doomed Acquire and the Release panic. TryAcquire refuses while six of eight units sit free. And an Acquire for more than total capacity does not error — it parks until the context is done, a permanent leak under Background, where rate.WaitN in the identical situation returns immediately.

Backpressure turned out to be a question of ownership rather than addition.

Measured raising a buffer from 10 to 500 slots raised finished work 24% and cut useful work by 69%, with rejections falling to zero — the row that looks like success is where the shedding decision moved from your code to the caller’s timeout. Wait is depth ÷ drain rate exactly, so deadline × drain rate is a ceiling to stay under.

Retries do not cause collapse on their own.

Measured three attempts instead of one raised offered load 44% and served work by a single request, while tripling peak in-flight from 219 to 653 — and with a cap of ten, the same retries left peak concurrency pinned at exactly 10. Retries multiply offered load; admission control decides whether that costs anything.

And the breaker’s predicate is where protection becomes an outage.

Measured with err != nil, two hundred impatient callers opened the breaker and the next two hundred ordinary requests were all refused — a 100% availability loss against a downstream that never returned a single error. Test ctx.Err(), not the error’s shape.

Finally, the number lives somewhere. Eight replicas holding NewLimiter(100, 10) enforce 800/s; autoscaling to forty makes it 4,000/s, and the protection weakens exactly during the spike that caused the scaling.

Chapter Connections

How Chapter 17 connects
Chapter 2
The semaphore pattern opens §17.1.1's inventory, and §17.4.5's doomed Acquire is §2's goroutine leak inside a library you trusted
Chapter 3
§17.4.5's doomed Acquire parks on a nil channel — §3.4's “receive from nil blocks forever”, reached through a library rather than a bug of your own
Chapter 4
Every cancellable acquire is §4.2's select, and the default case is the reject disposition
Chapter 8
The limiter’s mutex is what makes its float64 token count safe to read; without it §8.2's word-tearing question would apply to every Allow
Chapter 5
§5.3 and §5.4 own the channel semaphore and the bounded queue; §17.5.1 supplies the arithmetic they left to judgement
Chapter 7
§7.3 asked how big a queue should be; §17.5.1 answers it as deadline × drain rate
Chapter 9
A limiter is one mutex on the hot path (§17.3.6), and §17.7.2's sweeper is lock granularity on a path every request takes
Chapter 10
A drain stalled at a limiter is §10.2's deadlock with a deadline, and the goroutine dump names it immediately
Chapter 11
§11's self-check answer is §17.2.1's starting point, and half-open is CompareAndSwap on the one shape §11 said atomics do express
Chapter 12
§12.3 said rate-limiter state cannot live in a sync.Pool; §17.7.2 is what that leaves you to build
Chapter 13
Every wait here is bounded by a context, and §17.5.2 turns the deadline into the cheapest shedding a service can do
Chapter 14
§14.4.4's figures are cited rather than repeated; §14.7's degradation is the fourth disposition; §14.1's silence is §17.6.5's misattribution
Chapter 15
§15.5.3's in-flight/queued line falls on this chapter’s waiters (§17.5.4), and §15.4's shutdown cancellation is what opens a naive breaker
Chapter 16
Limiters are deterministic under synctest — the inverse of §15.7.7 — which is what makes a figure exact whenever a clock or a serialised admission point decides it, and a range whenever scheduling does
Chapter 18
The bug catalogue; several entries here are its rate-limiting section

Final Checklist

Before moving to Chapter 18, ensure you can:

Exercise 17.1 — Give Back What You Took, and Blame the Right Party

Your move

Give Back What You Took, and Blame the Right Party

This client is correct in every way the earlier chapters taught you to check. It compiles, go vet is clean, and go test -race finds nothing — every piece of shared state is under a mutex and there is genuinely no data race here.

It still throttles itself against requests nobody made, and it still blames a healthy downstream for its own callers' impatience.

The first bug is §17.3.4. Reserve() takes the tokens when it is called, not when you act on them, and the abandon path returns without calling Cancel(). Every caller who gives up while waiting for a token leaves that token spent, and the limiter drifts below its configured rate — furthest, exactly when the abandon path is hottest.

The second is §17.6.5, and it is the chapter’s centrepiece. Do hands every non-nil error to the breaker. The context belongs to your caller, so a caller who gives up produces an error that says nothing whatsoever about the downstream — and enough of them open the breaker against a service that has never failed once.

ch17/client.go
// Package ch17 is the exercise for Chapter 17: Rate Limiting and
// Flow Control.
//
// Client calls a downstream under two protections: a token-bucket
// limiter that paces outbound requests, and a circuit breaker that
// stops calling a downstream that is failing. It is meant to hold
// three promises:
//
//   - a caller who gives up is the caller's business, not the
//     downstream's fault
//   - an attempt abandoned before it is made costs no rate budget
//   - a downstream that really is failing still trips the breaker
//
// TODO(reader): this compiles, vets clean, and has no data race. It
// keeps the third promise and breaks the first two. Two tests prove
// it. Each needs a different fix, and neither repairs the other.
package ch17

import (
	"context"
	"errors"
	"time"

	"golang.org/x/time/rate"
)

// ErrBreakerOpen is returned when the breaker refused the call.
var ErrBreakerOpen = errors.New("circuit breaker open")

// Client calls a downstream through admission control.
type Client struct {
	Limiter *rate.Limiter
	Breaker *Breaker
	// Call is the downstream. It is the only thing here that can
	// legitimately fail.
	Call func(context.Context) error
}

// Do acquires a token, waits its turn, then calls the downstream.
func (c *Client) Do(ctx context.Context) error {
	if !c.Breaker.Allow() {
		return ErrBreakerOpen
	}

	r := c.Limiter.Reserve()
	if !r.OK() {
		return errors.New("ch17: unsatisfiable")
	}
	if d := r.Delay(); d > 0 {
		select {
		case <-time.After(d):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	err := c.Call(ctx)
	c.Breaker.Record(err)
	return err
}

The breaker is given to you and is correct — it trips on rate with a minimum-request floor. It is not where the bugs are, but read it anyway: the first bug is in how Do calls Record, and you cannot judge that without seeing what Record does with what it is given.

ch17/breaker.go
package ch17

import (
	"sync"
	"time"
)

// Breaker trips when the failure rate over the current window reaches
// Threshold, once at least MinRequests calls have been recorded. It
// is CORRECT. The bugs in this exercise are in how Client.Do uses it.
type Breaker struct {
	MinRequests int           // floor before the ratio means anything
	Threshold   float64       // failure ratio that opens the breaker
	Cooldown    time.Duration // how long to stay open

	mu           sync.Mutex
	fails, total int
	open         bool
	openedAt     time.Time
}

// Allow reports whether a call may proceed, closing the breaker again
// once the cooldown has elapsed.
func (b *Breaker) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	if !b.open {
		return true
	}
	if time.Since(b.openedAt) < b.Cooldown {
		return false
	}
	b.open = false // cooled down: let the next call try
	b.fails, b.total = 0, 0
	return true
}

// Record files the outcome of one call. A nil error is a success.
func (b *Breaker) Record(err error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.total++
	if err != nil {
		b.fails++
	}
	if b.total >= b.MinRequests &&
		float64(b.fails)/float64(b.total) >= b.Threshold {
		b.open, b.openedAt = true, time.Now()
	}
}

// Open reports whether the breaker is currently refusing calls.
func (b *Breaker) Open() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.open
}

The four gates it has to satisfy:

ch17/client_test.go
package ch17

import (
	"context"
	"errors"
	"runtime"
	"sync"
	"testing"
	"testing/synctest"
	"time"

	"golang.org/x/time/rate"
)

var errDownstream = errors.New("downstream failed")

// healthy takes 20ms and never fails.
func healthy(ctx context.Context) error {
	select {
	case <-time.After(20 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func newClient(
	call func(context.Context) error, r rate.Limit, b int,
) *Client {
	return &Client{
		Limiter: rate.NewLimiter(r, b),
		Breaker: &Breaker{
			MinRequests: 20,
			Threshold:   0.5,
			Cooldown:    time.Minute,
		},
		Call: call,
	}
}

// Gate 1: callers giving up must not open the breaker.
func TestBreakerStaysClosedWhenCallersGiveUp(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		c := newClient(healthy, rate.Inf, 1) // limiter never delays
		for i := 0; i < 40; i++ {
			ctx, cancel := context.WithTimeout(
				context.Background(), 5*time.Millisecond)
			_ = c.Do(ctx)
			cancel()
		}
		if c.Breaker.Open() {
			t.Fatal("breaker opened on a healthy downstream\n" +
				"  40 callers gave up after 5ms on a call that\n" +
				"  takes 20ms and never fails. Not one downstream\n" +
				"  error occurred, and every later request is now\n" +
				"  refused for a full Cooldown. Record counts every\n" +
				"  non-nil error, so caller-side cancellation reads\n" +
				"  as downstream failure. Decide what counts.")
		}
	})
}

// Gate 2: abandoned attempts must not consume rate budget.
func TestAbandonedAttemptsDoNotCostRateBudget(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// 100/s is one token per 10ms. The breaker is disabled here
		// so this gate measures only the limiter -- otherwise bug 1
		// trips it first and hides the behaviour under test.
		c := newClient(healthy, 100, 1)
		c.Breaker = &Breaker{
			MinRequests: 1 << 30, Threshold: 1, Cooldown: time.Minute}
		c.Limiter.Allow() // drain the burst

		for i := 0; i < 10; i++ {
			ctx, cancel := context.WithTimeout(
				context.Background(), time.Millisecond)
			_ = c.Do(ctx) // gives up while waiting for its token
			cancel()
		}

		start := time.Now()
		_ = c.Do(context.Background())
		if waited := time.Since(start); waited > 30*time.Millisecond {
			t.Fatalf("abandoned attempts consumed rate budget\n"+
				"  the next caller waited %v for a token;\n"+
				"  one token period is 10ms\n\n"+
				"  Reserve() takes the tokens whether or not you\n"+
				"  act. Ten callers reserved and left, and the\n"+
				"  eleventh is paying for ten requests nobody made.\n"+
				"  Wait cleans up after itself; Reserve makes you.",
				waited)
		}
	})
}

// Gate 3: guards the overshoot. A downstream that really fails must
// still trip the breaker -- "count nothing" is not a fix.
func TestBreakerStillOpensOnRealFailures(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		down := func(context.Context) error { return errDownstream }
		c := newClient(down, rate.Inf, 1)
		for i := 0; i < 20; i++ {
			_ = c.Do(context.Background())
		}
		if !c.Breaker.Open() {
			t.Fatal("twenty consecutive downstream failures and " +
				"the breaker is still closed\n" +
				"  Excluding caller-side cancellation is the fix\n" +
				"  for gate 1. Excluding everything is not: the\n" +
				"  breaker now protects nothing.")
		}
	})
}

// Gate 4: guards a fix that watches the reservation in a goroutine.
func TestDoLeavesNoGoroutineBehind(t *testing.T) {
	before := runtime.NumGoroutine()
	c := newClient(healthy, 100, 1)

	var wg sync.WaitGroup
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ctx, cancel := context.WithTimeout(
				context.Background(), 2*time.Millisecond)
			defer cancel()
			_ = c.Do(ctx)
		}()
	}
	wg.Wait()

	var after int
	for i := 0; i < 100; i++ {
		if after = runtime.NumGoroutine(); after <= before {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("a goroutine outlived Do\n  before=%d after=%d\n\n"+
		"  Cancelling the reservation from a watcher goroutine\n"+
		"  leaves one parked per abandoned attempt. Cancel it\n"+
		"  inline, on the path that is already returning.",
		before, after)
}

Run it:

Terminal
$ cd labs/go-concurrency/code/ch17
$ go test -race ./...

Two of the four fail, the same way every time. Gates 3 and 4 pass against the starter — like Chapter 15's, they guard against wrong fixes rather than against the code as written:

Terminal
--- FAIL: TestBreakerStaysClosedWhenCallersGiveUp (0.00s)
    client_test.go:52: breaker opened on a healthy downstream
          40 callers gave up after 5ms on a call that
          takes 20ms and never fails. Not one downstream
          error occurred, and every later request is now
          refused for a full Cooldown. Record counts every
          non-nil error, so caller-side cancellation reads
          as downstream failure. Decide what counts.
--- FAIL: TestAbandonedAttemptsDoNotCostRateBudget (0.00s)
    client_test.go:84: abandoned attempts consumed rate budget
          the next caller waited 120ms for a token;
          one token period is 10ms
          Reserve() takes the tokens whether or not you
          act. Ten callers reserved and left, and the
          eleventh is paying for ten requests nobody made.
          Wait cleans up after itself; Reserve makes you.
FAIL
FAIL corebackend.dev/go-concurrency/ch17 0.419s

(The 120 ms here rather than §17.3.4's 110 is not a discrepancy: Do spends one more token before the measured call.)

Done when: go test -race ./... in code/ch17/ reports ok for all four, and keeps reporting it under -count=10. Because everything here runs on virtual time (§17.2.8), a passing run is a proof rather than a good outcome — if any gate is flaky, the fix is wrong.
Two traps, and they are genuinely independent. This was verified rather than assumed: applying only the classification fix leaves gate 2 failing at 120 ms, and applying only the Cancel fix leaves gate 1 failing with the breaker open. Neither repairs the other, because they are defects in two different mechanisms that happen to share a method.

What is not independent is the order in which they hide each other, and gate 2 carries a comment about it. In the starter, bug 1 would trip the breaker within the first few iterations of gate 2's loop, and every later call would return ErrBreakerOpen before ever reaching Reserve — the reservation bug still there and the test unable to see it. Gate 2 therefore disables its breaker, which is the only way to measure one failure while the other is still present. That is worth noticing as a debugging lesson rather than a testing trick: compounding failures mask each other, and the first one you fix is usually the one that was hiding the rest.

The third gate is the interesting one, and it exists to catch the fix that looks right. Having discovered that caller-side cancellation should not count as a failure, the natural overshoot is to stop recording anything that resembles a cancellation — or to stop recording failures at all. Gate 3 puts twenty genuine failures through and requires the breaker to open. A breaker that never opens is not a fixed breaker; it is a removed one.

The subtlety worth finding on your own is what to test. The predicate is not “is this error a cancellation” but “was our own caller already gone” — ctx.Err() == nil rather than errors.Is(err, context.Canceled). A downstream enforcing its own internal deadline and returning DeadlineExceeded really did fail, and you want that one counted. Testing the error confuses the two; testing the context does not.

Where the files are: labs/go-concurrency/code/ch17/. A worked answer sits in solution/client.go.txt, including why the reservation must be cancelled inline rather than from a watcher goroutine — gate 4 exists for that fix — and why time.NewTimer with a defer t.Stop() is preferable to time.After here — for §17.2.4's reason and no other. Since Go 1.23 the unstopped timer is collectable (and since Go 1.27 unconditionally so), so this is not a leak fix; it releases the timer promptly on a path that returns early far more often than it runs to completion, which is exactly the tk.Stop() argument in a second costume.

Further Reading

Next

You can now put a bounded, orderly entrance in front of a service the way Chapter 15 put a bounded exit behind it. You can tell a rate limit from a concurrency limit and say which one Little's Law fixes for you; you can choose between refusing, waiting and dropping on purpose rather than by default; and you can write the one predicate that decides whether a circuit breaker protects you or becomes your outage.