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
Замер прода 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 попыток», границы бюджета ручки).
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
|
||
|
||
|
||
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
|