gendesign/tradein-mvp/backend/app/core/ratelimit.py
bot-backend 10ffa93a1c
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
CI Trade-In / backend-tests (pull_request) Successful in 5m26s
fix(support): доставленное сообщение не теряется при сбое БД, отказы Telegram расходуют бюджет
Два последних дефекта из разбора телеграм-стека, оба в ручках веб-поддержки.
Предыдущие три PR (#3456, #3457, #3458) чинили клиент и мост; эти — сами ручки.

## Сбой БД уже ПОСЛЕ доставки в топик

Порядок «сначала Telegram, потом БД» осознанный, но блок записи не был обёрнут
ничем, в отличие от шага отправки. `SQLAlchemyError` там означал: сообщение
оператору доставлено, а клиент получил 500. Дальше по цепочке — пользователь
шлёт повторно, в топике дубль, а на осиротевшее зеркало оператор отвечает в
пустоту, потому что треда в БД нет и мост на реплай пишет только WARNING.

Обе ручки теперь ловят `SQLAlchemyError` вокруг блока БД, тихо откатывают
сессию, предупреждают оператора реплаем к доставленному зеркалу и отдают
клиенту успех. Успех, а не отказ: доставка правда состоялась, и отказ
спровоцировал бы ровно тот дубль, которого избегаем.

Анонимная ветка на этом пути дополнительно ставит куку, хотя штатно ставит её
только на успехе: треда нет, но идентичность посетителя обязана пережить сбой,
иначе следующее сообщение заведёт второй тред.

## Успеха мало — клиент должен об этом узнать

Первая версия правки отдавала успех молча, и это было неотличимо от тишины.
Фронт выбрасывает тело POST и рендерит переписку только из GET, а сообщения там
нет: поле ввода очищается, в списке пусто, баннера нет. Пользователь решает, что
не отправилось, и шлёт снова — тот самый дубль. Нашло adversarial-ревью, и это
подтверждено чтением `useSupportChat.ts` и `SupportChatPanel.tsx`.

Поэтому `SupportMessageOut` получил поле `persisted` со значением `True` по
умолчанию — все существующие пути и `GET /support/messages` отдают его без
изменений. На пути деградации приходит `False`, и панель показывает рядом с
композером предупреждение: сообщение получено оператором, но в переписке его не
будет, отправлять ещё раз не нужно. Баннер гаснет на следующей нормально
записанной отправке. Анонимный виджет рендерит ту же панель и получает это
поведение автоматически.

Текст предупреждения оператору тоже переписан: он больше не рассчитывает на то,
что клиент напишет снова, и прямо говорит, что ответить через бота не получится.

## Рейт-лимит переставал считаться при недоступном Telegram

`retry_after()` — это peek, а `record()` звался только на успехе. Верно для
«не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram лежит,
лимита нет вообще, и каждый повтор стоит до четырёх попыток к api.telegram.org,
не расходуя ни один бюджет. Двух-трёх вкладок с авто-повтором хватает, чтобы
выесть лимиты группы ровно тогда, когда канал и так еле жив.

Добавлен отдельный счётчик отказов на тех же ключах: пять подряд в окне тридцати
секунд включают cooldown, и ручка отвечает 429 не доходя до Telegram. Пять
подряд на живом канале практически недостижимы, а `reset()` на успехе стирает
историю — считаем именно подряд. Тридцать секунд заведомо короче реальной
недоступности, так что после восстановления пользователя не наказывают.
Основной «успешный» бюджет и non-destructive peek не тронуты.

`SlidingWindowLimiter.reset(key)` добавлен аддитивно, с оговоркой в докстринге,
что лимитерам-бюджетам он противопоказан.

Барьер рассчитан на несколько вкладок с авто-повтором, а не на одиночного
последовательного клиента: один отказавший запрос сам занимает до двадцати трёх
секунд, и пять таких в окно не укладываются. Это принято сознательно — ловить
одиночку значило бы наказывать обычного пользователя за чужую аварию.

## Тесты

Отказ БД в обеих ручках: клиент получает успех с `persisted=False`, оператору
уходит предупреждение, текст обращения в него не попадает, 500 не возникает.
Отказ самого уведомления ручку не роняет. Серия отказов включает cooldown, и до
Telegram запрос не доходит. Окончание окна cooldown снимает. Успешный путь и
существующий рейт-лимит не изменились.

Бэкенд: 117 passed, ruff чистый. Фронт: type-check чистый, lint без новых
замечаний.
2026-09-12 10:53:45 +03:00

210 lines
12 KiB
Python
Raw Permalink 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.
Sliding window. Достаточно для одного backend-инстанса (MVP).
Защищает `/api/v1/*` от абуза — estimate/geocode/suggest публичны.
Ключ лимита (#2213):
- authenticated username (заголовок X-Authenticated-User от Caddy basic_auth) —
per-user лимит = rate_limit × rate_limit_authenticated_multiplier;
- для анонимов — client IP, лимит = rate_limit.
Раньше весь authenticated-трафик освобождался целиком (#655). Это было плацебо:
заголовок X-Authenticated-User клиент-контролируем (его ставит Caddy, но на общей
docker-сети запрос мог прийти и мимо Caddy). Теперь лимит применяется всегда, но
per-user порог щедрый — живой пилот его не достигнет.
Допущение по IP (#2213): перед backend ровно ОДИН доверенный прокси (Caddy).
Значит честный клиентский IP = ПОСЛЕДНИЙ (rightmost) элемент X-Forwarded-For,
добавленный самим Caddy. Leftmost-элементы клиент может подделать (X-Forwarded-For:
"1.2.3.4" в исходном запросе), поэтому брать leftmost — дыра (тривиальный обход
per-IP лимита сменой фейкового первого хопа). Если XFF пуст — remote_addr
соединения.
"""
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
# Платёжная нотификация Т-Банка (PR-D2, готовит почву под PR-D3 — путь ещё
# закрыт rbac до того момента). Сервер-к-серверу, без сессии/X-Authenticated-User
# → в общем лимитере попал бы в один и тот же per-IP ключ с любым другим
# анонимным трафиком с той же исходящей сети банка. Мотив НЕ «банк упрётся в
# лимит» — 300/60с и так щедро — а «429 никогда не должен стать причиной, по
# которой денежное состояние разъехалось»: для банка недоставленная нотификация
# = «доставка не удалась», альтернативного канала нет, а очередь ретраев
# растягивается на сутки. Только точный путь notify — НЕ checkout (тот
# инициирует пользователь с сессией/курсором в браузере, абуз там штатно
# лимитируем как любой другой API-путь).
_PAYMENTS_NOTIFY_PATH = "/api/v1/trade-in/payments/notify"
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
# Платёжная нотификация — мимо ОБЩЕГО (per-user/per-IP shared) лимитера,
# но НЕ без лимита вовсе: idiom `_notify_limiter` (`SlidingWindowLimiter`,
# тот же приём, что `support.py:92`/`:319` — узкий per-feature бюджет
# ВМЕСТО общего, не полное отключение защиты). Порог заведомо выше любого
# штатного трафика банка (документированное расписание ретраев неизвестно,
# см. mera-tbank-acquiring-recon.md — берём с кратным запасом), но конечен:
# полное отключение оставило бы путь без backstop против шторма запросов —
# подпись отсекает мусор ПОСЛЕ разбора тела (PR-D3), не до.
if path == _PAYMENTS_NOTIFY_PATH:
retry_after = _notify_limiter.check(_client_ip(request))
if retry_after is not None:
return JSONResponse(
status_code=429,
content={"detail": "Слишком много запросов. Попробуйте позже."},
headers={"Retry-After": str(int(retry_after) + 1)},
)
return await call_next(request)
# Лимитируем только API; health и прочее — пропускаем.
if not path.startswith("/api/"):
return await call_next(request)
# Ключ и порог: per-user для аутентифицированных, per-IP для анонимов.
username = request.headers.get("x-authenticated-user")
if username:
key = f"user:{username}"
limit = settings.rate_limit * settings.rate_limit_authenticated_multiplier
else:
key = f"ip:{_client_ip(request)}"
limit = settings.rate_limit
now = time.monotonic()
bucket = self._hits[key]
# Выкидываем устаревшие отметки за пределами окна.
cutoff = now - settings.rate_limit_window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= 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)
class SlidingWindowLimiter:
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
"""
def __init__(self, limit: int, window_s: float) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = defaultdict(deque)
def _prune(self, bucket: deque[float], now: float) -> None:
cutoff = now - self._window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
def retry_after(self, key: str) -> float | None:
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
лимитом, иначе None. НЕ регистрирует попытку — вызывающая сторона решает
сама, когда звать `record()` (обычно — только на успех действия, #tgsupport-web
review L3: неудачная попытка не должна съедать бюджет)."""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
if len(bucket) >= self._limit:
return self._window_s - (now - bucket[0])
return None
def record(self, key: str) -> int:
"""Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
пропорционально. Значение можно игнорировать — `check()` так и делает.
"""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
bucket.append(now)
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
# паттерн, что RateLimitMiddleware.dispatch).
if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k]
return len(bucket)
def reset(self, key: str) -> None:
"""Обнуляет окно под *key*.
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
— например счётчик отказов отправки (`support.py:_send_failure_limiter`):
успешная отправка означает, что канал жив, и предыдущие отказы больше не
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
`_anon_ip_limiter`, `_notify_limiter`) не используется — там обнуление
по запросу было бы дырой в самом лимите.
"""
self._hits.pop(key, None)
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
`retry_after`/`record` для раздельного варианта)."""
retry_after = self.retry_after(key)
if retry_after is not None:
return retry_after
self.record(key)
return None
# Щедрый бюджет для платёжной нотификации (PR-D2): 3000/60с (50 req/s) — на два
# порядка выше любого правдоподобного трафика банка (тест 400/60с проходит с
# запасом в 7.5×), но конечен — backstop против шторма запросов на путь, где
# подпись проверяется уже ПОСЛЕ разбора тела. Ключ — client IP (у сервер-к-
# серверу вызова нет сессии/X-Authenticated-User).
_NOTIFY_RATE_LIMIT = 3000
_NOTIFY_RATE_WINDOW_S = 60.0
_notify_limiter = SlidingWindowLimiter(limit=_NOTIFY_RATE_LIMIT, window_s=_NOTIFY_RATE_WINDOW_S)
def _client_ip(request: Request) -> str:
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
Caddy добавляет свой хоп в конец X-Forwarded-For, поэтому берём ПОСЛЕДНИЙ
(rightmost) элемент — его нельзя подделать с клиента. Leftmost элементы
клиент-контролируемы и для ключа лимита НЕ используются. Пустой XFF (прямое
соединение) → remote_addr.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
parts = [p.strip() for p in xff.split(",") if p.strip()]
if parts:
return parts[-1]
return request.client.host if request.client else "unknown"