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:
- Current token count
- Last refill timestamp
- 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:
capacityis the maximum burst size (10 requests).rateis 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 capacityX-RateLimit-Remaining— tokens left after this requestRetry-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.





