Two limiters tuned to the same average rate, fed one identical trace, disagreeing twice — in opposite directions. A study guide for rate_limiter.py.
Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd rate-limiter
python3 demo.py # the aha (§6)
python3 test_rate_limiter.py # pins the trace this page describes
This toy implements two rate limiters — a token bucket and a sliding window log — and tunes them to the same average rate: five requests per five seconds. If a rate limit were a single well-defined idea, two correct implementations of "5 per 5s" would agree on every request. They don't. Fed one identical trace, they disagree twice, in opposite directions.
By the end you should be able to:
The last one is the real payoff. "Rate limited" is a thing you will read in someone else's API docs, and the docs will usually not tell you which algorithm is behind it.
A shared resource — an API, a database, a mail relay — has finite capacity. Demand for it does not politely stay under that capacity. A rate limiter is the component that decides, per request, whether serving it is affordable, and it exists for at least four distinct reasons that often get conflated:
These pull in different directions, which is why there is no single correct algorithm. A capacity-protection limiter wants to smooth traffic — the thing that hurts the server is the instantaneous spike. A fairness limiter wants to count exactly — the thing that matters is the total each client consumed. Those two goals produce the two algorithms in this toy.
None of this is deep, but the commentary below leans on it.
| Concept | Where it's used here | One source |
|---|---|---|
| Burst vs. sustained rate | The entire point of the toy — two limiters with identical sustained rates and different burst behaviour | Token bucket § Burst size |
| Amortized analysis | Why the eviction while loop in SlidingWindowLog.allow is O(1) per call despite being a loop |
Amortized analysis |
deque vs. list |
Why the log is a deque: popleft() is O(1), list.pop(0) is O(n) |
collections.deque |
| Monotonic vs. wall-clock time | Why allow() takes now as a parameter instead of calling a clock |
time.monotonic |
The two that carry the result are the first and the last. Burst-vs-sustained is the whole aha; if you only internalise one row, take that one. Dependency injection of now is what makes the aha checkable — without it this toy would need sleep() calls and would be untestable and non-reproducible.
Before any code. Both limiters answer "may this request proceed?", but they hold completely different things in memory.
The difference in storage is the difference in personality, and it is worth stating as a slogan before you read a line of code:
Everything that follows is a consequence of that one distinction.
The file is 52 lines. Read it in this order.
"""Two rate-limiting algorithms, side by side: token bucket and sliding
window log. Both expose the same interface: allow(now) -> bool, where `now`
is a caller-supplied timestamp (seconds), so behavior is deterministic and
testable without real clocks or sleeps.
"""
The single most consequential design decision in the toy is in this docstring, not in any class: now is a parameter, not something the code fetches.
Had allow() called time.monotonic() internally, the demo would need real sleep() calls, would take five seconds to run, and — worse — would produce slightly different output on every run, because the burst at "t=5.0" would really land at 5.0003. The boundary behaviour that is half the aha would become a coin flip. Injecting the clock makes the toy a pure function of its trace: same input, same output, forever.
This is not just a testing trick; it is what production limiters do too, for a different reason. A limiter running on many machines must agree on what time it is, and the wall clock is not a safe source: NTP can step it backwards, and a backwards jump makes elapsed negative and quietly removes tokens. Real implementations use a monotonic source, or the storage layer's own clock (Redis's TIME), precisely so no client's skewed clock can corrupt the shared state.
TokenBucket.__init__ — the whole state is one float def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = 0.0
Two config values and two state values. Note self.tokens = capacity — a fresh bucket starts full, so a brand-new client may immediately burst. That is a deliberate and near-universal choice (a client who has never been seen isn't punished), but it has a real consequence: if your limiter's state is evicted from cache and recreated, the client silently gets a fresh full burst. Cache eviction becomes a rate-limit bypass.
last_refill is the interesting field. It exists so that refill can be computed lazily.
TokenBucket.allow — continuous refill without a timer def allow(self, now):
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
Nine lines containing three ideas worth slowing down for.
Lazy refill. The mental model says tokens arrive continuously. A naive implementation would need a background thread ticking tokens += 1 every second — per client, forever, for millions of idle clients. Instead the bucket computes what would have accumulated since it was last touched, at the moment it's next touched. Work happens only on request, and the answer is identical to the continuous model. This lazy-evaluation-of-elapsed-time trick is the standard implementation of every token bucket you will meet.
min(self.capacity, ...) is the entire burst policy. Delete the min and an idle client accrues unbounded credit; come back after a day and fire 86,400 requests at once. The cap is what makes the bucket a rate limiter rather than a quota. capacity and refill_rate are therefore two genuinely independent knobs: the rate sets what you may sustain, the capacity sets how much you may spend at once. This toy sets refill_rate = capacity / window, which ties them together and is what makes the comparison against the window fair — but they need not be tied.
Tokens are fractional; admission is integral. self.tokens is a float that happily sits at 2.5, but the test is >= 1. Partial credit accumulates and is never lost to rounding. This is why the bucket can be precisely fair over time while still making yes/no decisions — and, as you'll see in §6, that stored fraction is exactly what makes the second divergence happen.
SlidingWindowLog.__init__ — keeping the receipts def __init__(self, max_requests, window):
self.max_requests = max_requests
self.window = window
self.log = deque()
A deque, not a list, and that is load-bearing. Eviction always removes from the front (oldest first). list.pop(0) is O(n) — it shifts every remaining element down one slot — so a list-backed log would degrade quadratically exactly when it's busiest. deque.popleft() is O(1).
SlidingWindowLog.allow — evict, then count def allow(self, now):
cutoff = now - self.window
while self.log and self.log[0] <= cutoff:
self.log.popleft()
if len(self.log) < self.max_requests:
self.log.append(now)
return True
return False
The while loop is O(1) amortized, not O(n). It looks like a scan, and a single call can indeed pop many entries — but every entry is appended exactly once and popped at most once, so the total popping across N calls is bounded by N. The cost per call averages to a constant. A worst-case single call can still be slow, which matters if you're chasing tail latency, but the throughput is linear.
The <= is not a detail. cutoff = now - window, and an entry is evicted when it is <= cutoff — that is, an entry exactly window seconds old is considered expired. At t=5.0 with a 5-second window, cutoff = 0.0, and the entries recorded at 0.0 satisfy 0.0 <= 0.0. All five are dropped and the window is empty.
Change that one character to < and the entries survive, because 0.0 < 0.0 is false. I ran both against the demo trace rather than reasoning about it:
One character flips the result from all five admitted to all five rejected. Half this toy's headline aha rests on it. This is also a fair warning about the algorithm in general: its behaviour at the exact boundary is a judgement call, and the two defensible answers are maximally different.
demo.py drives both limiters with one trace: a burst of 5 at t=0, a single request at t=2.5, then another burst of 5 at exactly t=5.0.
TRACE = (
[(0.0, "burst #1") for _ in range(5)]
+ [(2.5, "lone request")]
+ [(5.0, "burst #2") for _ in range(5)]
)
Both are configured for the same average rate — refill_rate = CAPACITY / WINDOW = 1.0 tokens/sec, versus 5 requests per 5s window:
bucket = TokenBucket(capacity=CAPACITY, refill_rate=CAPACITY / WINDOW)
window = SlidingWindowLog(max_requests=CAPACITY, window=WINDOW)
Running it:
python3 demo.py
Bucket. After burst #1 it holds 0 tokens, last_refill = 0.0. At t=2.5: elapsed = 2.5, so tokens = min(5, 0 + 2.5 × 1.0) = 2.5. Since 2.5 >= 1, admit, leaving 1.5. The bucket has been quietly refilling and has credit to spend.
Log. cutoff = 2.5 - 5.0 = -2.5. Every entry sits at 0.0, and 0.0 <= -2.5 is false, so nothing is evicted. len(log) == 5, which is not < 5. Deny. All five original requests are still inside the trailing five-second window, so by an exact count the client has already spent its allowance.
Both are correct readings of "5 per 5 seconds." They disagree because they answer which five seconds? differently: the log means "the last five seconds, right now"; the bucket means "amortised, continuously."
Log. cutoff = 0.0. The five entries at 0.0 satisfy 0.0 <= 0.0 and are all evicted (§5.5). The log is empty; five appends all succeed. The window rolled over and the allowance reset completely and instantly.
Bucket. It carried 1.5 tokens out of t=2.5, and gains 2.5 × 1.0 = 2.5 more, for 4.0. Four requests are admitted, draining it to 0.0; the fifth finds 0.0 >= 1 false and is denied. Recovery is gradual: the bucket never hands back a full allowance at an instant.
Here is the part the summary tables miss. The bucket denies the fifth request at t=5.0 because it allowed the lone request at t=2.5. That earlier allow spent a token that never came back. Remove it from the trace and the bucket arrives at t=5.0 with a full 5.0 tokens and admits all five.
Rather than assert that, I ran it:
python3 test_rate_limiter.py
test_demo_trace_divergence (test_rate_limiter.py lines 12–33) asserts the exact divergence above, so the claim in this commentary can't rot silently. The other three are unit checks: the cap holds after a long idle, the drained bucket admits one request per 1/refill_rate seconds, and eviction drops entries at the boundary.
The obvious third algorithm — bump a counter, reset it every 5 seconds — is absent on purpose. It's cheap (one integer per client) and it's what people reach for first, but it has a notorious flaw: a client can fire the full allowance at the very end of one window and again at the start of the next, achieving twice the intended rate across the boundary.
The sliding window log exists precisely to fix that. Including the broken version would have made the toy a survey of three algorithms rather than a sharp comparison of the two real trade-offs. If you want to feel it, it's a five-line class — write it and run the demo trace through it.
This is the honest tension, and it is worth being precise about, because the obvious version of the criticism is wrong.
Per client, the bucket stores two floats, forever, whatever the traffic. It's tempting to say the log stores one timestamp per request — but read §5.5 again: append happens only inside the len(self.log) < self.max_requests branch, so the log cannot grow past max_requests. I checked:
A thousand requests, five entries. So memory is O(max_requests) per client — proportional to the limit you configured, not to the traffic you receive. That's a much better property than the naive reading suggests, and it means a client hammering you does not cost you extra memory.
It's still meaningfully worse than the bucket, though, because limits are often large. "10,000 requests per hour" means up to 10,000 timestamps resident per active client, against the bucket's two floats — a ~40,000× difference in state for the same policy, multiplied by every client you're tracking.
The log is here because it's the exact answer, and the exact answer is the right thing to compare an approximation against. In production the usual choice is a third option this toy deliberately doesn't implement: the sliding window counter, which keeps two fixed-window counters and blends them by how far into the current window you are. Cloudflare's formula:
rate = 42 * ((60-15)/60) + 18 = 42 * 0.75 + 18 = 49.5 requests
That's O(1) memory, no boundary doubling, and a small approximation error — it assumes the previous window's requests were spread evenly, which they weren't. For nearly all real workloads that error is irrelevant and the memory saving is not.
allow(now) rather than allow()Covered in §5.1, but to state the trade-off plainly: pushing the clock to the caller makes the library deterministic and testable at the cost of a slightly awkward API. A production version would wrap it — allow() calling allow_at(time.monotonic()) — to get both. This toy skips the wrapper because the whole point is that you supply the trace.
They implement the same interface and share zero code, which is a fair description of the algorithms themselves. An ABC with an abstract allow would add a file's worth of ceremony and hide the thing worth seeing: that these two have almost nothing in common beyond their signature.
INCR-based trick), and every check now costs a network round trip.allow() is not thread-safe. Two threads can both read self.tokens == 1, both decrement, and admit two requests on one token. A real implementation needs a lock, or an atomic operation in the backing store.False. Real limiters usually queue and delay rather than reject (nginx's limit_req does this by default, rejecting only once the queue exceeds burst), and return 429 with a Retry-After telling the client when to come back — which the bucket can compute exactly, and so can the log.tokens -= cost) and the log does not.self.log only when allow() is next called. A client that goes silent leaves up to max_requests timestamps resident indefinitely — the memory is bounded (§7.2) but not reclaimed. Multiply by every client you have ever seen and you need a real eviction policy for the limiters themselves, a second and separate cache-expiry problem this toy doesn't have because it holds exactly one limiter.Answer before expanding. Each answer is derivable from the source.
A client is idle for an hour, then fires 10 requests at once. How many does TokenBucket(capacity=5, refill_rate=1.0) admit?
Five. elapsed is 3600, so 3600 × 1.0 = 3600 tokens would accrue, but min(self.capacity, ...) clamps to 5. Five requests drain it; the sixth finds 0.0 >= 1 false.
This is test_bucket_never_exceeds_capacity_after_long_idle (test_rate_limiter.py lines 36–42), which uses t=1000.0 and asserts exactly [True, True, True, True, True, False].
The sliding window log resets as a cliff (§6.2). Can you exploit that the way you'd exploit a fixed-window counter — 5 requests at t=4.999, then 5 more at t=5.0, for 10 in a millisecond?
No, and this is the log's whole reason for existing. I tried both timings:
The cliff is measured per entry, not against a shared clock. At t=5.0 the cutoff is 0.0, and entries stamped 4.999 are nowhere near expiring — so the second burst is denied outright. Only entries genuinely window seconds old drop out, which is why the two bursts in the working case are a full 5 seconds apart.
That's the difference between a sliding window and a fixed one. A fixed window counter shares one reset instant across all clients, so straddling it gives 2× the limit. The log gives each request its own expiry, so it admits at most max_requests in any window-length interval — exactly what it says on the tin. It's the fixed-window counter that has the boundary bug, not this.
You swap self.tokens >= 1 for self.tokens > 0. What breaks?
The bucket starts admitting on partial credit, letting the balance go negative. Sending three requests at t=2.5 (where the bucket holds 2.5 tokens) shows it:
The third is admitted on half a token. The real code denies it (0.5 >= 1 is false), so this variant admits one extra request in every drain.
It is not an unlimited overdraft, though — a fourth request finds -0.5 > 0 false and is denied, and since min(capacity, tokens + elapsed × rate) never floors at zero, the debt is real and must be waited off before anything is admitted again. So the long-run average rate is still honoured. What changes is the shape at the margin: you've traded a clean "no token, no service" rule for a borrow-then-repay one, and made the state able to represent a debt you now have to reason about.
Why does SlidingWindowLog.allow append now only when returning True?
So denials don't count against the client. If rejected requests were logged, a client that keeps retrying while limited would keep its own window permanently full and never recover — the limiter would punish retrying rather than just throttling it.
Some production limiters deliberately do log denials to discourage hammering, but that's a policy choice, not the default reading of "5 requests per 5 seconds."
Your API is behind three load-balanced servers, each running this TokenBucket in memory. A client's effective rate limit is what?
Up to 3× the intended limit — each server enforces 5-per-5s against the share of traffic it happens to see, and the shares are independent. Worse, it's non-deterministic: it depends on how the balancer distributes that client's requests.
This is the single biggest gap between the toy and production, and it's why real limiters keep state in a shared store rather than in process memory. See §8.
Every link below was fetched and confirmed live when this was written.
ngx_http_limit_req_module — a production limiter you can read the config language of. Note it delays excess requests rather than rejecting them, and that burst is a separate knob from rate — exactly the capacity-vs-refill_rate split from §5.3.