"""Простой in-memory rate-limiter для публичного API. Per-IP sliding window. Достаточно для одного backend-инстанса (MVP). Защищает от абуза `/api/v1/*` — скрейп-эндпоинты уже за admin-токеном, но estimate/geocode/suggest публичны. Лимиты намеренно щедрые — обычный пользователь их не достигнет, режутся боты. """ from __future__ import annotations import time from collections import defaultdict, deque from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware # Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP. RATE_WINDOW = 60.0 RATE_LIMIT = 90 class RateLimitMiddleware(BaseHTTPMiddleware): """Sliding-window rate limit на /api/v1/*. Health и статика — без лимита.""" def __init__(self, app) -> None: # type: ignore[no-untyped-def] super().__init__(app) self._hits: dict[str, deque[float]] = defaultdict(deque) async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] path = request.url.path # Лимитируем только API; health и прочее — пропускаем. if not path.startswith("/api/"): return await call_next(request) ip = _client_ip(request) now = time.monotonic() bucket = self._hits[ip] # Выкидываем устаревшие отметки за пределами окна. cutoff = now - RATE_WINDOW while bucket and bucket[0] < cutoff: bucket.popleft() if len(bucket) >= RATE_LIMIT: retry = int(RATE_WINDOW - (now - bucket[0])) + 1 return JSONResponse( status_code=429, content={"detail": "Слишком много запросов. Попробуйте позже."}, headers={"Retry-After": str(retry)}, ) bucket.append(now) # Лёгкая защита от утечки памяти — чистим пустые корзины изредка. if len(self._hits) > 10000: for k in [k for k, v in self._hits.items() if not v]: del self._hits[k] return await call_next(request) def _client_ip(request: Request) -> str: """Реальный IP за реверс-прокси (Caddy/nginx ставят X-Forwarded-For).""" xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host if request.client else "unknown"