All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 8s
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 4m51s
Замер на проде 27.08: `getUpdates` падает 23 раза в сутки, 14 из них за один
час. Ретрай почти всегда чинит с первой попытки, поэтому сообщений не теряется
— теряется возможность понять, что происходит:
network error (попытка 1/3): — retry через 2s
После двоеточия пусто. У httpx.ReadError и httpx.ConnectError `str(exc)` пуст,
а тип исключения в строку не попадал. По такому логу не отличить таймаут от
обрыва соединения от сброса TLS, то есть 23 события в сутки не дают ни одной
зацепки. Сеть при этом цела: сырой TLS до Telegram проходит 6 из 6 попыток
за ~0.16s.
Тип добавляется к тексту, а не вместо него: на исключениях с внятным
сообщением диагностика не должна стать беднее прежней. Оба конца закреплены
тестами — с пустым текстом и с непустым.
Closes #3156
218 lines
8.5 KiB
Python
218 lines
8.5 KiB
Python
"""Unit tests for `app.services.tgbot.client.TelegramClient` retry/backoff logic.
|
||
|
||
NEVER calls real Telegram API — httpx.MockTransport only (consistent с
|
||
tests/services/test_dadata.py). `asyncio.sleep` is patched to a no-op so retry
|
||
tests run instantly regardless of configured backoff/retry_after durations.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest import mock
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||
|
||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||
|
||
|
||
def _install_transport(handler) -> None:
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||
|
||
mock.patch("app.services.tgbot.client.httpx.AsyncClient", factory).start()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _stop_patches_and_noop_sleep():
|
||
sleep_patcher = mock.patch("app.services.tgbot.client.asyncio.sleep", return_value=None)
|
||
sleep_patcher.start()
|
||
yield
|
||
mock.patch.stopall()
|
||
|
||
|
||
async def test_get_updates_happy_path_returns_list() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/getUpdates")
|
||
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
updates = await client.get_updates(offset=1)
|
||
assert updates == [{"update_id": 1}]
|
||
|
||
|
||
async def test_never_logs_or_leaks_token_in_request_url_host() -> None:
|
||
"""Sanity: token lives only in the path, base host stays api.telegram.org."""
|
||
captured: dict[str, str] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["url"] = str(request.url)
|
||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="super-secret-token")
|
||
await client.send_message(chat_id=1, text="hi")
|
||
assert "bot" + "super-secret-token" in captured["url"] # goes over the wire, not logged
|
||
|
||
|
||
async def test_copy_message_retries_on_429_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
return httpx.Response(
|
||
429,
|
||
json={
|
||
"ok": False,
|
||
"error_code": 429,
|
||
"description": "Too Many Requests",
|
||
"parameters": {"retry_after": 3},
|
||
},
|
||
)
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 5}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
result = await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||
|
||
assert result == {"message_id": 5}
|
||
assert calls["n"] == 2
|
||
|
||
|
||
async def test_send_message_retries_on_5xx_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 3:
|
||
return httpx.Response(
|
||
502, json={"ok": False, "error_code": 502, "description": "bad gw"}
|
||
)
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
result = await client.send_message(chat_id=1, text="retrying")
|
||
|
||
assert result == {"message_id": 9}
|
||
assert calls["n"] == 3
|
||
|
||
|
||
async def test_send_message_raises_immediately_on_non_retryable_4xx() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(
|
||
403, json={"ok": False, "error_code": 403, "description": "Forbidden: bot blocked"}
|
||
)
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
|
||
with pytest.raises(TelegramApiError) as exc_info:
|
||
await client.send_message(chat_id=1, text="hi")
|
||
|
||
assert exc_info.value.error_code == 403
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_copy_message_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(500, json={"ok": False, "error_code": 500, "description": "boom"})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
|
||
with pytest.raises(TelegramApiError) as exc_info:
|
||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3)
|
||
|
||
assert exc_info.value.error_code == 500
|
||
|
||
|
||
async def test_get_updates_returns_empty_list_on_malformed_result() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, json={"ok": True, "result": "not-a-list"})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
assert await client.get_updates(offset=1) == []
|
||
|
||
|
||
async def test_optional_thread_and_reply_params_omitted_when_falsy() -> None:
|
||
captured: dict[str, Any] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json as _json
|
||
|
||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||
|
||
_install_transport(handler)
|
||
client = TelegramClient(token="fake-token")
|
||
await client.copy_message(chat_id=1, from_chat_id=2, message_id=3, message_thread_id=0)
|
||
|
||
assert "message_thread_id" not in captured["body"]
|
||
|
||
|
||
async def test_network_error_log_names_the_exception_type(caplog) -> None:
|
||
"""В логе сетевого сбоя обязан быть ТИП исключения, а не только текст (#3156).
|
||
|
||
У `httpx.ReadError` и `httpx.ConnectError` текст обычно пуст, и строка
|
||
вырождалась в «network error (попытка 1/3): — retry через 2s»: после
|
||
двоеточия пустота. По ней невозможно отличить таймаут от обрыва соединения,
|
||
то есть 23 срабатывания в сутки на проде не давали ни одной зацепки.
|
||
|
||
Проверяем именно пустой текст — на непустом дефект и не проявлялся.
|
||
"""
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise httpx.ReadError("")
|
||
return httpx.Response(200, json={"ok": True, "result": []})
|
||
|
||
_install_transport(handler)
|
||
with caplog.at_level("WARNING", logger="app.services.tgbot.client"):
|
||
await TelegramClient(token="t").get_updates(offset=0)
|
||
|
||
warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"]
|
||
assert warnings, "не было предупреждения о сетевом сбое"
|
||
assert "ReadError" in warnings[0], (
|
||
f"тип исключения не попал в лог, диагностировать нечем: {warnings[0]!r}"
|
||
)
|
||
assert "network error (попытка 1/" in warnings[0], "формат строки изменился незаметно"
|
||
|
||
|
||
async def test_network_error_log_keeps_text_when_exception_has_one(caplog) -> None:
|
||
"""Когда текст у исключения есть — он остаётся, а тип добавляется к нему.
|
||
|
||
Обратный конец: правка не должна была ЗАМЕНИТЬ текст типом, иначе на
|
||
исключениях с внятным сообщением диагностика стала бы беднее прежней.
|
||
"""
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
raise httpx.ConnectTimeout("таймаут соединения")
|
||
return httpx.Response(200, json={"ok": True, "result": []})
|
||
|
||
_install_transport(handler)
|
||
with caplog.at_level("WARNING", logger="app.services.tgbot.client"):
|
||
await TelegramClient(token="t").get_updates(offset=0)
|
||
|
||
warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"]
|
||
assert warnings, "не было предупреждения о сетевом сбое"
|
||
assert "ConnectTimeout" in warnings[0], f"нет типа: {warnings[0]!r}"
|
||
assert "таймаут соединения" in warnings[0], f"текст исключения потерян: {warnings[0]!r}"
|