ghost-shopper-api / python /config /rate_limiter.py
azzaraqi
Deploy FastAPI backend with Supabase pooler support
b84ea83
Raw
History Blame Contribute Delete
4.46 kB
"""
Rate Limiter Middleware β€” IP-based, in-memory
Protects API endpoints without authentication.
Uses a sliding-window approach per client IP.
Configuration via .env:
RATE_LIMIT_ENABLED=True
RATE_LIMIT_REQUESTS_PER_MINUTE=60
RATE_LIMIT_REQUESTS_PER_HOUR=1000
"""
import os
import time
import logging
from collections import defaultdict
from fastapi import Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
# ── Configuration ──
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "True").lower() == "true"
REQUESTS_PER_MINUTE = int(os.getenv("RATE_LIMIT_REQUESTS_PER_MINUTE", "60"))
REQUESTS_PER_HOUR = int(os.getenv("RATE_LIMIT_REQUESTS_PER_HOUR", "1000"))
# ── In-Memory Store ──
# Structure: { ip: [timestamp1, timestamp2, ...] }
_request_log: dict[str, list[float]] = defaultdict(list)
# Paths excluded from rate limiting
EXCLUDED_PATHS = {"/", "/docs", "/redoc", "/openapi.json", "/api/v1/health"}
def _get_client_ip(request: Request) -> str:
"""Extract client IP, respecting X-Forwarded-For header."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _cleanup_old_entries(ip: str, window_seconds: int) -> None:
"""Remove timestamps older than the given window."""
cutoff = time.time() - window_seconds
_request_log[ip] = [t for t in _request_log[ip] if t > cutoff]
async def rate_limit_middleware(request: Request, call_next):
"""
FastAPI middleware that enforces per-IP rate limits.
Returns 429 Too Many Requests when limits are exceeded.
Adds X-RateLimit-* headers to every response.
"""
if not RATE_LIMIT_ENABLED:
return await call_next(request)
# Skip rate limiting for docs and health
if request.url.path in EXCLUDED_PATHS:
return await call_next(request)
client_ip = _get_client_ip(request)
now = time.time()
# Record this request
_request_log[client_ip].append(now)
# ── Check per-minute limit ──
_cleanup_old_entries(client_ip, 60)
requests_last_minute = len(_request_log[client_ip])
if requests_last_minute > REQUESTS_PER_MINUTE:
logger.warning(
f"Rate limit exceeded (per-minute) for IP: {client_ip} "
f"({requests_last_minute}/{REQUESTS_PER_MINUTE})"
)
return JSONResponse(
status_code=429,
content={
"status": "error",
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Please slow down.",
"details": {
"limit": REQUESTS_PER_MINUTE,
"window": "1 minute",
"retry_after_seconds": 60,
},
},
},
headers={"Retry-After": "60"},
)
# ── Check per-hour limit ──
_cleanup_old_entries(client_ip, 3600)
requests_last_hour = len(_request_log[client_ip])
if requests_last_hour > REQUESTS_PER_HOUR:
logger.warning(
f"Rate limit exceeded (per-hour) for IP: {client_ip} "
f"({requests_last_hour}/{REQUESTS_PER_HOUR})"
)
return JSONResponse(
status_code=429,
content={
"status": "error",
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Hourly request limit reached. Please try again later.",
"details": {
"limit": REQUESTS_PER_HOUR,
"window": "1 hour",
"retry_after_seconds": 3600,
},
},
},
headers={"Retry-After": "3600"},
)
# ── Proceed with request ──
response = await call_next(request)
# Add rate limit headers to response
response.headers["X-RateLimit-Limit-Minute"] = str(REQUESTS_PER_MINUTE)
response.headers["X-RateLimit-Remaining-Minute"] = str(
max(0, REQUESTS_PER_MINUTE - requests_last_minute)
)
response.headers["X-RateLimit-Limit-Hour"] = str(REQUESTS_PER_HOUR)
response.headers["X-RateLimit-Remaining-Hour"] = str(
max(0, REQUESTS_PER_HOUR - requests_last_hour)
)
return response