Chapter 02 · 15 minutes
Fixed window
The simplest limiter: count requests in each one-second window. It is easy to build and it has a hole you can drive twice the traffic through.
Goal
Read the fixed window limiter, put it in front of the API, and then find its weak spot with the simulator.
How it works
Time is cut into windows: 0 to 1 second, 1 to 2, and so on. Each key gets a counter per window. When the counter reaches the limit, the rest of that window is rejected. Here is all of it, from lab/limits/fixed_window.py:
def allow(self, key: str, now: float) -> Decision:
current = int(now // self.window)
number, count = self._counts.get(key, (current, 0))
if number != current:
count = 0 # a new window: the count starts again
if count >= self.limit:
return Decision(False, 0, (current + 1) * self.window - now)
self._counts[key] = (current, count + 1)
return Decision(True, self.limit - count - 1, 0.0)
It stores two numbers per key, and retry_after is simply the time left until the next window starts.
Put it in front of the API
Stop the server from chapter 01 and start it with a limit of 5 per second:
uv run python -m limits serve --limiter fixed-window --limit 5
Then hammer it again from the second terminal:
uv run python -m limits hammer
200 x5
429 x35
40 requests in 0.59s
Five got through, 35 were turned away with 429 Too Many Requests. That looks like a working limit.
Find the hole
The simulator replays traffic against a limiter with a fake clock, so you can test things that are hard to time by hand. The edge pattern sends 20 requests between 0.90 and 1.09 seconds, right across the edge between two windows:
uv run python -m limits simulate --pattern edge
pattern edge: 20 requests, limit 10 per second
limiter allowed rejected worst 1s
none 20 0 20
fixed-window 20 0 20
sliding-log 10 10 10
token-bucket 11 9 11
worst 1s is the most requests your backend received inside any one second. The fixed window allowed all 20 in a fifth of a second: 10 at the end of the first window, then 10 more at the start of the next. The limit says 10 per second, and your backend just took 20.
This is the classic weakness of fixed windows. A client that learns where your windows start can always send twice the limit in a burst.
Check it works
hammeragainst--limiter fixed-window --limit 5shows exactly 5 responses with200.- In the
edgesimulation,fixed-windowhasworst 1sof 20.
Exercise
Change the edge pattern in lab/limits/simulate.py so it starts at 0.50 instead of 0.90, and run the simulation again. Does the fixed window still let 20 through? Explain why, then put it back.
More to try
- Run the simulator with
--limit 5. What is the fixed window's worst second now? Find the rule: it is always a multiple of the limit. - The dictionary
_countsnever forgets a key. With a million different API keys, what happens to memory? Sketch a fix.