Arag / app /middleware /rate_limit.py
AuthorBot
Add enterprise growth APIs, billing, admin UI, and test suite.
12930b5
Raw
History Blame Contribute Delete
3.54 kB
"""Author RAG β€” Rate Limiting Middleware.
Protects auth endpoints from brute-force attacks.
Uses Redis (fakeredis in tests) for distributed rate limiting.
Limits:
/api/auth/login β†’ 10 attempts / 15 minutes / IP
/api/auth/forgot-password β†’ 3 attempts / hour / IP
/api/billing/webhook β†’ unlimited (Stripe IPs only)
All others β†’ 300 req / minute / IP
Implementation: Token bucket via Redis INCR + EXPIRE.
"""
import time
import structlog
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
logger = structlog.get_logger(__name__)
# (path_prefix, max_calls, window_seconds)
RATE_RULES = [
("/api/auth/login", 10, 900), # 10/15min
("/api/auth/forgot-password", 3, 3600), # 3/hour
("/api/auth/reset-password", 5, 3600), # 5/hour
("/api/auth/activate", 10, 900), # 10/15min
("/api/", 300, 60), # 300/min default
]
EXEMPT_PREFIXES = ("/api/billing/webhook", "/health", "/docs", "/openapi")
def _get_rule(path: str):
for prefix, limit, window in RATE_RULES:
if path.startswith(prefix):
return limit, window
return 300, 60 # default
class RateLimitMiddleware(BaseHTTPMiddleware):
"""IP-based rate limiting using Redis atomic INCR."""
def __init__(self, app, redis_client=None):
super().__init__(app)
self._redis = redis_client # injected; falls back to no-op if None
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# Skip exempt paths
if any(path.startswith(p) for p in EXEMPT_PREFIXES):
return await call_next(request)
# Only rate-limit API paths
if not path.startswith("/api/"):
return await call_next(request)
if not self._redis:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
limit, window = _get_rule(path)
# Bucket key: ip:path_prefix:window_slot
slot = int(time.time() // window)
key = f"rl:{ip}:{path[:40]}:{slot}"
try:
count = await self._redis.incr(key)
if count == 1:
await self._redis.expire(key, window)
# Add rate limit headers
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(max(0, limit - count))
response.headers["X-RateLimit-Reset"] = str((slot + 1) * window)
if count > limit:
logger.warning("Rate limit exceeded", ip=ip, path=path, count=count)
return JSONResponse(
status_code=429,
content={
"detail": "Too many requests. Please wait before trying again.",
"retry_after": window,
},
headers={
"Retry-After": str(window),
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
},
)
return response
except Exception as e:
# Never fail open β€” if Redis is down, let request through
logger.warning("Rate limiter error β€” passing request through", error=str(e))
return await call_next(request)