gendesign/tradein-mvp/backend/app/core/ratelimit.py
lekss361 747a325428 fix(tradein): bypass rate-limit for authenticated pilots + configurable limit (#655)
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
2026-05-29 18:30:53 +03:00

76 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Простой 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"