Results page hits ~8-10 /api/* endpoints per estimate, tripping the per-IP rate-limit (was 90/60s) and 429'ing trusted pilots behind Caddy basic_auth. Requests carrying X-Authenticated-User (injected by Caddy) now bypass the limiter; anonymous traffic stays throttled. Limit/window are env-configurable (RATE_LIMIT default 90->300, RATE_LIMIT_WINDOW_S=60). Closes #655
76 lines
3.3 KiB
Python
76 lines
3.3 KiB
Python
"""Простой 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"
|