FastAPI Rate Limiting with Redis and SlowAPI

Key takeaways:

  • SlowAPI gives you decorator-based limits; Redis storage is what makes them correct across workers.
  • Every decorated endpoint must declare a Request parameter or it fails at call time, not import time.
  • The storage strategy — fixed versus moving window — decides how boundaries behave.
  • A fixed window lets a client send twice the limit around a boundary; this is measured below.
  • Key on the authenticated principal, because anything the client can set, the client can change.

This guide is the library-based path from Rate Limiting and Throttling. It covers wiring, storage and identity. If you are implementing the algorithm rather than adopting a library, see per-user token bucket throttling; for the shape of the rejection itself, see rate limit headers and 429 responses.

The Problem This Solves

You want limits without maintaining counter code, and you want them to hold across a deployment rather than per process. SlowAPI supplies the decorators and the storage abstraction; Redis supplies the shared state. Most of the difficulty is in three details the quickstart glosses over: the mandatory Request parameter, the choice of window strategy, and how you identify a client.

Why It Happens: Where the Counter Lives

A rate limiter is a counter, a key, and a clock. Everything that goes wrong follows from getting one of those three wrong.

The key decides who is limited. Key on request.client.host behind a load balancer and that value is the proxy's address, so all your users share a single bucket and the limit fires for everyone at once. Key on a client-supplied header and a client that hits the limit simply changes the header.

The counter must be shared. A typical deployment runs four or more Uvicorn workers, each a separate process with separate memory. SlowAPI's default in-memory storage therefore means four independent counters, so the effective global limit is roughly four times what you configured, and it drifts as the load balancer distributes connections unevenly. This is the most common reason a limit that "works" in staging — one worker — fails in production.

The clock and window decide how the count resets, and this is subtler than it looks. A fixed window divides time into aligned buckets and counts within the current one. It is one Redis key with INCR and an expiry, which is why nearly every simple limiter uses it. Its flaw is at the boundary.

Why a fixed window admits twice the limit at a boundaryFive requests land at the end of one fixed window and five more at the start of the next, so ten pass within two seconds. A moving window counts the trailing sixty seconds continuously and rejects the second group.Fixed window: two buckets, five each, ten in one secondwindow A ends t=605 allowed at t=59window B starts t=605 allowed at t=60boundaryMoving window: trailing 60s, no boundary to exploit5 allowed at t=59quota now spent5 rejected at t=60trailing count is 5

Prerequisites

  • slowapi and a Redis instance reachable from every worker.
  • An app built by a factory, so the limiter exists before routes are registered.

Why the Window Strategy Matters

SlowAPI's storage layer exposes fixed-window and moving-window strategies. The difference sounds academic until you measure it. Below, both strategies receive identical traffic against a limit of five per sixty seconds: five requests at t=59, then five more at t=60. The clock is frozen so the boundary is hit exactly.

Redis is not available in this site's verification environment, so this runs the same arithmetic in-process. The counters are dictionaries rather than Redis keys, and the clock is a variable rather than time.time(); the window logic under test is unchanged.

LIMIT = 5
WINDOW = 60.0

CLOCK = {"now": 0.0}   # Frozen clock; real code uses time.time() or Redis TIME.

FIXED: dict[str, tuple[int, float]] = {}    # key -> (count, window_start)
LOG: dict[str, list[float]] = {}            # key -> timestamps, for the sliding window


def fixed_window(key: str) -> bool:
    """Counts hits per aligned bucket, resetting the count when the bucket rolls over."""
    now = CLOCK["now"]
    bucket = (now // WINDOW) * WINDOW
    count, start = FIXED.get(key, (0, bucket))
    if start != bucket:
        count, start = 0, bucket
    if count >= LIMIT:
        FIXED[key] = (count, start)
        return False
    FIXED[key] = (count + 1, start)
    return True


def sliding_window(key: str) -> bool:
    """Counts hits in the trailing WINDOW seconds, with no bucket boundary to exploit."""
    now = CLOCK["now"]
    hits = [t for t in LOG.get(key, []) if t > now - WINDOW]
    if len(hits) >= LIMIT:
        LOG[key] = hits
        return False
    hits.append(now)
    LOG[key] = hits
    return True

Both strategies judge the same request, so their verdicts sit side by side in the real output:

$ GET /selftest
200 OK
{
  "steps": [
    "t= 59.0  fixed=allow  sliding=allow",
    "t= 59.0  fixed=allow  sliding=allow",
    "t= 59.0  fixed=allow  sliding=allow",
    "t= 59.0  fixed=allow  sliding=allow",
    "t= 59.0  fixed=allow  sliding=allow",
    "t= 60.0  fixed=allow  sliding=429",
    "t= 60.0  fixed=allow  sliding=429",
    "t= 60.0  fixed=allow  sliding=429",
    "t= 60.0  fixed=allow  sliding=429",
    "t= 60.0  fixed=allow  sliding=429"
  ],
  "totals": {
    "fixed_allowed": 10,
    "sliding_allowed": 5,
    "limit_per_window": 5,
    "elapsed_seconds": 1.0
  }
}

Ten requests admitted within one second, against a limit of five per minute. The fixed window is not malfunctioning — each individual window contains exactly five hits — but a client that aligns its bursts with the boundary gets double throughput, and a client under load discovers that alignment by accident. If your limit protects something with a hard capacity ceiling, such as a downstream API with its own quota, either size the fixed window for twice the limit or use the moving window.

The moving window is not free: it stores a timestamp per hit rather than a single integer, so memory scales with the limit as well as with the number of clients. For a limit of 5 that is nothing. For 10,000 per hour per client, it is a real amount of Redis.

The Fix: Wiring SlowAPI

# app/limiter.py
from slowapi import Limiter
from starlette.requests import Request


def client_key(request: Request) -> str:
    # Prefer the authenticated principal; the API key is a fallback, the IP a last resort.
    user = getattr(request.state, "user", None)
    if user is not None:
        return f"user:{user.id}"
    api_key = request.headers.get("x-api-key")
    return f"key:{api_key}" if api_key else f"ip:{request.client.host}"


limiter = Limiter(
    key_func=client_key,
    storage_uri="redis://redis:6379/0",   # Shared by every worker on every host.
    strategy="moving-window",             # No boundary burst; costs more memory.
)
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware


def create_app() -> FastAPI:
    app = FastAPI()
    app.state.limiter = limiter
    app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
    app.add_middleware(SlowAPIMiddleware)   # Applies the default limits to every route.
    return app
@router.post("/exports")
@limiter.limit("5/minute")
async def create_export(request: Request) -> dict[str, str]:
    # `request` is unused here, but SlowAPI resolves it from the arguments at call time.
    # Remove it and this endpoint raises on its first call, not at import.
    return {"status": "queued"}

That last comment is the practical trap. SlowAPI's decorator looks for a Request among the arguments it receives, and FastAPI only supplies one when the parameter is declared. Nothing checks this at import, so the module imports cleanly, passes any test that does not exercise the route, and fails on first use in production. Make it a review rule: every @limiter.limit sits above a function with request: Request in its signature.

Registration order matters too. The exception handler must be installed before a limit can fire, or the raised RateLimitExceeded surfaces as a 500 instead of a 429. SlowAPIMiddleware applies only the default limits — per-route decorators work without it.

Identity behind a proxy

request.client.host is the address of whatever opened the TCP connection. Behind a load balancer, that is the balancer. The real client address is in X-Forwarded-For, but that header is client-writable, so trusting it unconditionally lets anyone reset their own limit by sending a fresh random value.

The correct arrangement is Uvicorn's --proxy-headers together with --forwarded-allow-ips set to your proxy's address, so the header is honoured only when the connection genuinely came from that proxy. request.client.host then holds the real client and your key function needs no special handling at all. Doing this in application code instead means reimplementing the trust check, usually incorrectly.

Verification

Storage is the part most likely to be misconfigured, so test against the storage you deploy:

def test_limit_is_shared_across_app_instances(redis_url):
    # Two apps, two Limiter objects, one Redis: the second sees the first's counter.
    a, b = make_app(redis_url), make_app(redis_url)
    for _ in range(5):
        assert TestClient(a).post("/exports").status_code == 200
    assert TestClient(b).post("/exports").status_code == 429

That single assertion is what catches the in-memory default, and it is worth more than any number of single-app tests. If both clients return 200, the storage URI is not being applied — a common outcome when the limiter is constructed at import time, before settings are loaded.

In production, confirm the shared counter under load with redis-cli --scan --pattern 'LIMITER*'. Keys present and expiring means it is working; an empty keyspace while limits still fire means each worker is counting alone.

Trade-offs and When Not To

Every request costs a network round trip. The limiter calls Redis on the hot path, so its latency is added to every response, and Redis being unreachable becomes an availability question for the whole API. Decide deliberately whether to fail open (serve traffic unlimited) or fail closed (reject everything), and test that behaviour rather than assuming it.

A limiter is not a defence against a real flood. By the time a request reaches Python you have already paid for TLS termination, ASGI parsing and routing. Volumetric attacks belong at the CDN or load balancer; SlowAPI protects you against a heavy or buggy client, not a botnet.

Per-route decorators scatter policy. Twenty routes with twenty literal limits become impossible to reason about, and nobody can answer "what is the limit for a free-tier user" without grepping. Keep the values in configuration and reference named tiers.

Skip the library for a single limit. If you need one global policy, a small middleware with one atomic Redis operation is less machinery than a dependency carrying its own decorator semantics and storage layer.

FAQ

Why does SlowAPI raise an error about a missing Request parameter? The decorator inspects the wrapped function's arguments at call time to find the Request object its key function needs. FastAPI only passes a Request if the parameter is declared, so a decorated endpoint without one fails at runtime rather than at import, which is why it often reaches production.

Why does SlowAPI need Redis storage in production? The default in-memory storage counts per process. With several Uvicorn workers each keeps its own counter, so a client load-balanced across four workers can send roughly four times the limit while every worker believes it is enforcing the policy. Redis gives all workers one authoritative counter.

What is the difference between fixed-window and moving-window strategy? A fixed window counts hits inside aligned time buckets and resets at each boundary, which lets a client send up to twice the limit across that boundary. A moving window counts the trailing period continuously, so there is no boundary to exploit, at the cost of storing individual hit timestamps.

How do I rate limit by API key rather than IP address? Pass a key function that returns the API key from the request and falls back to the client host when no key is present. Prefer the authenticated principal over any raw header, because clients control headers and a spoofable key means a spoofable limit.

Is it safe to use request.client.host behind a load balancer? No. Behind a proxy that value is the proxy's address, so every client shares one bucket. Read the client address from a forwarded header, but only trust that header when the request came from a proxy you control, or clients will forge it to reset their own limits.