"""Простой 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 from app.core.config import settings # Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S): # не более settings.rate_limit запросов за settings.rate_limit_window_s секунд # с одного IP. 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) # Аутентифицированный трафик не лимитируем: Caddy basic_auth ставит # X-Authenticated-User на каждый запрос пилота. Лимит — только для # анонимного трафика (заголовок отсутствует/пуст). #655. if request.headers.get("x-authenticated-user"): return await call_next(request) ip = _client_ip(request) now = time.monotonic() bucket = self._hits[ip] # Выкидываем устаревшие отметки за пределами окна. cutoff = now - settings.rate_limit_window_s while bucket and bucket[0] < cutoff: bucket.popleft() if len(bucket) >= settings.rate_limit: retry = int(settings.rate_limit_window_s - (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"