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
Замер прода за сутки 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 чистые. Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика, и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
80 lines
3.6 KiB
Python
80 lines
3.6 KiB
Python
"""Жизненный цикл общего на приложение `TelegramClient` (`app.services.tgbot.shared`).
|
||
|
||
Смысл модуля — ровно в том, что экземпляр ОДИН: до правки три HTTP-ручки создавали
|
||
клиент на каждый входящий запрос, и keep-alive не было ни на каком уровне. Если
|
||
синглтон однажды перестанет быть синглтоном, тесты ручек этого не заметят (они
|
||
подменяют аксессор целиком), а на проде вернутся TLS-хендшейки на каждое сообщение.
|
||
Поэтому проверяем идентичность и закрытие здесь, отдельно.
|
||
|
||
Реальных сетевых вызовов нет: `TelegramClient` создаёт `httpx.AsyncClient` лениво,
|
||
при первом запросе, а мы до запросов не доходим.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.tgbot import shared
|
||
from app.services.tgbot.client import TelegramClient
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_singleton():
|
||
"""Глобал модуля не должен течь между тестами — иначе порядок решает исход."""
|
||
shared._client = None
|
||
yield
|
||
shared._client = None
|
||
|
||
|
||
def test_get_returns_same_instance() -> None:
|
||
"""Два обращения — один объект. Это и есть весь смысл модуля."""
|
||
first = shared.get_telegram_client()
|
||
second = shared.get_telegram_client()
|
||
|
||
assert isinstance(first, TelegramClient)
|
||
assert first is second, "аксессор создал второй клиент — пул перестал переиспользоваться"
|
||
|
||
|
||
def test_init_returns_the_shared_instance() -> None:
|
||
"""`init_telegram_client` в lifespan и `get_telegram_client` в ручке — один и тот же объект."""
|
||
created = shared.init_telegram_client()
|
||
|
||
assert created is shared.get_telegram_client()
|
||
|
||
|
||
async def test_close_resets_and_next_get_builds_a_fresh_one() -> None:
|
||
"""После shutdown глобал обнулён; повторный startup обязан получить рабочий клиент.
|
||
|
||
Ленивость после закрытия намеренна: держать «остановленный» флаг значило бы,
|
||
что повторный `init_telegram_client()` в том же процессе отдаёт закрытый пул.
|
||
"""
|
||
first = shared.get_telegram_client()
|
||
await shared.close_telegram_client()
|
||
|
||
assert shared._client is None, "глобал не обнулён — следующий startup взял бы закрытый пул"
|
||
|
||
second = shared.get_telegram_client()
|
||
assert second is not first
|
||
|
||
|
||
async def test_close_is_idempotent() -> None:
|
||
"""Второй `close` не должен падать: lifespan зовёт его из `finally`."""
|
||
shared.get_telegram_client()
|
||
|
||
await shared.close_telegram_client()
|
||
await shared.close_telegram_client()
|
||
|
||
assert shared._client is None
|
||
|
||
|
||
async def test_close_without_client_is_a_noop() -> None:
|
||
"""Пустой токен — клиента в lifespan не создавали, закрывать нечего."""
|
||
assert shared._client is None
|
||
|
||
await shared.close_telegram_client()
|
||
|
||
assert shared._client is None
|