Skip to content

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. Never 500, which tells clients your server is broken, and never 200 with an error in the body.
  • Retry-After, in whole seconds, rounded up, so a client that waits that long will get through.
  • RateLimit-Limit and RateLimit-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 429 with Retry-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-Reset header: 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?