"""Бюджет ретраев интерактивной отправки в поддержку (#tgsupport-retry). Замер прода 01.09.2026, из контейнера бота: канал до api.telegram.org рвётся всплесками (15-38% отказов на попытку, 353 ConnectTimeout за сутки в логе long-polling'а), успешный запрос отвечает за 0.13с, а неудачный ВСЕГДА упирается в таймаут целиком — быстрых отказов ноль. При одном повторе до пользователя доходило ~9% отказов; тесты ниже фиксируют новый бюджет и то, ЧТО ИМЕННО в нём нельзя сломать: воркерная политика ретраев остаётся прежней. """ from __future__ import annotations from typing import Any import httpx import pytest from app.api.v1 import support as support_module from app.services.tgbot import client as client_module from app.services.tgbot.client import TelegramApiError, TelegramClient, TelegramNetworkError class _SleepSpy: """Подменяет asyncio.sleep — паузы не ждём, а записываем.""" def __init__(self) -> None: self.slept: list[float] = [] async def __call__(self, seconds: float) -> None: self.slept.append(seconds) @pytest.fixture def sleep_spy(monkeypatch: pytest.MonkeyPatch) -> _SleepSpy: spy = _SleepSpy() monkeypatch.setattr(client_module.asyncio, "sleep", spy) return spy def _always_network_error(monkeypatch: pytest.MonkeyPatch) -> None: """Каждая попытка — ConnectTimeout, ровно как на проде.""" class _Boom: async def __aenter__(self) -> Any: return self async def __aexit__(self, *_: Any) -> None: return None async def post(self, *_: Any, **__: Any) -> Any: raise httpx.ConnectTimeout("connect timeout") monkeypatch.setattr(client_module.httpx, "AsyncClient", lambda **_: _Boom()) def _always_429(monkeypatch: pytest.MonkeyPatch, retry_after: float) -> None: class _Throttled: async def __aenter__(self) -> Any: return self async def __aexit__(self, *_: Any) -> None: return None async def post(self, *_: Any, **__: Any) -> httpx.Response: return httpx.Response( 429, json={ "ok": False, "error_code": 429, "description": "Too Many Requests", "parameters": {"retry_after": retry_after}, }, ) monkeypatch.setattr(client_module.httpx, "AsyncClient", lambda **_: _Throttled()) # --- потолок отката в клиенте ------------------------------------------------- async def test_max_backoff_caps_network_retry_pauses( monkeypatch: pytest.MonkeyPatch, sleep_spy: _SleepSpy ) -> None: """С потолком 1с паузы между попытками не растут 2→4→8.""" _always_network_error(monkeypatch) tg = TelegramClient("fake-token") with pytest.raises(TelegramNetworkError): await tg.send_message(chat_id=-1, text="x", max_retries=3, max_backoff=1.0) # 3 повтора → 3 паузы, каждая не выше потолка. assert sleep_spy.slept == [1.0, 1.0, 1.0] async def test_without_max_backoff_worker_policy_is_unchanged( monkeypatch: pytest.MonkeyPatch, sleep_spy: _SleepSpy ) -> None: """Без явного потолка откат прежний экспоненциальный — воркеры не задеты.""" _always_network_error(monkeypatch) tg = TelegramClient("fake-token") with pytest.raises(TelegramNetworkError): await tg.send_message(chat_id=-1, text="x", max_retries=3) assert sleep_spy.slept == [2.0, 4.0, 8.0] async def test_max_backoff_caps_429_retry_after( monkeypatch: pytest.MonkeyPatch, sleep_spy: _SleepSpy ) -> None: """Главное, ради чего потолок нужен на 429: иначе рост max_retries умножил бы retry_after (для группы штатные 30-60с) на число попыток и подвесил бы синхронный HTTP-запрос на минуты.""" _always_429(monkeypatch, retry_after=60.0) tg = TelegramClient("fake-token") with pytest.raises(TelegramApiError): await tg.send_message(chat_id=-1, text="x", max_retries=3, max_backoff=1.0) assert sleep_spy.slept == [1.0, 1.0, 1.0] assert sum(sleep_spy.slept) < 5.0 async def test_without_max_backoff_429_still_honours_retry_after( monkeypatch: pytest.MonkeyPatch, sleep_spy: _SleepSpy ) -> None: """Воркерный путь по-прежнему уважает retry_after целиком — не злим Telegram.""" _always_429(monkeypatch, retry_after=60.0) tg = TelegramClient("fake-token") with pytest.raises(TelegramApiError): await tg.send_message(chat_id=-1, text="x", max_retries=1) assert sleep_spy.slept == [60.0] async def test_retry_count_is_attempts_minus_one(monkeypatch: pytest.MonkeyPatch) -> None: """max_retries=N даёт N+1 попыток — от этого считается худший случай ожидания.""" attempts = 0 class _Counting: async def __aenter__(self) -> Any: return self async def __aexit__(self, *_: Any) -> None: return None async def post(self, *_: Any, **__: Any) -> Any: nonlocal attempts attempts += 1 raise httpx.ConnectTimeout("connect timeout") monkeypatch.setattr(client_module.httpx, "AsyncClient", lambda **_: _Counting()) monkeypatch.setattr(client_module.asyncio, "sleep", _SleepSpy()) tg = TelegramClient("fake-token") with pytest.raises(TelegramNetworkError): await tg.send_message(chat_id=-1, text="x", max_retries=3, max_backoff=0.0) assert attempts == 4 # --- бюджет ручки поддержки --------------------------------------------------- def test_interactive_budget_constants() -> None: """Значения подобраны по замеру, а не на глаз — см. комментарий в support.py.""" assert support_module._INTERACTIVE_SEND_MAX_RETRIES == 3 assert support_module._INTERACTIVE_SEND_TIMEOUT_S == 5.0 assert support_module._INTERACTIVE_SEND_MAX_BACKOFF_S == 1.0 def test_interactive_worst_case_stays_within_http_patience() -> None: """Худший случай — все попытки в таймаут — обязан остаться десятками секунд, а не минутами: это синхронный request/response, за ним ждёт браузер.""" attempts = support_module._INTERACTIVE_SEND_MAX_RETRIES + 1 pauses = support_module._INTERACTIVE_SEND_MAX_RETRIES worst = ( attempts * support_module._INTERACTIVE_SEND_TIMEOUT_S + pauses * support_module._INTERACTIVE_SEND_MAX_BACKOFF_S ) assert worst <= 30.0, f"худший случай {worst}с — слишком долго для интерактивного пути" def test_interactive_budget_is_narrower_than_worker_default() -> None: """Смысл узкого бюджета: он обязан оставаться строго уже воркерного.""" assert support_module._INTERACTIVE_SEND_MAX_RETRIES < client_module._DEFAULT_MAX_RETRIES assert support_module._INTERACTIVE_SEND_TIMEOUT_S < client_module._DEFAULT_TIMEOUT_S assert support_module._INTERACTIVE_SEND_MAX_BACKOFF_S < client_module._MAX_BACKOFF_S