FastAPI Rate Limiting with Redis and SlowAPI
Key takeaways:
- SlowAPI provides decorator-based rate limits for FastAPI routes.
- Back it with Redis so the limit is shared across all workers.
- Use a custom key function to limit by API key rather than IP.
- Exceeded limits return HTTP 429 with
Retry-After. - Apply broad and per-route limits together for layered protection.
This guide is the library-based path from Rate Limiting and Throttling. Read that page for why the counter must be shared.
The Problem This Solves
You want rate limiting without hand-rolling counters, and it must be correct across many workers. SlowAPI gives you a decorator API, and Redis storage makes the limit authoritative across the whole deployment.
Prerequisites
slowapiinstalled and a Redis instance reachable from every worker.- A FastAPI app constructed via a factory.
Step-by-Step Implementation
1. Create the limiter with a key function and Redis storage
# app/limiter.py
from slowapi import Limiter
from starlette.requests import Request
def client_key(request: Request) -> str:
# Limit by API key when present, else by client IP.
return request.headers.get("x-api-key") or request.client.host
limiter = Limiter(key_func=client_key, storage_uri="redis://localhost:6379")
2. Register it on the app
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.limiter import limiter
def create_app():
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware) # Applies default limits globally.
return app
3. Apply per-route limits
from fastapi import Request
from app.limiter import limiter
@router.post("/exports")
@limiter.limit("5/minute") # Tighter limit on an expensive endpoint.
async def create_export(request: Request) -> dict[str, str]:
return {"status": "queued"}
Edge Cases and Gotchas
- The
requestparameter. SlowAPI's decorator needs theRequestin the signature; omitting it raises at call time. - Proxies. Behind a load balancer,
request.client.hostmay be the proxy; read the forwarded client IP from a trusted header. - Default vs route limits. A global default plus per-route overrides can interact; verify the effective limit on each route.
Verification
def test_limit_returns_429(client):
for _ in range(5):
assert client.post("/exports").status_code == 200
blocked = client.post("/exports")
assert blocked.status_code == 429
assert "Retry-After" in blocked.headers
Related Reading
- Up to the topic: Rate Limiting and Throttling.
- Related guides: Per-User Token Bucket Throttling and Middleware Implementation.