Недоступный Telegram отдаёт 502 — теперь на всём дереве транспортных отказов #3457

Merged
lekss361 merged 1 commit from fix/tg-transport-error-502 into main 2026-09-12 06:26:31 +00:00
Owner

Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий TelegramError и отдавать 502, но дыру закрыл не до конца: клиент по-прежнему выпускал наружу сырой httpx.

Что не доделал #3456

Ретраящийся except перехватывал узкий кортеж (httpx.TimeoutException, httpx.NetworkError). А RemoteProtocolError, ProxyError, LocalProtocolError и UnsupportedProtocol — не наследники NetworkError, а сёстры по TransportError. Проверено запуском на httpx 0.28.1, не по памяти:

RemoteProtocolError    ловился_старым=False  is_TransportError=True
LocalProtocolError     ловился_старым=False  is_TransportError=True
ProxyError             ловился_старым=False  is_TransportError=True
UnsupportedProtocol    ловился_старым=False  is_TransportError=True
DecodingError          ловился_старым=False  is_TransportError=False  is_RequestError=True
ConnectTimeout         ловился_старым=True

Практическое следствие — ровно тот отказ, который #3456 и чинил. RemoteProtocolError («Server disconnected without sending a response») для api.telegram.org из РФ бытовой, а не экзотика. Он вылетал из _request сырым, проходил мимо except TelegramError в glitchtip.py:227 и support.py:233 / :424, и FastAPI снова отдавал 500. Поймать его выше некому: в core/http_errors.py зарегистрирован только RequestValidationError. Вдобавок такой отказ не ретраился ни разу — вылетал с первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде отличить его от исчерпания бюджета было нечем.

Что сделано

Два except, вместе покрывающие всё дерево отказов запроса.

  • Ретраящийся расширен до httpx.TransportError. Тело не тронуто: те же reason, backoff, лог и TelegramNetworkError из #3156.
  • Ниже страховочный httpx.RequestError без ретраев — сегодня это DecodingError, завтра всё, что httpx заведёт под RequestError. Повторов нет намеренно: испорченный ответ и кривую конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы интерактивную ручку почти на минуту впустую.
  • Порядок значим и отмечен комментарием: TransportError — наследник RequestError и обязан стоять выше, иначе сетевые отказы перестали бы ретраиться.

Расширение ретраев на RemoteProtocolError наследует уже принятый в этом клиенте риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот же, что у давно ретраящегося ReadTimeout — политика не меняется, и это сказано в комментарии.

Прецедент лова именно TransportError в этом же репозитории — app/services/payments/tbank_client.py:136.

Что НЕ менялось

Ручки (уже ловят предок), bridge.py (except TelegramApiError там намеренный — разбор 403 «бот заблокирован»), _extract_retry_after, обработка 429/5xx, потолки backoff.

Отдельно проверено, что это не класс бага, а единичное место: оба клиента DaData ловят тот же узкий кортеж, но у каждого следом стоит except Exception с возвратом None, так что наружу ничего не утекает.

Тесты

Прежний тест «наружу свой тип» параметризован по ConnectTimeout, RemoteProtocolError, ProxyError, DecodingError с ожидаемым числом попыток. Новый тест фиксирует разницу бюджета: обрыв протокола ретраится, битый ответ — нет.

Прогон по четырём затронутым файлам: 80 passed, ruff check и ruff format --check зелёные.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs

Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и отдавать 502, но дыру закрыл не до конца: клиент по-прежнему выпускал наружу сырой httpx. ## Что не доделал #3456 Ретраящийся `except` перехватывал узкий кортеж `(httpx.TimeoutException, httpx.NetworkError)`. А `RemoteProtocolError`, `ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники `NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1, не по памяти: ``` RemoteProtocolError ловился_старым=False is_TransportError=True LocalProtocolError ловился_старым=False is_TransportError=True ProxyError ловился_старым=False is_TransportError=True UnsupportedProtocol ловился_старым=False is_TransportError=True DecodingError ловился_старым=False is_TransportError=False is_RequestError=True ConnectTimeout ловился_старым=True ``` Практическое следствие — ровно тот отказ, который #3456 и чинил. `RemoteProtocolError` («Server disconnected without sending a response») для `api.telegram.org` из РФ бытовой, а не экзотика. Он вылетал из `_request` сырым, проходил мимо `except TelegramError` в `glitchtip.py:227` и `support.py:233` / `:424`, и FastAPI снова отдавал 500. Поймать его выше некому: в `core/http_errors.py` зарегистрирован только `RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде отличить его от исчерпания бюджета было нечем. ## Что сделано Два `except`, вместе покрывающие всё дерево отказов запроса. - Ретраящийся расширен до `httpx.TransportError`. Тело не тронуто: те же `reason`, backoff, лог и `TelegramNetworkError` из #3156. - Ниже страховочный `httpx.RequestError` без ретраев — сегодня это `DecodingError`, завтра всё, что httpx заведёт под `RequestError`. Повторов нет намеренно: испорченный ответ и кривую конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы интерактивную ручку почти на минуту впустую. - Порядок значим и отмечен комментарием: `TransportError` — наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы ретраиться. Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот же, что у давно ретраящегося `ReadTimeout` — политика не меняется, и это сказано в комментарии. Прецедент лова именно `TransportError` в этом же репозитории — `app/services/payments/tbank_client.py:136`. ## Что НЕ менялось Ручки (уже ловят предок), `bridge.py` (`except TelegramApiError` там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`, обработка 429/5xx, потолки backoff. Отдельно проверено, что это не класс бага, а единичное место: оба клиента DaData ловят тот же узкий кортеж, но у каждого следом стоит `except Exception` с возвратом `None`, так что наружу ничего не утекает. ## Тесты Прежний тест «наружу свой тип» параметризован по `ConnectTimeout`, `RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток. Новый тест фиксирует разницу бюджета: обрыв протокола ретраится, битый ответ — нет. Прогон по четырём затронутым файлам: **80 passed**, ruff check и ruff format --check зелёные. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs
lekss361 added 1 commit 2026-09-12 06:20:22 +00:00
fix(tg): ретраим весь TransportError, остальной RequestError → 502 без ретраев
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 5m10s
087c48fef5
Follow-up к #3456. Тот PR научил три HTTP-ручки ловить общий `TelegramError` и
отдавать 502, но закрыл дыру не до конца: клиент по-прежнему выпускал наружу
сырой httpx. Ретраящийся `except` перехватывал узкий кортеж
`(httpx.TimeoutException, httpx.NetworkError)`, а `RemoteProtocolError`,
`ProxyError`, `LocalProtocolError` и `UnsupportedProtocol` — не наследники
`NetworkError`, а сёстры по `TransportError`. Проверено запуском на httpx 0.28.1,
не по памяти.

Практическое следствие — ровно тот отказ, который #3456 и чинил.
`RemoteProtocolError` («Server disconnected without sending a response») для
api.telegram.org из РФ — бытовой ответ, а не экзотика. Он вылетал из `_request`
сырым, проходил мимо `except TelegramError` в glitchtip.py:227 и support.py:233
и :424, и FastAPI снова отдавал 500. Глобального обработчика, который поймал бы
его выше, нет: в `core/http_errors.py` зарегистрирован только
`RequestValidationError`. Вдобавок такой отказ не ретраился ни разу — вылетал с
первой попытки, без backoff и без строки лога о сетевом сбое, так что в проде
отличить его от исчерпания бюджета было нечем.

Теперь два `except`, и вместе они покрывают всё дерево отказов запроса.
Ретраящийся расширен до `httpx.TransportError` — тело не тронуто, те же reason,
backoff, лог и `TelegramNetworkError` из #3156. Ниже страховочный
`httpx.RequestError` без ретраев: сегодня это `DecodingError`, завтра — всё, что
httpx заведёт под `RequestError`. Порядок значим — `TransportError`
наследник `RequestError` и обязан стоять выше, иначе сетевые отказы перестали бы
ретраиться. Повторов у страховочного нет намеренно: испорченный ответ и кривую
конфигурацию повтор не лечит, а пять попыток с backoff подвесили бы
интерактивную ручку почти на минуту впустую.

Расширение ретраев на `RemoteProtocolError` наследует уже принятый в этом клиенте
риск at-least-once: запрос мог дойти до Telegram, а ответ потеряться. Риск тот
же, что у давно ретраящегося `ReadTimeout`, политика не меняется.

Прецедент лова именно `TransportError` в этом же репозитории —
`app/services/payments/tbank_client.py:136`.

Не тронуто: ручки (они уже ловят предок), `bridge.py` (`except TelegramApiError`
там намеренный — разбор 403 «бот заблокирован»), `_extract_retry_after`,
обработка 429/5xx, потолки backoff.

Тесты: прежний тест «наружу свой тип» параметризован по `ConnectTimeout`,
`RemoteProtocolError`, `ProxyError`, `DecodingError` с ожидаемым числом попыток;
новый тест фиксирует разницу бюджета — обрыв протокола ретраится, битый ответ нет.
Прогон по четырём затронутым файлам: 80 passed.
lekss361 merged commit 8994e041cf into main 2026-09-12 06:26:31 +00:00
lekss361 deleted branch fix/tg-transport-error-502 2026-09-12 06:26:31 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3457
No description provided.