Rate limiting
There is no rate limiting in FastAPI or Starlette. The shipped middleware is
authentication, cors, errors, exceptions, gzip, httpsredirect, sessions,
trustedhost and wsgi — nothing that counts requests.
So this is a control you add, and the interesting part is not choosing a library. It is that whatever you choose has to answer “who is this?”, and on FastAPI that answer comes from a setting on a different page.
The key function is the whole control
Section titled “The key function is the whole control”slowapi is the common choice. Its default key function, in full:
def get_remote_address(request: Request) -> str: """ Returns the ip address for the current request (or 127.0.0.1 if none found) """ if not request.client or not request.client.host: return "127.0.0.1"
return request.client.hostrequest.client.host is the peer address unless uvicorn has been told to honour
forwarded headers. Behind a proxy without that configuration it is the proxy’s
address, identically, for every visitor — so a limiter of “5 per minute” becomes five
requests per minute across your entire user base, and the first legitimate burst locks
everyone out.
Configure it the other way, with --forwarded-allow-ips='*', and the key becomes a
value the caller supplies. Rotating X-Forwarded-For then defeats the limiter
completely.
Both failures are silent. One throttles everybody, the other throttles nobody, and neither logs anything unusual. Read proxy headers before this page — the limiter inherits whatever that decides.
Note the fallback in that function too: when there is no client at all, every such
request keys to the literal 127.0.0.1 and shares one bucket.
from slowapi import Limiter, _rate_limit_exceeded_handlerfrom slowapi.errors import RateLimitExceededfrom slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address, headers_enabled=True)app.state.limiter = limiterapp.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/login")@limiter.limit("5/minute")async def login(request: Request, form: LoginForm): ...Two details that are easy to get wrong. The decorated function must take a
parameter named request — slowapi finds the request by name, and omitting it raises
at call time rather than at import. And headers_enabled=True adds the RateLimit
response headers, which is what makes the next section possible.
Rate limit the credential, not only the address
Section titled “Rate limit the credential, not only the address”An IP limit stops one host trying many passwords. It does not stop a distributed attempt at one account, because each source makes few requests.
For login specifically, key on the submitted username as well, so attempts against a single account are counted wherever they come from:
@limiter.limit("5/minute") # per IP@limiter.limit("10/hour", key_func=lambda r: r.path_params.get("username", ""))Two limits on one endpoint is normal, and they close different attacks. Be careful that the account-keyed limit cannot be used to lock a victim out on purpose — prefer throttling the response over rejecting it outright, or pair it with a signal the attacker cannot control.
for i in $(seq 1 12); do printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' \ -X POST https://api.example.com/login \ -H 'Content-Type: application/json' \ -d '{"username":"probe","password":"wrong"}')"done; echoYou want the codes to change to 429 partway through the run. If all twelve return the
same status, the limiter is not engaged on that route.
Then confirm the limiter is keying on distinct clients rather than lumping them together — the check that catches the proxy misconfiguration:
curl -si -X POST https://api.example.com/login -H 'X-Forwarded-For: 203.0.113.7' \ -d '{}' | grep -i '^ratelimit'Vary that header across a few values. If the remaining quota keeps falling regardless, every caller shares one bucket. If it resets to full for each new value, the header is being trusted and the limiter is bypassable.
In-memory storage does not survive more than one worker
Section titled “In-memory storage does not survive more than one worker”slowapi’s default storage is in-process. uvicorn --workers 4 gives you four
independent counters, so the effective limit is roughly four times what you configured,
and a rolling deploy resets all of them.
For anything that matters, point it at shared storage:
limiter = Limiter(key_func=get_remote_address, storage_uri="redis://cache:6379")This is worth knowing even at one worker, because it becomes wrong the moment somebody scales the deployment and nothing about the change looks related to rate limiting.
Rate limits break automation you own before they inconvenience anyone else: the
monitoring probe hitting /login as a synthetic check, the integration test suite in
CI, a mobile client retrying aggressively on a flaky connection, and any batch job
running from a single address.
The 429s surface as intermittent failures in systems that are not the API, so the diagnosis usually starts in the wrong place.
Deploy in observe mode first if the library allows it, or start with a limit far above observed traffic and tighten once you have seen a week of real numbers:
# requests per source per minute, from your access logawk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20Exempt your health check path explicitly rather than relying on it staying under the limit.
Related
Section titled “Related”- Proxy headers — the setting this control’s key function depends on
- Request size limits — the other unbounded-input control with nothing shipped by default