fix(tgbot): honest H1 rejection, per-role H2 budget, M1/M2/L1 cleanup (#3471 review)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 2m29s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

Deep review of PR #3494 found the rate limiter unusable as designed:
- H1: acquire() waited unbounded even for interactive HTTP handlers (support.py,
  glitchtip.py already pass a narrow `timeout` — reuse it as the queue wait cap
  instead of editing those handlers, which are out of scope here). New
  TelegramRateLimitedError (subclass of TelegramError) gives a fast, honest
  502 instead of hanging past the caller's own budget.
- H2: the limiter is per-process (in-memory), but two processes write to the
  same group (uvicorn API + bot worker) — giving each the same 18/min doubled
  the platform ceiling. Split into telegram_group_rate_limit_api_per_minute
  (12) and _bot_per_minute (6), sum kept below ~20.
- M1: bridge.py sends without an explicit timeout inherited "wait forever",
  stalling the single-threaded poll loop (open DB session) past the SIGTERM
  drain window. Bounded via rate_limit_max_wait=20s at the six call sites.
- M2: _locks/_sent_at grew unbounded on every unique DM chat_id. Added
  opportunistic cleanup of fully-expired entries.
- L1: the "queue full" warning now logs once per acquire() call, not once
  per sleep iteration.
- Corrected a factual error in the docstring: TELEGRAM_SUPPORT_CHAT_ID and
  TELEGRAM_ALERTS_CHAT_ID are the SAME group on prod (topics differ only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
This commit is contained in:
bot-backend 2026-09-12 15:28:16 +03:00
parent 6433477f7c
commit 8e7c65061b
6 changed files with 279 additions and 28 deletions

View file

@ -1290,10 +1290,27 @@ class Settings(BaseSettings):
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
# потерю сообщений). Дефолт — чуть ниже площадочного потолка. 0/отрицательное
# значение выключает лимитер (см. `TelegramGroupRateLimiter.acquire`).
telegram_group_rate_limit_per_minute: int = Field(
default=18, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_PER_MINUTE"
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
# проде равны (одна группа, темы разные) — обе половины делят один
# площадочный бюджет.
#
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
# обычно только зеркалирование/уведомления, которые могут подождать дольше
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
telegram_group_rate_limit_api_per_minute: int = Field(
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
)
telegram_group_rate_limit_bot_per_minute: int = Field(
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
)
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────

View file

@ -147,6 +147,21 @@ _NOTIFY_SEND_TIMEOUT_S = 5.0
_NOTIFY_SEND_MAX_RETRIES = 3
_NOTIFY_SEND_MAX_BACKOFF_S = 1.0
# Потолок ожидания в TelegramGroupRateLimiter для отправок ИЗ ЭТОГО модуля,
# которые не проходят через _notify_topic (review M1, #3471). Без него
# `client.send_message`/`copy_message` без явного `timeout` наследуют
# лимитер-политику "без потолка" (см. TelegramClient._request) — приемлемую
# ДЛЯ ФОНОВОЙ отправки как таковой, но НЕ здесь: эти вызовы идут внутри
# `run_poll_loop`, который на каждый апдейт держит ОТКРЫТУЮ сессию БД
# (SessionLocal, см. вызывающих) и однопоточно блокирует опрос СЛЕДУЮЩИХ
# апдейтов — минутный сон здесь стопорит и БД-соединение, и весь мост, а не
# только одно сообщение. Второй повод — SIGTERM drain: `tgbot_main._DRAIN_TIMEOUT_S`
# даёт 100с на завершение текущей итерации; ожидание слота дольше этого
# бюджета уже не успевает подчиниться cooperative drain. 20с — заметно больше,
# чем разумная очередь при исчерпанном лимите (окно 60с, обычно секунды), но
# заметно МЕНЬШЕ минуты и вписывается в drain-бюджет с запасом.
_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S = 20.0
# Потолок переигрываний ОДНОГО update_id на транзиентных сетевых отказах
# (#tg-connection-resilience).
#
@ -535,7 +550,11 @@ async def _handle_private_message(
text_body = message.get("text")
if text_body == "/start":
# E) команда — не содержательное обращение, топик не засоряем.
await client.send_message(chat_id=chat_id, text=GREETING_TEXT)
await client.send_message(
chat_id=chat_id,
text=GREETING_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
if not settings.telegram_support_chat_id:
@ -545,7 +564,11 @@ async def _handle_private_message(
chat_id,
)
# Не молчим клиенту (#5 review) — иначе он ждёт ответа, которого никогда не будет.
await client.send_message(chat_id=chat_id, text=SERVICE_UNAVAILABLE_TEXT)
await client.send_message(
chat_id=chat_id,
text=SERVICE_UNAVAILABLE_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
message_id = message.get("message_id")
@ -573,7 +596,11 @@ async def _handle_private_message(
# за окно — `_flood_notify_limiter.check()` возвращает None (и сам
# фиксирует попытку) ровно один раз за окно.
if _flood_notify_limiter.check(flood_key) is None:
await client.send_message(chat_id=chat_id, text=FLOOD_LIMITED_TEXT)
await client.send_message(
chat_id=chat_id,
text=FLOOD_LIMITED_TEXT,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
return
_flood_limiter.record(flood_key)
@ -584,6 +611,7 @@ async def _handle_private_message(
chat_id=settings.telegram_support_chat_id,
text=header,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
mirrored = await client.copy_message(
@ -591,6 +619,7 @@ async def _handle_private_message(
from_chat_id=chat_id,
message_id=message_id,
message_thread_id=settings.telegram_support_topic_id or None,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
@ -655,6 +684,7 @@ async def _handle_group_reply(
chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id,
message_id=message_id,
rate_limit_max_wait=_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S,
)
except TelegramApiError as exc:
if exc.error_code == 403:

View file

@ -86,9 +86,16 @@ class TelegramGroupRateLimiter:
(`asyncio.sleep`), а не только на чтение/запись счётчика это осознанно:
цель не просто «не гонять счётчик без гонки», а ФАКТИЧЕСКИ сериализовать
отправителей в этот чат, чтобы они не просыпались все разом по истечении
окна и не били по лимиту повторно. Другие `chat_id` (например, тема
поддержки и тема алертов лежат в РАЗНЫХ группах) не блокируют друг друга
у каждого свой лок и свой список меток.
окна и не били по лимиту повторно.
ВАЖНО про ключ (проверено на проде, review 2026-09-12): `TELEGRAM_SUPPORT_CHAT_ID`
и `TELEGRAM_ALERTS_CHAT_ID` это ОДНА И ТА ЖЕ группа, различаются только
темы (`*_TOPIC_ID`). Именно поэтому ключ лимитера `chat_id`, а НЕ
`(chat_id, message_thread_id)`: поток алертов и поток поддержки сегодня
физически делят один Telegram-бюджет группы, и лимитер обязан это
отражать. Ключ по `chat_id` при этом остаётся корректным и в гипотезе, что
когда-нибудь эти два потока разведут по разным группам, тогда у каждой
просто появится свой независимый лок/бюджет автоматически, без правки кода.
Регистр локов защищён отдельным `asyncio.Lock` только на момент
создания записи сам подсчёт/сон идёт уже под персональным локом чата.
@ -113,18 +120,38 @@ class TelegramGroupRateLimiter:
self._locks[chat_id] = lock
return lock
async def acquire(self, chat_id: int) -> None:
async def acquire(self, chat_id: int, max_wait: float | None = None) -> None:
"""Блокируется, пока в окне `_window_s` для `chat_id` есть свободный слот.
Не роняет и не отбрасывает вызов только ждёт очередь (требование
задачи: при исчерпании лимита ждать, а не терять сообщение).
`max_wait` (review H1, #3471): потолок ожидания очереди. `None` (дефолт)
без потолка, ждать сколько нужно; это ПРАВИЛЬНОЕ поведение для
фоновых отправок бота, где потерять сообщение хуже, чем подождать.
Если задан и слот не появился вовремя бросает `TelegramRateLimitedError`
(честный отказ), а НЕ продолжает ждать: интерактивная HTTP-ручка не
может легально держать открытый запрос браузера дольше своего
собственного таймаута. Вызывающая сторона `TelegramClient._request`,
см. её докстринг про то, откуда берётся конкретное значение.
Логирование (review L1): предупреждение об ожидании пишется РОВНО ОДИН
раз за вызов `acquire` (флаг `warned`), а не на каждой итерации сна
при реальной перегрузке группы это иначе валит лог сотнями одинаковых
строк вместо одного сигнала «была очередь».
Побочный эффект (review M2): перед постановкой в очередь чистит ЧУЖИЕ
полностью просроченные записи в `_sent_at`/`_locks` см. `_cleanup_stale`.
"""
if self._max_per_window <= 0:
return # 0/отрицательное значение конфига = лимитер выключен
now0 = time.monotonic()
await self._cleanup_stale(now0)
deadline = None if max_wait is None else now0 + max_wait
lock = await self._lock_for(chat_id)
async with lock:
warned = False
while True:
now = time.monotonic()
if deadline is not None and now >= deadline:
raise TelegramRateLimitedError(chat_id, max_wait or 0.0)
history = self._sent_at.setdefault(chat_id, [])
cutoff = now - self._window_s
while history and history[0] <= cutoff:
@ -133,16 +160,45 @@ class TelegramGroupRateLimiter:
history.append(now)
return
wait_s = history[0] + self._window_s - now
logger.warning(
"tg group rate limit: chat_id=%s — лимит %d/%.0fs исчерпан, "
"жду %.1fs перед отправкой",
chat_id,
self._max_per_window,
self._window_s,
wait_s,
)
if deadline is not None:
wait_s = min(wait_s, max(deadline - now, 0.0))
if not warned:
logger.warning(
"tg group rate limit: chat_id=%s — лимит %d/%.0fs исчерпан, "
"отправки встают в очередь (одно предупреждение на серию)",
chat_id,
self._max_per_window,
self._window_s,
)
warned = True
await asyncio.sleep(max(wait_s, 0.01))
async def _cleanup_stale(self, now: float) -> None:
"""Чистит ЧУЖИЕ (не текущий вызов `acquire`) записи с полностью
просроченной историей (review M2, #3471).
Зачем: `_locks`/`_sent_at` ключуются по ЛЮБОМУ `chat_id`, включая
личные чаты каждого клиента бота (`bridge.py` зеркалит их 1:1 через тот
же `TelegramClient`) большинство из них шлют боту одно сообщение и
больше никогда не возвращаются. Без чистки оба словаря растут
монотонно на всё время жизни долгоживущего процесса (медленная утечка).
Безопасность удаления: лок пропускаем, если `lock.locked()` значит
кто-то ИМЕННО СЕЙЧАС работает с этим `chat_id`, трогать нельзя. Если
лок свободен и вся история старше окна запись безвредно удалить:
следующий `acquire` для того же `chat_id` просто создаст её заново
пустой (`setdefault`), с тем же результатом, что и не удаляй мы её.
"""
cutoff = now - self._window_s
async with self._registry_lock:
stale = [cid for cid, ts in self._sent_at.items() if not ts or ts[-1] <= cutoff]
for cid in stale:
lock = self._locks.get(cid)
if lock is not None and lock.locked():
continue
self._sent_at.pop(cid, None)
self._locks.pop(cid, None)
# Раздельные таймауты вместо скаляра. httpx разворачивает скаляр в
# connect=read=write=pool, поэтому long-poll `getUpdates` (read = 30с, которые
# Telegram держит запрос, + 10с запаса = 40с) ставил 40 секунд и на УСТАНОВКУ
@ -215,6 +271,25 @@ class TelegramNetworkError(TelegramError):
super().__init__(f"Telegram {method} unreachable after {attempts} attempts: {reason}")
class TelegramRateLimitedError(TelegramError):
"""Слот в `TelegramGroupRateLimiter` не появился за `max_wait` секунд (review H1).
Бросается ТОЛЬКО когда вызывающий явно (или неявно, через `timeout`)
попросил ограниченное ожидание фоновые вызовы без такого ограничения
ждут очередь сколько нужно и этого исключения никогда не увидят. Общий
предок `TelegramError` существующие `except TelegramError` в
`app.api.v1.support`/`glitchtip` подхватывают этот отказ автоматически, без
правки самих ручек, и отвечают своим честным 502 вместо зависшего запроса.
"""
def __init__(self, chat_id: int, max_wait: float) -> None:
self.chat_id = chat_id
self.max_wait = max_wait
super().__init__(
f"Telegram group rate limit: no slot for chat_id={chat_id} within {max_wait:.1f}s"
)
def _request_timeout(read: float) -> httpx.Timeout:
"""Разворачивает «сколько ждать ответа» (скаляр вызывающего) в таймауты httpx.
@ -377,9 +452,24 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> Any:
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу.
`rate_limit_max_wait` (review H1/M1, #3471) — потолок ожидания слота в
`TelegramGroupRateLimiter.acquire`. Приоритет:
1. Явный `rate_limit_max_wait` используется как есть (bridge.py
передаёт его точечно для конкретных мест, см. `_BRIDGE_SEND_RATE_LIMIT_MAX_WAIT_S`).
2. Иначе, если вызывающий передал явный `timeout` используем
`effective_timeout` КАК ЕСТЬ. Интерактивные ручки (`app.api.v1.support`,
`app.api.v1.glitchtip`) и так ОБЯЗАНЫ передавать узкий `timeout`
(5-8с, см. их собственные докстринги) этого достаточно, чтобы
очередь лимитера не держала открытый HTTP-запрос браузера дольше
его же собственного бюджета, БЕЗ дополнительной правки этих ручек.
3. Иначе `None` без потолка. Это дефолт для фоновых отправок бота
(`app.tgbot_main`/`bridge.py` без явного `timeout`), где потерять
сообщение хуже, чем подождать дольше.
`max_backoff` (#tgsupport-retry) — потолок паузы МЕЖДУ попытками. По
умолчанию `_MAX_BACKOFF_S` (30с) и полный `retry_after` из тела 429 это
воркерная политика, она НЕ меняется. Интерактивный вызывающий передаёт узкий
@ -393,18 +483,23 @@ class TelegramClient:
это штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на
минуты ровно то, от чего предостерегает докстринг `send_message`.
"""
effective_timeout = timeout if timeout is not None else self._timeout
# Лимитер применяется ТОЛЬКО к методам с `chat_id` в payload (отправка
# в конкретный чат) — `getUpdates` его не несёт и лимиту не подлежит.
# Списывается ОДИН слот на логический вызов `_request` (то есть на
# одну попытку отправки конкретного сообщения), а не на каждую HTTP
# попытку внутри ретрай-цикла ниже: ретраи по 429/5xx лечат один и тот
# же send, а не порождают новые отправки.
# же send, а не порождают новые отправки. Приоритет `wait_cap` — см.
# докстринг параметра `rate_limit_max_wait` выше.
chat_id = payload.get("chat_id")
if isinstance(chat_id, int):
await self._rate_limiter.acquire(chat_id)
wait_cap = rate_limit_max_wait
if wait_cap is None and timeout is not None:
wait_cap = effective_timeout
await self._rate_limiter.acquire(chat_id, max_wait=wait_cap)
url = f"{self._base}/{method}"
effective_timeout = timeout if timeout is not None else self._timeout
# Раздельные таймауты считаем ОДИН раз и передаём per-request: у общего
# AsyncClient свой дефолт, а бюджет ответа у каждого вызова свой.
request_timeout = _request_timeout(effective_timeout)
@ -574,8 +669,12 @@ class TelegramClient:
message_id: int,
message_thread_id: int | None = None,
reply_to_message_id: int | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла.
`rate_limit_max_wait` см. `TelegramClient._request`; используется
`bridge.py` для точечного потолка ожидания на конкретных местах (review M1)."""
payload: dict[str, Any] = {
"chat_id": chat_id,
"from_chat_id": from_chat_id,
@ -585,7 +684,9 @@ class TelegramClient:
payload["message_thread_id"] = message_thread_id
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
result = await self._request("copyMessage", payload)
result = await self._request(
"copyMessage", payload, rate_limit_max_wait=rate_limit_max_wait
)
return result if isinstance(result, dict) else {}
async def send_message(
@ -598,6 +699,7 @@ class TelegramClient:
timeout: float | None = None,
max_retries: int | None = None,
max_backoff: float | None = None,
rate_limit_max_wait: float | None = None,
) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
@ -621,6 +723,8 @@ class TelegramClient:
kwargs["max_retries"] = max_retries
if max_backoff is not None:
kwargs["max_backoff"] = max_backoff
if rate_limit_max_wait is not None:
kwargs["rate_limit_max_wait"] = rate_limit_max_wait
result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {}

View file

@ -36,7 +36,10 @@ def get_telegram_client() -> TelegramClient:
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
group_rate_limit_per_minute=settings.telegram_group_rate_limit_per_minute,
# API-роль (review H2, #3471) — см. докстринг настройки в
# app.core.config: бюджет группы разделён статически между этим
# процессом и app.tgbot_main, суммарно ниже площадочного лимита.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_api_per_minute,
)
return _client

View file

@ -127,7 +127,9 @@ async def _run_bridge() -> None:
settings.telegram_bot_token,
relay_base_url=settings.telegram_relay_base_url,
relay_secret=settings.telegram_relay_secret,
group_rate_limit_per_minute=settings.telegram_group_rate_limit_per_minute,
# Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета
# группы; см. докстринг настройки в app.core.config.
group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute,
) as client:
# Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы,
# прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет

View file

@ -26,6 +26,7 @@ from app.services.tgbot.client import (
TelegramApiError,
TelegramClient,
TelegramGroupRateLimiter,
TelegramRateLimitedError,
verify_chat_and_topic,
)
@ -261,3 +262,97 @@ def test_telegram_api_error_importable_for_manual_inspection() -> None:
"""Sanity: тестовый модуль не потерял импорт TelegramApiError (использовался
при отладке 400-ответа выше)."""
assert issubclass(TelegramApiError, Exception)
# ── review H1: честный отказ вместо бесконечного ожидания на interactive-пути ─
async def test_rate_limiter_raises_rate_limited_error_when_max_wait_exceeded() -> None:
"""Слот не появился за `max_wait` — `TelegramRateLimitedError`, не вечный сон."""
fake_now = [0.0]
def fake_monotonic() -> float:
return fake_now[0]
async def fake_sleep(seconds: float) -> None:
fake_now[0] += seconds
with (
mock.patch("app.services.tgbot.client.time.monotonic", fake_monotonic),
mock.patch("app.services.tgbot.client.asyncio.sleep", fake_sleep),
):
limiter = TelegramGroupRateLimiter(max_per_window=1, window_s=60.0)
await limiter.acquire(chat_id=9) # занимает единственный слот
with pytest.raises(TelegramRateLimitedError):
await limiter.acquire(chat_id=9, max_wait=2.0) # бюджет короче окна
async def test_send_message_bounds_rate_limit_wait_by_explicit_timeout() -> None:
"""Явный `timeout` (как у interactive-ручек support.py/glitchtip.py) сам по
себе ограничивает ожидание очереди без правки самих ручек (review H1)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
with mock.patch(
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
autospec=True,
) as acquire_mock:
client = TelegramClient(token="fake-token")
await client.send_message(chat_id=1, text="hi", timeout=3.0)
_, kwargs = acquire_mock.call_args
assert kwargs.get("max_wait") == 3.0
async def test_send_message_unbounded_wait_by_default_for_background_calls() -> None:
"""Без явного `timeout` (типичный фоновый вызов) — `max_wait=None` (без потолка)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
_install_transport(handler)
with mock.patch(
"app.services.tgbot.client.TelegramGroupRateLimiter.acquire",
autospec=True,
) as acquire_mock:
client = TelegramClient(token="fake-token")
await client.send_message(chat_id=1, text="hi")
_, kwargs = acquire_mock.call_args
assert kwargs.get("max_wait") is None
# ── review H2: бюджет группы разделён по ролям (API-процесс / бот-процесс) ────
def test_group_rate_limit_split_by_role_sums_below_platform_ceiling() -> None:
"""API- и бот-роль вместе НЕ должны превышать площадочный лимит (~20/мин).
Регрессия ровно на баг из review H2: раньше оба процесса получали
ОДИНАКОВЫЙ дефолт (18+18=36) сумма вдвое превышала лимит площадки.
"""
from app.core.config import settings
api_limit = settings.telegram_group_rate_limit_api_per_minute
bot_limit = settings.telegram_group_rate_limit_bot_per_minute
assert api_limit > 0
assert bot_limit > 0
assert api_limit + bot_limit < 20
def test_shared_client_uses_api_role_limit() -> None:
"""`app.services.tgbot.shared` (процесс uvicorn) собирает клиент с API-долей."""
from app.core.config import settings
from app.services.tgbot import shared
shared._client = None
try:
client = shared.get_telegram_client()
assert (
client._rate_limiter._max_per_window
== settings.telegram_group_rate_limit_api_per_minute
)
finally:
shared._client = None