Rate limiting distribuido con FastAPI y Redis
Implementa rate limiting distribuido en FastAPI usando algoritmos de sliding window y token bucket con Redis para límites por usuario, IP y endpoint.
El rate limiting evita que una API sea abusada, saturada o agotada por demasiadas peticiones. Si ejecutás varias instancias de servidor detras de un load balancer, un rate limiter en memoria no sirve porque cada instancia tiene su propio contador. Redis lo soluciona dándole a todas las instancias un contador compartido y atomico. Esta receta cubre rate limiting con sliding window y token bucket en FastAPI usando Redis.
Cuándo Usar
Usá esta configuración cuando tu API corra en varias instancias de servidor detras de un load balancer, cuando
necesites límites por usuario o por IP en endpoints públicos, o cuando distintos endpoints necesiten distintos
límites (por ejemplo, auth: 5/min y search: 100/min).
Requisitos Previos
Necesitás Python 3.10 o superior, los paquetes fastapi y redis instalados, y un servidor Redis corriendo.
Solucion
1. Instalar dependencias
pip install fastapi redis
2. Rate limiter sliding window
import time
import redis
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
class SlidingWindowRateLimiter:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def is_allowed(
self,
key: str,
max_requests: int,
window_seconds: int,
) -> tuple[bool, dict]:
"""Check if a request is allowed using sliding window algorithm.
Args:
key: Unique identifier (user_id, IP, etc.).
max_requests: Maximum requests in the window.
window_seconds: Window size in seconds.
Returns:
Tuple of (allowed, info_dict with remaining, reset_at).
"""
now = time.time()
window_start = now - window_seconds
pipe = self.redis.pipeline()
# Remover entradas viejas fuera de la ventana
pipe.zremrangebyscore(key, 0, window_start)
# Contar entradas actuales en la ventana
pipe.zcard(key)
# Agregar peticion actual
pipe.zadd(key, {str(now): now})
# Establecer TTL en la key
pipe.expire(key, window_seconds)
results = pipe.execute()
current_count = results[1]
allowed = current_count < max_requests
remaining = max(0, max_requests - current_count - 1)
return allowed, {
"limit": max_requests,
"remaining": remaining,
"reset_at": int(now + window_seconds),
}
rate_limiter = SlidingWindowRateLimiter(redis_client)
3. Middleware de FastAPI
from typing import Callable
from starlette.middleware.base import BaseHTTPMiddleware
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
rate_limiter: SlidingWindowRateLimiter,
max_requests: int = 100,
window_seconds: int = 60,
):
super().__init__(app)
self.rate_limiter = rate_limiter
self.max_requests = max_requests
self.window_seconds = window_seconds
async def dispatch(self, request: Request, call_next: Callable):
# Obtener identificador — IP o user ID del token
client_ip = request.client.host if request.client else "unknown"
key = f"rate_limit:{client_ip}"
allowed, info = self.rate_limiter.is_allowed(
key, self.max_requests, self.window_seconds
)
if not allowed:
return JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded",
"limit": info["limit"],
"remaining": info["remaining"],
"reset_at": info["reset_at"],
},
headers={
"Retry-After": str(self.window_seconds),
"X-RateLimit-Limit": str(info["limit"]),
"X-RateLimit-Remaining": str(info["remaining"]),
"X-RateLimit-Reset": str(info["reset_at"]),
},
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(info["limit"])
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(info["reset_at"])
return response
app.add_middleware(
RateLimitMiddleware,
rate_limiter=rate_limiter,
max_requests=100,
window_seconds=60,
)
4. Limites por endpoint con decorador
from functools import wraps
from fastapi import Depends, HTTPException, Request
def rate_limit(max_requests: int, window_seconds: int, key_func=None):
"""Decorator for per-endpoint rate limiting.
Args:
max_requests: Maximum requests in the window.
window_seconds: Window size in seconds.
key_func: Function to extract the rate limit key from the request.
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get("request")
if not request:
for arg in args:
if isinstance(arg, Request):
request = arg
break
if key_func:
key = key_func(request)
else:
key = f"rate_limit:{request.url.path}:{request.client.host}"
allowed, info = rate_limiter.is_allowed(
key, max_requests, window_seconds
)
if not allowed:
raise HTTPException(
status_code=429,
detail={
"error": "Rate limit exceeded",
"limit": info["limit"],
"remaining": info["remaining"],
"reset_at": info["reset_at"],
},
headers={
"Retry-After": str(window_seconds),
"X-RateLimit-Limit": str(info["limit"]),
"X-RateLimit-Remaining": str(info["remaining"]),
},
)
return await func(*args, **kwargs)
return wrapper
return decorator
@app.post("/auth/login")
@rate_limit(max_requests=5, window_seconds=60)
async def login(request: Request):
return {"message": "Login endpoint with strict rate limit"}
@app.get("/search")
@rate_limit(max_requests=100, window_seconds=60)
async def search(request: Request):
return {"message": "Search endpoint with standard rate limit"}
5. Rate limiting por usuario
def get_user_key(request: Request) -> str:
"""Extract user ID from JWT for per-user rate limiting."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
# Decodificar JWT para obtener user_id (simplificado)
import jwt
token = auth.split(" ")[1]
try:
payload = jwt.decode(token, "secret", algorithms=["HS256"])
return f"rate_limit:user:{payload['sub']}"
except jwt.InvalidTokenError:
pass
return f"rate_limit:ip:{request.client.host}"
@app.get("/api/data")
@rate_limit(max_requests=200, window_seconds=60, key_func=get_user_key)
async def get_data(request: Request):
return {"data": "Per-user rate limited endpoint"}
Explicación
El enfoque de sliding window guarda cada petición como miembro de un sorted set de Redis (ZSET), usando el timestamp
de la petición como score. Antes de permitir una petición, elimina las entradas más viejas que la ventana actual,
cuenta las restantes y agrega la nueva.
El pipeline de Redis ejecuta todos esos comandos en un solo round-trip, así el conteo y la adición son atómicos y evitan race conditions. Un TTL en la key hace que expire automáticamente después de la ventana, así no hay que hacer limpieza manual.
Los límites por endpoint funcionan usando distintos prefijos de key, como rate_limit:/auth/login:... y
rate_limit:/search:..., lo que mantiene el contador de cada endpoint independiente.
Los headers X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset siguen el estándar draft de la IETF, así
los clientes pueden leerlos y hacer back off antes de alcanzar el límite.
Variantes
Algoritmo token bucket
class TokenBucketRateLimiter:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def is_allowed(
self,
key: str,
capacity: int,
refill_rate: float,
) -> tuple[bool, dict]:
"""Token bucket algorithm — allows bursts up to capacity.
Args:
key: Unique identifier.
capacity: Maximum tokens in the bucket.
refill_rate: Tokens added per second.
Returns:
Tuple of (allowed, info_dict).
"""
now = time.time()
bucket_key = f"token_bucket:{key}"
# Script Lua para check-and-decrement atomico
lua_script = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Rellenar tokens
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate))
return {allowed, math.floor(tokens)}
"""
result = self.redis.eval(
lua_script, 1, bucket_key,
capacity, refill_rate, now,
)
return bool(result[0]), {
"limit": capacity,
"remaining": int(result[1]),
}
token_limiter = TokenBucketRateLimiter(redis_client)
Contador de ventana fija
class FixedWindowRateLimiter:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def is_allowed(
self,
key: str,
max_requests: int,
window_seconds: int,
) -> tuple[bool, dict]:
"""Fixed window — simpler but allows bursts at window boundary."""
now = int(time.time())
window = now - (now % window_seconds)
window_key = f"fixed_window:{key}:{window}"
pipe = self.redis.pipeline()
pipe.incr(window_key)
pipe.expire(window_key, window_seconds)
results = pipe.execute()
count = results[0]
allowed = count <= max_requests
remaining = max(0, max_requests - count)
return allowed, {
"limit": max_requests,
"remaining": remaining,
"reset_at": window + window_seconds,
}
Limites por niveles
def tiered_rate_limit(user_tier: str = "free"):
"""Apply different rate limits based on user tier."""
limits = {
"free": (100, 60), # 100 req/min
"pro": (1000, 60), # 1000 req/min
"enterprise": (10000, 60), # 10000 req/min
}
max_requests, window = limits.get(user_tier, limits["free"])
return rate_limit(max_requests=max_requests, window_seconds=window)
@app.get("/api/expensive")
@tiered_rate_limit(user_tier="pro")
async def expensive_operation(request: Request):
return {"data": "Tiered rate limited endpoint"}
Buenas Prácticas
Usá sliding window cuando la precisión importe, porque la ventana fija puede permitir bursts dobles justo en los
límites de la ventana. Establecé un header Retry-After con sentido para que los clientes sepan cuánto esperar.
Limitá por usuario cuando lo conozcas, no solo por IP. Varios usuarios detras de un NAT o proxy pueden compartir la
misma IP. Monitoreá con qué frecuencia tus endpoints retornan 429; un pico repentino puede significar un límite mal
configurado o un ataque.
Para una guía más profunda, consultá Cache Database Query Results with Redis and Python.
Errores Comunes
Usar rate limiters en memoria en despliegues distribuidos es un error común porque cada instancia termina con su propio contador. Usá Redis en su lugar. Olvidar el TTL en las keys de Redis es otro error; sin TTL, las keys se acumulan indefinidamente y consumen memoria.
Poner límites demasiado agresivos puede bloquear usuarios legítimos, así que empezá generoso y ajustá con datos reales.
Finalmente, no manejar respuestas 429 en los clientes hace que pierdas la oportunidad de hacer back off con lógica de
reintento exponencial.
Preguntas frecuentes
¿Sliding window o token bucket?
Usá sliding window para límites estrictos, como 100 req/min sin bursts. Usá token bucket cuando querés permitir un
burst corto y luego rellenar de forma constante, como 100 peticiones instantáneas y después 10 por segundo.
¿Cuánta memoria de Redis usa el rate limiting?
Sliding window guarda una entrada ZSET por petición. Para 1000 usuarios a 100 req/min, son unas 100 000 entradas
con TTL de 60 segundos — despreciable.
¿Qué pasa si Redis cae?
El rate limiting deja de funcionar. Decidí de antemano si fallar abierto (permitir todo) o cerrado (rechazar todo). Un limiter local en memoria puede servir como respaldo temporal.
¿Debería limitar por IP o por usuario?
Usá el ID de usuario para endpoints autenticados. Usá IP para endpoints públicos como login o signup. Para endpoints sensibles, combiná ambos.
¿Esta solución está lista para producción?
Los ejemplos están probados y funcionan, pero deberías adaptar el manejo de errores, la configuración y el fallback a tu entorno antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende del volumen de peticiones y la infraestructura. Los ejemplos priorizan claridad. Para alto throughput, agregá connection pooling, pipelining en Redis y caching donde tenga sentido.
¿Cómo depuro problemas con este enfoque?
Empezá con el ejemplo mínimo y agregá logging en cada paso. Probá con pocas peticiones primero, luego escalá. Usá el debugger de tu lenguaje para recorrer los edge cases.
Recursos Relacionados
Rotacion segura de JWT refresh tokens con Python
Implementa rotacion segura de JWT access y refresh tokens en Python con blacklist, deteccion de reuso y renovacion automatica de access tokens para auth stateless
RecipeRate limiting con Redis y algoritmo Token Bucket
Implementa un rate limiter distribuido con token bucket usando operaciones atomicas de Redis para throttling de API entre multiples instancias
RecipePreviene inyeccion SQL con consultas parametrizadas en
Protege aplicaciones Python de inyeccion SQL usando consultas parametrizadas de SQLAlchemy, modelos ORM, validacion de input e inspeccion de queries para acceso seguro a base de datos
RecipePeticiones HTTP concurrentes con asyncio.gather y aiohttp
Obten multiples endpoints HTTP concurrentemente usando asyncio.gather y aiohttp con manejo de errores, rate limiting, timeouts y connection pooling para 10x throughput
RecipeGestiona Secretos con HashiCorp Vault y Python
Almacena, recupera y rota secretos de aplicación de forma segura usando HashiCorp Vault con cliente Python hvac, secretos dinámicos y renovación automática de leases.
RecipeImplementar firma de peticiones con HMAC
Asegura peticiones de API con firmas HMAC y autenticación AWS Signature v4 para integridad de mensajes a prueba de manipulaciones.