Building a Rate Limiter Library
Four algorithms, one choice, and about sixty lines of Go that protect a service from its busiest clients without slowing down everyone else.
The problem
One client retries in a tight loop, a batch job forgets to sleep, a scraper finds your search endpoint. Without a limit, a single caller can use up the capacity meant for everyone. A rate limiter answers one question on every request: may this caller proceed right now? It has to answer in microseconds, under heavy concurrency, and it must never become the bottleneck it was meant to prevent.
This post builds an in-process limiter: one service, one memory space. Part 11 moves the same idea into a shared store so a fleet of servers can agree on a single budget.
Requirements
Functional
- Limit by key: user, API key or IP.
- A steady rate plus a short burst.
- Tell rejected callers when to retry.
Non-functional
- O(1) time and memory per key.
- Safe for concurrent use.
- Deterministic in tests.
Choosing an algorithm
Four designs come up again and again. They differ in how much they remember about each caller and in what they do at the edge of a window.
| Algorithm | Memory per key | Bursts | Accuracy |
|---|---|---|---|
| Fixed window | O(1) | Up to 2× at window edges | Low |
| Sliding log | O(n) | None allowed | Exact |
| Sliding window counter | O(1) | Smoothed | Approximate |
| Token bucket | O(1) | Configurable | Exact for rate |
The token bucket wins for a library: two numbers per key, a burst you can tune separately from the rate, and no timers running in the background.
How a token bucket works
There is no background refill loop. Each time a request arrives we work out how many tokens would have dripped in since the last request, add them, cap at the capacity, and then try to spend one. Time does the bookkeeping for us.
The core
The bucket stores a token count and the time it was last updated. The Clock interface is the
only concession to testing, and it costs nothing at runtime.
package ratelimit
import ( "math" "sync" "time")
// Clock exists so tests can move time forward without sleeping.type Clock interface{ Now() time.Time }
type Bucket struct { mu sync.Mutex clock Clock tokens float64 capacity float64 rate float64 // tokens per second last time.Time}
func (b *Bucket) Allow() bool { b.mu.Lock() defer b.mu.Unlock()
now := b.clock.Now() elapsed := now.Sub(b.last).Seconds() b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.rate) b.last = now
if b.tokens < 1 { return false } b.tokens-- return true}Tokens are a float64 on purpose. At 2 requests per second, a request 300 ms after the last one
earns 0.6 of a token; rounding that away would make the limiter stricter than configured.
One bucket per client
A limiter keeps a map from key to bucket. The map lock is held only long enough to find or create the bucket; the bucket's own lock does the rest, so two different clients never wait on each other.
type Limiter struct { mu sync.Mutex buckets map[string]*Bucket rate float64 capacity float64 clock Clock}
func (l *Limiter) Allow(key string) bool { l.mu.Lock() b, ok := l.buckets[key] if !ok { b = &Bucket{clock: l.clock, tokens: l.capacity, capacity: l.capacity, rate: l.rate, last: l.clock.Now()} l.buckets[key] = b } l.mu.Unlock()
return b.Allow()}
func (l *Limiter) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !l.Allow(clientKey(r)) { w.Header().Set("Retry-After", "1") http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) })}Testing with a fake clock
Tests that call time.Sleep are slow and flaky. With an injected clock, the test moves time
forward itself and every assertion is exact.
type fakeClock struct{ now time.Time }
func (c *fakeClock) Now() time.Time { return c.now }func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
func TestBucketRefills(t *testing.T) { clock := &fakeClock{now: time.Unix(0, 0)} b := &Bucket{clock: clock, tokens: 2, capacity: 2, rate: 1, last: clock.Now()}
if !b.Allow() || !b.Allow() { t.Fatal("a fresh bucket should allow a full burst") } if b.Allow() { t.Fatal("an exhausted bucket should reject") }
clock.Advance(time.Second) if !b.Allow() { t.Fatal("one second should refill exactly one token") }}The refill shown in Listing 1 is what makes this testable: no goroutine, no ticker, nothing to wait for.
Trade-offs
Good fit
- A single service or sidecar.
- Protecting a downstream dependency.
- Smoothing bursts from background jobs.
Not a fit
- Quotas shared across many replicas.
- Limits that must survive a restart.
- Billing-grade, per-request accounting.
Every one of the "not a fit" cases comes down to state that has to live outside the process. That is where the series goes next.