37 lines
1.7 KiB
Python
37 lines
1.7 KiB
Python
"""Rate limit на основе Redis `INCR`+`EXPIRE` для публичных (без auth) эндпоинтов.
|
||
|
||
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
||
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
||
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
|
||
"""
|
||
|
||
from fastapi import HTTPException, status
|
||
|
||
from core.redis import redis_client
|
||
|
||
RATE_LIMIT_MAX_REQUESTS = 10
|
||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||
|
||
|
||
async def enforce_rate_limit(
|
||
key: str,
|
||
*,
|
||
max_requests: int = RATE_LIMIT_MAX_REQUESTS,
|
||
window_seconds: int = RATE_LIMIT_WINDOW_SECONDS,
|
||
) -> None:
|
||
"""Увеличить счётчик запросов по ключу; бросить 429, если лимит превышен.
|
||
|
||
`INCR` атомарно создаёт ключ со значением 1, если его ещё не было; TTL
|
||
выставляется только при первом инкременте в окне (когда счётчик стал
|
||
равен 1) — иначе окно продлевалось бы при каждом запросе и лимит
|
||
никогда бы не истекал.
|
||
"""
|
||
redis_key = f"rate_limit:{key}"
|
||
current = await redis_client.incr(redis_key)
|
||
if current == 1:
|
||
await redis_client.expire(redis_key, window_seconds)
|
||
if current > max_requests:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate_limit_exceeded"
|
||
)
|