fix(tradein): bypass rate-limit for authenticated pilots + configurable limit (#655) (#669)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy Trade-In / deploy (push) Successful in 33s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-29 15:56:30 +00:00 committed by bot-reviewer
parent aa8ca9bcfc
commit 6ddc9bc661
3 changed files with 79 additions and 6 deletions

View file

@ -34,6 +34,13 @@ class Settings(BaseSettings):
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
redis_url: str = "redis://localhost:6379/0"
# Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT,
# RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s
# секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от
# Caddy basic_auth) не лимитируется — см. ratelimit.py (#655).
rate_limit: int = 300
rate_limit_window_s: float = 60.0
# Password for tradein_fdw_reader role — used by backend startup to create/refresh
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).

View file

@ -16,9 +16,11 @@ 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
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):
@ -34,17 +36,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
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 - RATE_WINDOW
cutoff = now - settings.rate_limit_window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= RATE_LIMIT:
retry = int(RATE_WINDOW - (now - bucket[0])) + 1
if len(bucket) >= settings.rate_limit:
retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1
return JSONResponse(
status_code=429,
content={"detail": "Слишком много запросов. Попробуйте позже."},

View file

@ -0,0 +1,58 @@
"""Tests for RateLimitMiddleware — per-IP sliding window + auth bypass (#655).
Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не
тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно читаются из
settings на каждый dispatch monkeypatch'им их в маленькие значения.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import config
from app.core.ratelimit import RateLimitMiddleware
@pytest.fixture
def client(monkeypatch):
"""Минимальное приложение с лимитом 3 запроса / 60 с (для быстрого 429)."""
monkeypatch.setattr(config.settings, "rate_limit", 3)
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
return TestClient(app)
def test_anonymous_throttled_past_limit(client):
# Первые rate_limit запросов проходят, следующий — 429.
for _ in range(3):
assert client.get("/api/v1/ping").status_code == 200
resp = client.get("/api/v1/ping")
assert resp.status_code == 429
assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."}
assert "Retry-After" in resp.headers
def test_authenticated_user_bypasses_limit(client):
# X-Authenticated-User (Caddy basic_auth) → лимит не применяется.
headers = {"X-Authenticated-User": "pilot@example.com"}
for _ in range(10): # сильно больше rate_limit=3
assert client.get("/api/v1/ping", headers=headers).status_code == 200
def test_empty_auth_header_does_not_bypass(client):
# Пустой заголовок не должен no-op'ить лимит (header present но falsy).
headers = {"X-Authenticated-User": ""}
for _ in range(3):
assert client.get("/api/v1/ping", headers=headers).status_code == 200
assert client.get("/api/v1/ping", headers=headers).status_code == 429