Merge pull request 'Недоступный Telegram отдаёт 502 — теперь на всём дереве транспортных отказов' (#3457) from fix/tg-transport-error-502 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 7m30s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s

This commit is contained in:
lekss361 2026-09-12 06:26:30 +00:00
commit 8994e041cf
2 changed files with 102 additions and 13 deletions

View file

@ -12,14 +12,21 @@ Docs: https://core.telegram.org/bots/api
Ретраи:
- HTTP 429 (Too Many Requests) уважаем `parameters.retry_after` из тела ответа
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
- HTTP 5xx / сетевые ошибки (timeout/connect) экспоненциальный backoff,
`capped` на `_MAX_BACKOFF_S`.
- HTTP 5xx / транспортные ошибки (`httpx.TransportError`: timeout, connect,
обрыв протокола, прокси) экспоненциальный backoff, `capped` на
`_MAX_BACKOFF_S`.
- Прочие отказы запроса (`httpx.RequestError`: битый ответ) НЕ ретряются,
сразу `TelegramNetworkError`: повтор не чинит ни испорченный ответ, ни
кривую конфигурацию.
- Любая другая 4xx (400/401/403/404) НЕ ретраится, сразу `TelegramApiError`
(запрос некорректен или прав нет повтор не поможет).
Наружу летит только свой тип: `TelegramApiError` (площадка ответила отказом) или
`TelegramNetworkError` (не ответила), общий предок `TelegramError`. Сырые
httpx-исключения из клиента не выходят.
httpx-исключения из клиента не выходят: инвариант держат ДВА `except` в
`_request` `httpx.TransportError` (ретраится) и страховочный
`httpx.RequestError` (не ретраится), вместе покрывающие всё дерево отказов
запроса, включая те, что появятся в httpx позже.
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
HTTP-статус и `description` из ответа Telegram токен туда не пишем.
@ -165,7 +172,21 @@ class TelegramClient:
try:
async with httpx.AsyncClient(timeout=effective_timeout) as client:
response = await client.post(url, json=payload)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
except httpx.TransportError as exc:
# Ловим ВЕСЬ `TransportError`, а не узкий кортеж
# `(TimeoutException, NetworkError)`: `RemoteProtocolError`
# («Server disconnected without sending a response» — бытовой
# ответ api.telegram.org из РФ), `ProxyError`,
# `LocalProtocolError` и `UnsupportedProtocol` — СЁСТРЫ
# `NetworkError` по `TransportError`, а не наследники. Кортеж
# оставлял дыру ровно того класса, который чинил #3456: отказ
# вылетал сырым httpx мимо `except TelegramError` в ручках и
# снова давал 500 вместо 502 — и вдобавок не ретраился ни разу.
# Расширение ретраев на `RemoteProtocolError` наследует уже
# принятый здесь риск at-least-once (запрос мог дойти до
# Telegram, потерялся ответ) — он тот же, что у давно
# ретраящегося `ReadTimeout`; политика не меняется.
#
# Тип исключения обязан попасть в строку (#3156). У
# httpx.ReadError и httpx.ConnectError `str(exc)` пуст, и лог
# выглядел так: «network error (попытка 1/3): — retry через 2s»
@ -193,6 +214,26 @@ class TelegramClient:
)
await asyncio.sleep(backoff)
continue
except httpx.RequestError as exc:
# Страховка на остаток дерева отказов запроса: сегодня это
# `DecodingError` (битая компрессия в ответе), завтра — всё, что
# httpx заведёт под `RequestError`. `TooManyRedirects` сюда НЕ
# относится: клиент создаётся с дефолтным `follow_redirects=False`
# и редиректы не ходит. Порядок `except`-ов
# значим: `TransportError` — наследник `RequestError`, и стоять
# обязан ВЫШЕ, иначе сетевые отказы перестали бы ретраиться.
#
# Без ретраев намеренно: это не «площадка недоступна», а
# испорченный ответ или кривая конфигурация — повтор не лечит
# ни то, ни другое, а пять попыток с backoff подвесили бы
# интерактивную ручку почти на минуту впустую. Свой тип тут
# нужен ровно за тем же, за чем и выше: чтобы ручка увидела
# `TelegramError` и отдала 502, а не 500.
reason = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
logger.error(
"tg client: %s — запрос не состоялся (без ретраев): %s", method, reason
)
raise TelegramNetworkError(method, reason, attempt) from exc
if response.status_code == 429:
retry_after = _extract_retry_after(response)

View file

@ -222,26 +222,74 @@ async def test_network_error_log_keeps_text_when_exception_has_one(caplog) -> No
assert "таймаут соединения" in warnings[0], f"текст исключения потерян: {warnings[0]!r}"
async def test_network_exhaustion_raises_own_type_not_raw_httpx() -> None:
"""Исчерпали ретраи по сети — наружу свой тип, а не `httpx.ConnectTimeout`.
@pytest.mark.parametrize(
("exc_type", "expected_attempts"),
[
(httpx.ConnectTimeout, 2),
(httpx.RemoteProtocolError, 2),
(httpx.ProxyError, 2),
(httpx.DecodingError, 1),
],
)
async def test_request_failure_raises_own_type_not_raw_httpx(
exc_type: type[Exception], expected_attempts: int
) -> None:
"""Любой отказ запроса — наружу свой тип, а не сырой httpx.
Сырой httpx пролетал мимо `except TelegramApiError` во всех трёх HTTP-ручках
и превращался в 500 вместо задуманного 502 (#3456). Тип отказа при этом
терять нельзя он остаётся в `__cause__`, иначе в GlitchTip не отличить
таймаут соединения от сброса TLS.
и превращался в 500 вместо задуманного 502 (#3456). Первый заход закрыл
только `(TimeoutException, NetworkError)`, а `RemoteProtocolError` («Server
disconnected without sending a response» бытовой ответ api.telegram.org из
РФ), `ProxyError` и `DecodingError` сёстры по `TransportError`/
`RequestError`, не наследники `NetworkError`, и дыра оставалась открытой.
Тип отказа при этом терять нельзя он остаётся в `__cause__`, иначе в
GlitchTip не отличить таймаут соединения от сброса TLS.
"""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectTimeout("таймаут соединения")
raise exc_type("сбой транспорта")
_install_transport(handler)
with pytest.raises(TelegramNetworkError) as caught:
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=1)
assert caught.value.method == "sendMessage"
assert caught.value.attempts == 2, "число попыток должно попасть в исключение"
assert "ConnectTimeout" in caught.value.reason
assert isinstance(caught.value.__cause__, httpx.ConnectTimeout), "причина потеряна"
assert caught.value.attempts == expected_attempts, "число попыток должно попасть в исключение"
assert exc_type.__name__ in caught.value.reason
assert isinstance(caught.value.__cause__, exc_type), "причина потеряна"
async def test_remote_protocol_error_is_retried_but_decoding_error_is_not() -> None:
"""Разница бюджета между двумя `except`: что чинится повтором, а что нет.
`RemoteProtocolError` «площадка не ответила», ровно как таймаут: повтор
осмыслен, и он наследует уже принятый здесь риск at-least-once (запрос мог
дойти до Telegram, потерялся ответ) тот же, что у `ReadTimeout`.
`DecodingError` испорченный ответ / кривая конфигурация: пять попыток с
backoff подвесили бы интерактивную ручку почти на минуту без единого шанса
на успех.
"""
counts: dict[str, int] = {}
async def _attempts_for(exc_type: type[Exception]) -> int:
counts[exc_type.__name__] = 0
def handler(request: httpx.Request) -> httpx.Response:
counts[exc_type.__name__] += 1
raise exc_type("сбой транспорта")
_install_transport(handler)
with pytest.raises(TelegramNetworkError):
await TelegramClient(token="t").send_message(chat_id=-1, text="x", max_retries=2)
return counts[exc_type.__name__]
assert await _attempts_for(httpx.RemoteProtocolError) == 3, (
"обрыв протокола обязан ретраиться наравне с таймаутом"
)
assert await _attempts_for(httpx.DecodingError) == 1, (
"битый ответ ретраить нельзя — повтор не лечит, а бюджет ручки съедает"
)
async def test_network_error_is_not_api_error() -> None: