Связь с Telegram не встаёт колом, ответ оператора не теряется #3458
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3458
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/tg-connection-resilience"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Замер прода за сутки 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чистые.Что сознательно НЕ вошло
support.py, отдельным заходом, чтобы не смешивать с этим PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs