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