Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
aa8ca9bcfc
commit
6ddc9bc661
3 changed files with 79 additions and 6 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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": "Слишком много запросов. Попробуйте позже."},
|
||||
|
|
|
|||
58
tradein-mvp/backend/tests/test_ratelimit.py
Normal file
58
tradein-mvp/backend/tests/test_ratelimit.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue