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

  • slowapi installed 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 request parameter. SlowAPI's decorator needs the Request in the signature; omitting it raises at call time.
  • Proxies. Behind a load balancer, request.client.host may 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