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.
// 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
}
// 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()
// 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.
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.
- 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,ReserveandWaitas 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.Weightedbuys 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
- 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/synctestand says so once - The bug catalogue — Chapter 18 collects the leaks and misclassifications this chapter warns about
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.
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.
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.
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
errgroup.SetLimitFour 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:
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.
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:
// 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.
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 8×. 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:
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.
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:
- The kernel’s accept backlog, sized by
somaxconnandnet.ListenConfig - Socket receive buffers, sized by the operating system
- The load balancer’s own queue, sized by someone on another team
- The client’s retry loop, sized by whoever wrote the client
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.
This line recurs for the rest of the chapter, because every remaining section is a specific instance of it.
17.1.7 Common Mistakes
Unbounded rate when the work is fast
Decide which unit you meant; L = λW converts
In-flight count grows as the dependency slows
Bound concurrency too; rate alone does not cap L
No measurable change, and a new failure mode
Measure first; block [1] is a real shape
A p99 nobody chose and nobody can explain
Size by W = L/λ and state the latency
“Somewhat rejected” work that is really just late
Choose one per class of work, deliberately
The queue moves somewhere with no deadline check
Own the queue you can see
Throughput unchanged, latency worse
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
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.
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.
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 = λWis 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
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:
// 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.
Measuredlimit = 100 per second, starting just before a window boundary:
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.
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.
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 is2 × 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.
// 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.
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 fixthe 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():
// 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.
- Burst 1 is a strict pacer. Requests come out evenly spaced and idleness earns no credit. Use it when the downstream’s problem is simultaneity.
- Burst = the downstream’s safe concurrency is the usual right answer, because that is literally what it permits to arrive at once.
- Burst = rate forgives a full second of history, which is fine for batch clients and wrong for a downstream sized by rate rather than by concurrency.
17.2.7 The Algorithms You Will Hear Named
burst, explicitx/time/rate, most librariesToken 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 insidesynctest.Test, with rate.NewLimiter(10, 1):
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
2× the rate across every boundary
Token bucket; the burst is explicit and capped
Race-free, still bursty
Ask whether the bug was agreement or algorithm
time.TickerSilently enforces the body’s rate
rate.Limiter; it needs no receiver
Ticker to queue missed ticksFewer permits than configured, no error
It drops them by documented design
Bursts the downstream cannot absorb
Burst is a concurrency answer; ask the downstream
defer tk.Stop() believed to prevent a leakAdvice that outlived Go 1.23
Keep it for promptness and intent
rate.Limiter runs a goroutinePhantom worries about leaking limiters
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
-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.
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.
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.Limiterruns 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
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
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:
// 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:
// 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 inrate.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:
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.
Measuredrate.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.
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.
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.
rate.NewLimiter(100, 1) — one token per 10 ms — burst already spent, then ten reservations made and abandoned:
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?
Wait calls at 100 per second, with zero, one and three additional callers reserving and being cancelled mid-wait between each one:
Wait calls: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 callingWait 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.
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:
Allow(), min of five passesrate.Limitersync.Mutex, 8 keyssync.Mutex, 64 keyssync.Mutex, 512 keyssync.RWMutex, 64 keys16.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.
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
WaitAllow, or ReserveReserve, and compare Delay()Allow — the only one that cannot make a caller slowReserve, and cancel on every early return17.3.9 Common Mistakes
Wait where concurrency is inbound trafficPerfect rate, unbounded goroutines, OOM later
Allow to shed, or Reserve to decide
Wait failure means the budget is spentFallbacks never fire; the caller has time left
It short-circuits; use the returned slack
NewLimiter(r, 0) from unset config100% rejection, reported only on the shed path
Validate limit and burst at construction
Reserve() on a path that can return earlyAchieved rate below configured, silently
defer the Cancel, cleared on the used path
Cancel to always restore fullyDrift under heavy reservation churn
It restores “as much as possible”; cancel promptly
ReserveContextThere isn’t one
Compare r.Delay() to your budget yourself
Wait calls leak budgetComplexity added for a non-problem
Measured: 300 cancellations cost nothing
The limiter is the bottleneck
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
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.
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.
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,ReserveandWaitare the reject, decide and wait dispositions;Reserveis least used and often rightWaitis an unbounded queue whenever arrivals decide the caller count — ask whether the number is one you choseWaitfails 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
Waitcalls cost nothing — 300 of them moved the schedule not at all Waitcleans up after itself;Reservemakes you do it, which is the reason to preferWaitWaitis strictly FIFO, on every run- A shared limiter is one mutex: 180.3 ns/op against 10.9, 16.5× under 16-way load;
RWMutexon the keyed map beats every sharding decision
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:
// 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:
// 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.
// 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.
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.Weightedsemaphore.Weighted
semaphore.Weighted
semaphore.Weighted
semaphore.Weighted
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.
rate checks and refuses. semaphore blocks until the context is done, and the source is explicit:
// 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.
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.
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
select on ctx.Done()errgroup.SetLimit (§14.4.4)semaphore.Weightedsemaphore.Weighted, accepting the FIFO cost17.4.9 Common Mistakes
semaphore.Weighted for uniform slotsHead-of-line blocking bought for nothing
Chapter 5's channel; simpler and faster
Limit silently grows by one per failure
defer the release inside the success branch
TryAcquire assumed to mean “if there is room”Everything shed while capacity sits idle
It also requires an empty waiter queue
Acquire(ctx, n) with n from user inputPermanent goroutine leak under Background
Validate n against the capacity you chose
It blocks; only the context ends it
rate and semaphore differ here
Panic: released more than held
One release per successful acquire
Dependency idle, or queueing internally
L = λW from its rate and its latency
Offered rate far above the published limit
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
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.
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.
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,
TryAcquiremeans “room and no waiters” — one blockedAcquiredisables 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 underBackground, whererate.WaitNerrors immediately Releasepanics on over-release, which is the better failure- Derive the limit with
L = λWfrom the dependency’s published rate and its sustained latency, never its p99
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.
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.
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:
// 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.
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:
// 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
Zero rejections, goodput collapsed
Cap at deadline × drain rate
Consumer busy, callers all timing out
Measure goodput: finished for someone
Latency grows, useful work falls
Buffers absorb bursts, never overload
Capacity spent on callers who left
Check again at dequeue; it is an atomic load
Server busy, observed completion near zero
Serve newest-first as a deliberate degradation
Slow rejections plus parked goroutines
Reject at the edge; block only internally
Shutdown waits for permits nobody will issue
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
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.
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.
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 rateexactly, sodeadline × drain rateis 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
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.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”:
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:
// 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.
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:
// 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.
// 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.err != nilA 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:
// 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.
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.
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:
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 classificationCancellation storm opens a healthy dependency
Test ctx.Err() == nil, not the error’s shape
errors.Is(err, context.Canceled) as the testDownstream’s own deadline stops counting
Test the context; it asks whose fault it was
Load opens the breaker; dependency is fine
Exclude admission-control errors explicitly
Malformed client input opens your breaker
Your request was wrong; tripping helps nobody
Recovering service knocked over; oscillation
One probe, via CompareAndSwap
Quiet endpoints never trip; busy ones trip on blips
Error rate over a window, plus minRequests
Opens on the first failure after a deploy
Require a sample before the ratio means anything
Aggregate load unbounded under wide failure
Cap retries as a fraction of requests
Synchronised spikes that re-trigger the failure
Uniform draw over the whole interval
Guaranteed absence where degradation was fine
Degrade (§14.7); skip the state machine
Duplicated side effects, not a latency problem
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 witherr != 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
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.
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.
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 != nilcost 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
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
// 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.
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.
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:
// 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:
// 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:
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.
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:
// 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
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:
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:
// 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.
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.
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.
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.
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.
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
Dependency sees N × the configured rate
Divide by replicas, or enforce at a chokepoint
Protection weakens exactly during a spike
Put it where there is one of it
Unbounded memory, keyed by caller-chosen input
Sweep by last use, or bound with an LRU
Every request blocked for the sweep
Collect keys, release, delete in batches
Callers reset their own limit by forcing eviction
Only delete entries idle past a full refill
Throttled or billed by the vendor anyway
Adapt capacity guesses; contracts are constants
Collapses and cannot prove recovery
Always let a small fraction through
Throttle inert when it is finally needed
Decay the window; measure the recovery curve
Backlog growth and memory climb invisible
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
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.
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 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,
Ktoo 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
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 8×, 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 isdepth ÷ 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 witherr != 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
Acquire is §2's goroutine leak inside a library you trustedAcquire parks on a nil channel — §3.4's “receive from nil blocks forever”, reached through a library rather than a bug of your ownselect, and the default case is the reject dispositionfloat64 token count safe to read; without it §8.2's word-tearing question would apply to every Allowdeadline × drain rateCompareAndSwap on the one shape §11 said atomics do expresssync.Pool; §17.7.2 is what that leaves you to buildsynctest — 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 doesFinal Checklist
Before moving to Chapter 18, ensure you can:
- State the difference between a rate limit and a concurrency limit, and convert between them with
L = λW - Name the condition under which a limiter changes nothing, and say why that population is self-limiting
- Explain why Chapter 11's race-free limiter is still wrong, and what class of bug a mutex cannot fix
- Say what
time.Tickerdoes to a slow receiver, and whatdefer tk.Stop()still buys you after Go 1.23 - Choose between
Allow,ReserveandWaitfrom the caller’s population and deadline - Explain how
Waitturns a rate problem into a memory problem, and the question to ask before using it - Say what an abandoned
Reservecosts, which paths abandon them, and whyWaitneeds no such care - Predict
TryAcquirereturning false while capacity is free, and justify the design - Name the two packages that disagree about an impossible acquisition, and which one leaks
- Compute the break-even queue capacity from a deadline and a drain rate, and say why you size below it
- Distinguish throughput from goodput, and say which one a bigger buffer improves
- Name four policies for a full queue, and a case where dropping the oldest is correct
- Say what retries amplify, and why a bound rather than a retry policy is the fix
- Write a circuit breaker’s failure predicate, and say why it tests the context rather than the error
- Explain why half-open admits exactly one probe, and how
CompareAndSwapenforces it - Compute the rate your fleet enforces from the rate you configured
- Name both failure modes of an adaptive limit, and the floor that prevents one of them
- Name the four metrics without which none of this is tunable
- Fix the exercise: give back what you took, and blame the right party
Exercise 17.1 — Give Back What You Took, and Blame the Right Party
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.
// 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.
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:
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:
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:
(The 120 ms here rather than §17.3.4's 110 is not a discrepancy: Do spends one more token before the measured call.)
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.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.
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
golang.org/x/time/rate— readReserve,ReserveNandCancelAtin full, then the source.limiter.gois about four hundred lines andadvanceis twenty of them, so the whole token bucket is visible at once. TheCancelAtrestoration rule that §17.3.4 measures is stated in the doc comment, andTokensAt/SetLimitAtare what make a limiter testable against a clock you control.golang.org/x/sync/semaphore— under two hundred lines. ReadnotifyWaitersand then > s.sizebranch together: the first explains §17.4.4's head-of-line blocking and the second is §17.4.5's leak, with the rationale for both in comments.- Google SRE Book, "Handling Overload" — the source of §17.7.4's client-side throttle, and the best short treatment of why load shedding must be cheaper than serving. The cascading-failures chapter that follows is the long-form version of §17.6.1.
- AWS Architecture Blog, "Exponential Backoff and Jitter" — the measurements behind §17.6.2's insistence on full jitter rather than a small perturbation.
- Marc Brooker's writing on retries, congestion and queueing — the clearest available explanation of why a retry budget beats a retry count, and of the queueing arithmetic behind §17.5.1.
- Netflix concurrency-limits — a production adaptive limiter using latency gradients rather than accept ratios. Read it after §17.7.4 for the algorithm this chapter deliberately did not implement, and for its own account of the oscillation problem.
testing/synctest— what makes §17.2.8's exact figures possible. Chapter 16 covers it properly; the doc’s description of “durably blocked” is what explains why a limiter works inside a bubble and §15.7.7's socket does not.$GOROOT/src/time/tick.go— thirty lines, and theNewTickerdoc comment contains the sentence §17.2.3 measures. Read it alongside the Go 1.23 timer rewrite notes — and the Go 1.27 note that removed theasynctimerchanopt-out — to settle thedefer tk.Stop()question faster than any blog post.
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.