# ============================================ # Projet : API-KA # Fichier : src/api/middleware/ratelimit.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-16 # ============================================ """Rate limiting basique par IP (défaut : 120 req/min) — l'API est publique via ngrok.""" from __future__ import annotations import time from collections import defaultdict, deque from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request from starlette.responses import JSONResponse, Response from src.config import get_settings WINDOW_SECONDS = 60.0 EXEMPT_PATHS = ("/health",) class RateLimitMiddleware(BaseHTTPMiddleware): """Fenêtre glissante de 60 s par IP ; au-delà de la limite → HTTP 429.""" def __init__(self, app, limit_per_minute: int | None = None) -> None: # type: ignore[no-untyped-def] super().__init__(app) self.limit = limit_per_minute or get_settings().rate_limit_per_minute self._hits: dict[str, deque[float]] = defaultdict(deque) async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: if request.url.path in EXEMPT_PATHS: return await call_next(request) client_ip = request.client.host if request.client else "unknown" now = time.monotonic() hits = self._hits[client_ip] while hits and now - hits[0] > WINDOW_SECONDS: hits.popleft() if len(hits) >= self.limit: return JSONResponse( status_code=429, content={ "success": False, "error": f"Limite dépassée : {self.limit} requêtes/minute par IP", "meta": {"node": get_settings().node_name}, }, ) hits.append(now) return await call_next(request)