gendesign/tradein-mvp/backend/tests/test_tgsupport_retry_budget.py
bot-backend 46326ba96e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 5m8s
fix(tg): недоступный Telegram отдаёт 502, а не 500
Прод 11.09.2026, 01:35 и 01:38 MSK — два 500 на glitchtip-webhook. Причина не
в вебхуке: `TelegramClient._request` после исчерпания сетевых ретраев делал
голый `raise`, наружу летел `httpx.ConnectTimeout`. Все три HTTP-ручки ловят
`TelegramApiError` — сырой httpx пролетал мимо, и FastAPI отдавал 500 вместо
задуманного 502. Отказ площадки и её недоступность для вызывающего
неразличимы: переслать не смогли и там, и там.

Клиент больше не выпускает наружу чужой тип. Появился общий предок
`TelegramError`, под ним прежний `TelegramApiError` (ответили `ok: false`) и
новый `TelegramNetworkError` (не ответили вовсе). Раздельно, а не наследником,
потому что у сетевого отказа нет ни `error_code`, ни `description` — брать их
неоткуда, а `bridge` по `error_code == 403` разбирает «бот заблокирован» и
недоступность в этот разбор попадать не должна. Причина сохраняется в
`__cause__`: в GlitchTip по-прежнему видно, таймаут это соединения или сброс
TLS (#3156).

Три ручки — вебхук GlitchTip и обе ручки поддержки, авторизованная и
анонимная — ловят предок. Поведение воркеров не менялось: poll loop в
`bridge` и так ловит `Exception`, бюджеты ретраев те же.

Тесты: два в клиенте (свой тип наружу, причина не потеряна, это НЕ
`TelegramApiError`), три на ручках (502 на недоступности, ничего не
персистится, анонимной куки не выдаём). Четыре теста бюджета ретраев ждали
`httpx.ConnectTimeout` — ждут новый тип, проверяемые паузы прежние.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 03:02:03 +03:00

189 lines
7.7 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.

"""Бюджет ретраев интерактивной отправки в поддержку (#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