Chapter 05 · 12 minutes
Say no properly: HTTP 429
A limit is only useful if clients can tell they hit it and know when to come back. Status code, headers and a key that cannot be faked.
Goal
Read how the server turns a Decision into an HTTP response, inspect the headers yourself, and see why the choice of key matters as much as the algorithm.
The response
When a request is rejected, the server in lab/limits/server.py answers with:
429 Too Many Requests, the status code made for this. Never500, which tells clients your server is broken, and never200with an error in the body.Retry-After, in whole seconds, rounded up, so a client that waits that long will get through.RateLimit-LimitandRateLimit-Remaining, on every response, so a well-behaved client can slow down before it is rejected at all.
# Whole seconds, rounded up, so a client that waits this long will get through.
headers["Retry-After"] = str(math.ceil(decision.retry_after))
See it
Start the server with a small limit:
uv run python -m limits serve --limiter fixed-window --limit 2
Then send three requests in a row with curl (Windows has it too):
curl -i -H "X-Api-Key: demo" http://127.0.0.1:8000/api/hello
curl -i -H "X-Api-Key: demo" http://127.0.0.1:8000/api/hello
curl -i -H "X-Api-Key: demo" http://127.0.0.1:8000/api/hello
Watch RateLimit-Remaining count down, then the third answer turn into 429 with a Retry-After header. If you are slow, the window may roll over between commands: run them on one line, separated by ;.
Try /health as many times as you like. It is never limited, because load balancers and uptime checks poll it, and a health check that fails because of a rate limit takes your service out of rotation.
The key is the real decision
# Who is asking: the API key when there is one, otherwise the client's address.
key = self.headers.get("X-Api-Key") or self.client_address[0]
In this lab any string works as an API key, so a client can dodge the limit by sending a new key each time. Try it:
uv run python -m limits hammer --key alice
uv run python -m limits hammer --key bob
Each key gets its own budget. In production the key must be something the client cannot invent: an API key you issued and checked, a user id from a verified session, or the address of the client. Behind a proxy or load balancer, the address is the proxy's, so you read it from X-Forwarded-For, and only trust that header when it was set by your own proxy.
Check it works
- The third curl request answers
429withRetry-After: 1. uv run pytest -k "429 or separately"passes.
Exercise
Make the server reject unknown keys. Add a set of allowed keys to LimitedServer in lab/limits/server.py (for example {"alice", "bob"}), answer 401 with {"error": "unknown_key"} for anything else, and add a test in lab/tests/test_http.py. Should the 401 count against a rate limit too? Decide, and write down why.
More to try
- Add a
RateLimit-Resetheader: seconds until the client has its full budget back. Each limiter computes it differently; start with the fixed window. - Give different keys different limits, for example a free tier of 5 per second and a paid tier of 50. Where does that table live?