How to Build a REST API With Rate Limiting in FastAPI

Shipping a FastAPI endpoint that works on localhost is the easy part. Shipping one that survives a traffic spike, a misbehaving client, or a scraper hitting your /data route 500 times a minute — that is a different problem entirely. Rate limiting is one of the first production-hardening steps most tutorials skip. This guide does not skip it.

We will build a REST API with FastAPI, then layer in a token-bucket rate limiter backed by Redis. By the end you will have a pattern you can drop into any real project.


Why Token Bucket Over Other Algorithms?

Three common rate limiting strategies exist:

  • Fixed window — counts requests in a fixed time window (e.g., 100 req/min). Simple, but a client can double-burst at window boundaries.
  • Sliding window — smoother than fixed window, but more expensive to implement in Redis.
  • Token bucket — clients accumulate tokens at a steady rate up to a maximum. Each request consumes one token. No token, no request. This mirrors how real traffic should be shaped — short bursts are allowed, sustained floods are not.

Token bucket is the right default for most APIs. It tolerates legitimate bursts while protecting backend resources from sustained abuse.


Project Setup

Install the dependencies:

pip install fastapi uvicorn redis[asyncio] python-dotenv

You will need a running Redis instance. For local development, Docker works fine:

docker run -d -p 6379:6379 redis:7-alpine

Implementing the Token-Bucket Rate Limiter

The Core Logic

The token-bucket algorithm needs three values per client key:

  1. Current token count
  2. Last refill timestamp
  3. Bucket capacity and refill rate

We implement this in a single async function that reads and writes to Redis atomically using a Lua script. Atomicity is critical — without it, two concurrent requests can both read a "full" bucket and both succeed when only one should.

import time
import redis.asyncio as aioredis
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

app = FastAPI()

# Redis connection
redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

# Lua script for atomic token-bucket check
RATE_LIMIT_SCRIPT = """
local key        = KEYS[1]
local capacity   = tonumber(ARGV[1])
local rate       = tonumber(ARGV[2])   -- tokens per second
local now        = tonumber(ARGV[3])

local data       = redis.call("HMGET", key, "tokens", "last_refill")
local tokens     = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now

-- Refill tokens based on elapsed time
local elapsed    = math.max(0, now - last_refill)
local refill     = elapsed * rate
tokens           = math.min(capacity, tokens + refill)

if tokens >= 1 then
    tokens = tokens - 1
    redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
    redis.call("EXPIRE", key, 3600)
    return 1
else
    redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
    redis.call("EXPIRE", key, 3600)
    return 0
end
"""

registered_script = None

async def is_allowed(client_id: str, capacity: int = 10, rate: float = 1.0) -> bool:
    global registered_script
    if registered_script is None:
        registered_script = redis_client.register_script(RATE_LIMIT_SCRIPT)

    now = time.time()
    result = await registered_script(
        keys=[f"rate_limit:{client_id}"],
        args=[capacity, rate, now]
    )
    return result == 1

A few things worth noting here:

  • capacity is the maximum burst size (10 requests).
  • rate is 1 token per second, meaning a sustained throughput of 60 requests per minute.
  • The Lua script runs atomically on the Redis server — no race conditions, no extra round trips.

Wiring It Into FastAPI as Middleware

Rather than decorating every route individually, use FastAPI middleware so the limiter applies globally and stays out of your business logic:

@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    # Use client IP as the identifier; swap for API key in authenticated routes
    client_id = request.client.host

    allowed = await is_allowed(client_id, capacity=20, rate=2.0)

    if not allowed:
        return JSONResponse(
            status_code=429,
            content={"detail": "Too many requests. Please slow down."},
            headers={"Retry-After": "1"},
        )

    response = await call_next(request)
    return response


@app.get("/api/v1/items")
async def list_items():
    return {"items": ["widget", "gadget", "component"]}

@app.get("/api/v1/health")
async def health():
    return {"status": "ok"}

For authenticated APIs, replace request.client.host with the user's API key or JWT subject claim. Per-user limiting is far more accurate and fair than IP-based limiting, which breaks behind NATs and shared corporate networks.


What to Watch in Production

Headers Tell Clients What Is Happening

A 429 response alone is not enough. Clients need to know when to retry. Extend the middleware to return rate limit headers on every response:

  • X-RateLimit-Limit — the bucket capacity
  • X-RateLimit-Remaining — tokens left after this request
  • Retry-After — seconds until the next token is available

This makes your API a good citizen and reduces support noise from confused integrators.

Redis Availability

Your rate limiter now depends on Redis. If Redis goes down, decide upfront: fail open (allow all requests) or fail closed (block all requests). For most public APIs, failing open is the safer UX choice — wrap the Redis call in a try/except and default to True on connection errors, then alert your on-call team.

Tiered Limits by Client Tier

SaaS products routinely offer different rate limits per plan. The middleware pattern above makes this straightforward — look up the client's plan from a cache or database, then pass the appropriate capacity and rate values into is_allowed(). No changes to the core Lua script required.


Common Mistakes to Avoid

  • Using in-memory state for rate limiting — this breaks the moment you run more than one API worker or container. Always use a shared store like Redis.
  • Rate limiting only at the application layer — for high-traffic APIs, add rate limiting at your API gateway (Kong, AWS API Gateway, Nginx) as a first line of defense. Application-layer limiting is your second line.
  • Ignoring clock skew — if you run multiple Redis nodes, use a single primary for rate limit keys or account for replication lag. Slight over-counting is acceptable; severe under-counting is not.

Why This Matters for Your Project

Whether you are building a SaaS product, a mobile app backend, or an internal microservice, an unprotected API is a liability. A single runaway client — or a well-intentioned load test pointed at the wrong environment — can cascade into downtime for everyone. Implementing token-bucket rate limiting with Redis takes an afternoon and protects the reliability guarantees your users depend on. The pattern shown here scales from a single-server startup to a multi-region deployment with minimal modification. Ship the feature, then harden the gate.