gendesign/tradein-mvp/backend/tests/test_tgsupport_retry_budget.py
bot-backend d708f15019
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
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 4m50s
fix(tradein/support): один повтор терял каждое одиннадцатое сообщение в поддержку
Замер прода 01.09.2026 из контейнера бота: канал до api.telegram.org рвётся
всплесками, доля отказов на попытку 15-38% (пять проб: 3/8, 15/40, 5/20, 3/20,
1/25), в логе long-polling'а 353 ConnectTimeout за сутки. Транспорт ни при чём —
httpx и сырой сокет отваливаются одинаково (25% против 35% в чередующемся
замере), и прокси не помогает, а мешает: через SCRAPER_PROXY_URL 0 из 20.

Ручка веб-поддержки ходила с max_retries=1, то есть двумя попытками. При 30%
отказов на попытку до пользователя доходило ~9% отказов — каждое одиннадцатое
сообщение возвращало 502 «сервис недоступен».

Два других числа из того же замера задают конструкцию. Успешный запрос отвечает
за 0.13с (максимум из 25 проб — 0.18с), а неудачный НИКОГДА не отваливается
быстро: все отказы упираются в таймаут целиком (10.02с при timeout=10.0). Значит
десятисекундный таймаут не покупал ничего, кроме цены за неудачу, — снижен до 5с,
это ~28-кратный запас к измеренному максимуму. И экспоненциальная пауза 2→4→8с
здесь бессмысленна: отказ — неустановленное соединение, а не троттлинг, пережидать
нечего; она лишь добавляла 14с к ожиданию.

Правка: бюджет ручки — 3 повтора, таймаут 5с, потолок паузы 1с. Худший случай
4 попытки × 5с + 3 паузы × 1с = 23с и требует четырёх отказов подряд; типичный
случай не меняется (0.13с). Расчётная потеря падает с ~9% до ~0.8%.

В TelegramClient добавлен необязательный max_backoff. Воркерная политика НЕ
меняется: без явного потолка откат прежний экспоненциальный до 30с, а retry_after
из 429 уважается целиком — эту границу держит отдельный тест, потому что первая
версия правки её сломала (капала 60с до 30с и для воркера тоже). Потолок на
retry_after применяется только когда его передали явно: интерактивному пути
нельзя ждать Telegram-овские 30-60с, за ним стоит открытый запрос от браузера.

Тесты: 8 новых (потолок на network/429/5xx, неизменность воркерного пути,
арифметика «max_retries=N → N+1 попыток», границы бюджета ручки).
2026-09-01 09:53:21 +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
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(httpx.ConnectTimeout):
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(httpx.ConnectTimeout):
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(httpx.ConnectTimeout):
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