Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |