gendesign/tradein-mvp/backend/app/core/ratelimit.py

68 lines
2.7 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
# Окно и лимит: не более 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"