Связь с Telegram не встаёт колом, ответ оператора не теряется #3458

Merged
lekss361 merged 1 commit from fix/tg-connection-resilience into main 2026-09-12 07:20:21 +00:00
Owner

Замер прода за сутки 12.09.2026 (Loki, container=tradein-tgbot): 576 строк network error и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop. Три причины, все подтверждены на коде и в рантайме.

1. Ответ оператора мог пропасть навсегда

process_update заканчивался безусловным finally: save_offset(update_id). Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не отличал неисправимый апдейт от транзиентного сетевого отказа.

Сценарий: оператор отвечает клиенту в топике → copy_message падает по сети → TelegramNetworkError улетает в общий except Exception → offset сдвигается. Telegram этот апдейт больше не отдаст, record_message не выполнился, оператор уверен, что ответил. Следа нет нигде, кроме строчки в логе.

Теперь process_update возвращает bool. На TelegramNetworkErrorrollback(), offset НЕ сохраняется, возврат False, и run_poll_loop прерывает разбор пачки: offset у Telegram единая «высшая отметка», подтверждение любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram отдаст заново.

Переигрывания ограничены _MAX_NETWORK_REPLAYS = 3 — без потолка «вечно недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного сообщения. На потолке offset всё-таки двигается, но с logger.error и с chat_id / message_id, по которым ответ находится в топике и пересылается руками. Текст переписки в лог не идёт.

Дубли — осознанный at-least-once компромисс: TelegramNetworkError означает исчерпанный бюджет ретраев, запрос мог дойти, а ответ потеряться. Дубль видят и клиент, и оператор; тихая потеря не видна никому. Полная идемпотентность по паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради редкого случая — вместо неё число дублей ограничено сверху.

Ветка except TelegramApiError с разбором error_code == 403 («бот заблокирован») не тронута.

2. Таймаут задавался скаляром — connect ждал сорок секунд

httpx.AsyncClient(timeout=...) разворачивает скаляр в connect=read=write=pool. Для getUpdates бюджет ответа 40с (30 держит Telegram плюс запас), и те же 40с уходили на установку соединения — при живом connect в 0.036с. Худший цикл: 4 попытки × 40с плюс backoff ≈ 174 секунды слепоты бота. В логе ровно эти разрывы: 06:40:10 → 06:42:22 → 06:43:35.

Теперь httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5), значения в именованных константах. Запас +10s у get_updates относится к read.

3. Клиент создавался заново на каждую попытку

httpx.AsyncClient стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный TCP+TLS-хендшейк на каждый запрос и каждый повтор, и заново кидался кубик «встанет ли коннект». Плюс три HTTP-ручки создавали TelegramClient на каждый входящий запрос.

Теперь один ленивый переиспользуемый AsyncClient на экземпляр, с aclose() и async with. Общий клиент приложения — новый app/services/tgbot/shared.py, создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга.

keepalive_expiry задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы ничего там, где нужнее всего. Poll loop переиспользует соединение и так (следующий getUpdates уходит сразу), а веб-поддержка шлёт раз в минуты и за 5с теряла бы его каждый раз. Плата — шанс взять из пула закрытое той стороной соединение; httpx отдаёт это как RemoteProtocolError, который ретраится с #3457.

4. Уведомления оператору шли с воркерным бюджетом внутри poll loop

Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались без своего бюджета, то есть с дефолтом 5 ретраев и backoff до 30с. Одна такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта. Вынесены в _notify_topic с узким бюджетом и собственным except.

Тесты

Новый tests/services/tgbot/test_shared.py — жизненный цикл общего клиента. В test_bridge.py — сетевой отказ оставляет offset нетронутым и апдейт переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную ветку, поведение на 403 не изменилось. В test_client.py — раздельные таймауты доезжают до httpx per-request, два вызова используют один AsyncClient, aclose() его закрывает.

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

Что сознательно НЕ вошло

  • Прокси до Telegram. Замер был на восьми запросах — это не статистика, и решение инфраструктурное. Если после этих правок обрывы останутся, мерить сотней попыток и решать отдельно.
  • Отказ БД уже после успешной отправки зеркала в топик и рейт-лимит, который перестаёт считаться ровно при недоступном Telegram — обе в support.py, отдельным заходом, чтобы не смешивать с этим PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs

Замер прода за сутки 12.09.2026 (Loki, `container=tradein-tgbot`): **576** строк `network error` и **7** полных исчерпаний бюджета ретраев, после которых падала итерация poll loop. Три причины, все подтверждены на коде и в рантайме. ## 1. Ответ оператора мог пропасть навсегда `process_update` заканчивался безусловным `finally: save_offset(update_id)`. Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не отличал неисправимый апдейт от транзиентного сетевого отказа. Сценарий: оператор отвечает клиенту в топике → `copy_message` падает по сети → `TelegramNetworkError` улетает в общий `except Exception` → offset сдвигается. Telegram этот апдейт больше не отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет нигде, кроме строчки в логе. Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` — `rollback()`, offset НЕ сохраняется, возврат `False`, и `run_poll_loop` прерывает разбор пачки: offset у Telegram единая «высшая отметка», подтверждение любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram отдаст заново. Переигрывания ограничены `_MAX_NETWORK_REPLAYS = 3` — без потолка «вечно недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с `chat_id` / `message_id`, по которым ответ находится в топике и пересылается руками. Текст переписки в лог не идёт. Дубли — осознанный at-least-once компромисс: `TelegramNetworkError` означает исчерпанный бюджет ретраев, запрос мог дойти, а ответ потеряться. Дубль видят и клиент, и оператор; тихая потеря не видна никому. Полная идемпотентность по паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради редкого случая — вместо неё число дублей ограничено сверху. Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован») не тронута. ## 2. Таймаут задавался скаляром — connect ждал сорок секунд `httpx.AsyncClient(timeout=...)` разворачивает скаляр в connect=read=write=pool. Для `getUpdates` бюджет ответа 40с (30 держит Telegram плюс запас), и те же 40с уходили на установку соединения — при живом connect в **0.036с**. Худший цикл: 4 попытки × 40с плюс backoff ≈ **174 секунды** слепоты бота. В логе ровно эти разрывы: 06:40:10 → 06:42:22 → 06:43:35. Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`, значения в именованных константах. Запас `+10s` у `get_updates` относится к read. ## 3. Клиент создавался заново на каждую попытку `httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный TCP+TLS-хендшейк на каждый запрос и каждый повтор, и заново кидался кубик «встанет ли коннект». Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос. Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и `async with`. Общий клиент приложения — новый `app/services/tgbot/shared.py`, создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга. `keepalive_expiry` задан **явно**: дефолт httpx — 5 секунд, и с ним пул не давал бы ничего там, где нужнее всего. Poll loop переиспользует соединение и так (следующий `getUpdates` уходит сразу), а веб-поддержка шлёт раз в минуты и за 5с теряла бы его каждый раз. Плата — шанс взять из пула закрытое той стороной соединение; httpx отдаёт это как `RemoteProtocolError`, который ретраится с #3457. ## 4. Уведомления оператору шли с воркерным бюджетом внутри poll loop Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались без своего бюджета, то есть с дефолтом 5 ретраев и backoff до 30с. Одна такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта. Вынесены в `_notify_topic` с узким бюджетом и собственным `except`. ## Тесты Новый `tests/services/tgbot/test_shared.py` — жизненный цикл общего клиента. В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную ветку, поведение на 403 не изменилось. В `test_client.py` — раздельные таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`, `aclose()` его закрывает. Прогон по затронутым файлам: **127 passed**. `ruff check` и `ruff format --check` чистые. ## Что сознательно НЕ вошло - **Прокси до Telegram.** Замер был на восьми запросах — это не статистика, и решение инфраструктурное. Если после этих правок обрывы останутся, мерить сотней попыток и решать отдельно. - **Отказ БД уже после успешной отправки зеркала в топик** и **рейт-лимит, который перестаёт считаться ровно при недоступном Telegram** — обе в `support.py`, отдельным заходом, чтобы не смешивать с этим PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs
lekss361 added 1 commit 2026-09-12 07:14:28 +00:00
fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
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 5m19s
1fa65eba6b
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot`
и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop.
Три причины, все подтверждены на коде и в рантайме.

## Ответ оператора мог пропасть навсегда

`process_update` заканчивался безусловным `finally: save_offset(update_id)`.
Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не
отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает
клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает
в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не
отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет
нигде, кроме строчки в логе.

Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается
`rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop`
прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение
любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram
отдаст заново.

Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно
недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного
сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с
`chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт
руками. Текст переписки в лог по-прежнему не идёт.

Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом
запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит
сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и
клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по
паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради
редкого случая; вместо неё число дублей жёстко ограничено сверху.

Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован»)
не тронута — там повтор действительно ничего не изменит.

## Таймаут задавался скаляром, поэтому connect ждал сорок секунд

`httpx.AsyncClient(timeout=effective_timeout)` разворачивается в
connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит
Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при
живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс
backoff, около трёх минут, в течение которых бот не видит ответов оператора.
В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35.

Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`,
значения в именованных константах. Запас `+10s` у `get_updates` относится к read,
докстринг поправлен.

## Клиент создавался заново на каждую попытку

`httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик
«встанет ли коннект». Для long-polling это была основная статья сетевых отказов.
Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос.

Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и
`async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`,
создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга.
`keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы
ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот
веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за
длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx
отдаёт это как `RemoteProtocolError`, который ретраится с #3457.

## Уведомления оператору шли с воркерным бюджетом внутри poll loop

Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались
без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна
такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта.
Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал
вторичного действия больше не отменяет основную ветку.

## Тесты

`tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента.
В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт
переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную
ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные
таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`,
`aclose()` его закрывает.

Прогон по затронутым файлам: 127 passed. Ruff check и format чистые.

Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика,
и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
lekss361 merged commit a3d4fcf0b3 into main 2026-09-12 07:20:21 +00:00
lekss361 deleted branch fix/tg-connection-resilience 2026-09-12 07:20:21 +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#3458
No description provided.