"""Фоновая пересылка GlitchTip-алерта в Telegram после отказа синхронной попытки. Контекст (#3471, #3157, #3456). GlitchTip-вебхуки НЕ ретраятся — сам GlitchTip безусловно помечает уведомление ``is_sent`` сразу после HTTP-ответа приёмника (upstream-поведение, см. #3157), поэтому если синхронная пересылка в Telegram (``app.api.v1.glitchtip``) не удалась, повторной доставки от GlitchTip не будет никогда — текст алерта исчезает бесследно. Ответ 502 на отказ Telegram остаётся как есть (задуман осознанно, #3456: честный сигнал отправителю, а не тихий проглот) — меняется то, что происходит С ТЕКСТОМ алерта после этого отказа. Почему не Celery. В tradein-mvp нет очереди с воркером: ни ``app/celery_app.py``, ни зависимости ``celery`` в ``backend/pyproject.toml`` не существует (проверено при работе над #3471) — попытка ``from celery import ...`` здесь упала бы ``ModuleNotFoundError``. Бутстрап полноценного Celery-воркера — новый контейнер и брокер, инфраструктурное решение вне границ этой задачи. Единственный доступный внутри границ задачи (``app/api/v1/glitchtip.py`` + ``app/tasks/**``) механизм «не блокировать интерактивный ответ, но не потерять текст» — Starlette ``BackgroundTasks``: выполняется ПОСЛЕ отправки HTTP-ответа тем же процессом, вне узкого интерактивного бюджета (``_INTERACTIVE_SEND_TIMEOUT_S=8s`` в glitchtip.py), поэтому здесь можно позволить себе штатную "воркерную" ретрай-политику клиента (``TelegramClient.send_message`` без явных ``timeout``/``max_retries`` — 5 попыток, backoff до 30s, см. ``app.services.tgbot.client``), плюс собственный внешний потолок ниже. Компромисс, честно: BackgroundTasks не переживает рестарт процесса (это не персистентная очередь) — если tradein-backend упадёт ровно между отказом синхронной попытки и завершением фоновой, текст всё-таки потеряется. Событие редкое (одно с начала эксплуатации, TRADE-IN-3F7, 28.08.2026), а сеть до Telegram теряет отдельные запросы, а не рвётся на минуты (замер 12.09: 9/12 успешных ``getMe``) — штатной ретрай-политики клиента обычно достаточно без внешнего потолка вовсе. Персистентная очередь (переживающая рестарт) требует Celery/Redis-воркера — отдельное инфраструктурное решение. Идемпотентность настолько, насколько дёшево. Текст между попытками не пересобирается (переиспользуется уже отформатированный ``text`` из ``glitchtip.py`` — никакого дублирования форматирования). Полной идемпотентности нет и быть не может дёшево: Telegram ``sendMessage`` не идемпотентен сам по себе (повтор создаёт НОВОЕ сообщение, не апдейтит старое) — именно поэтому внешний потолок попыток мал (``_MAX_ATTEMPTS``), а не «ретраить пока не получится». """ from __future__ import annotations import asyncio import logging from app.services.tgbot.client import TelegramClient, TelegramError logger = logging.getLogger(__name__) __all__ = ["retry_forward_alert"] # Внешний потолок ПОВЕРХ штатной ретрай-политики клиента (5 попыток внутри одного # send_message с backoff до 30s) — защита от «недоставляемый алерт крутится в фоне # вечно»: если сеть до Telegram не восстановилась за это время, сдаёмся и логируем # ERROR с текстом, а не повторяем бесконечно. _MAX_ATTEMPTS = 3 _RETRY_DELAY_S = 30.0 async def retry_forward_alert( client: TelegramClient, *, chat_id: int, text: str, message_thread_id: int | None, ) -> None: """Досылает уже отформатированный текст алерта после отказа синхронной попытки. ``client`` — ТОТ ЖЕ общий клиент приложения, что и в синхронном пути (``get_telegram_client()`` в ``glitchtip.py``), а не новый инстанс: он живёт в lifespan ради keep-alive-соединения (см. docstring ``glitchtip.py``). """ for attempt in range(1, _MAX_ATTEMPTS + 1): try: await client.send_message( chat_id=chat_id, text=text, message_thread_id=message_thread_id, # Без явных timeout/max_retries — штатная "воркерная" политика # клиента (см. докстринг модуля). ) except TelegramError: if attempt == _MAX_ATTEMPTS: logger.error( "glitchtip alert retry: не удалось доставить алерт в Telegram " "после %d попыток — текст потерян: %r", _MAX_ATTEMPTS, text[:200], exc_info=True, ) return logger.warning( "glitchtip alert retry: попытка %d/%d не удалась, повтор через %.0fs", attempt, _MAX_ATTEMPTS, _RETRY_DELAY_S, exc_info=True, ) await asyncio.sleep(_RETRY_DELAY_S) else: logger.info( "glitchtip alert retry: доставлено фоном с попытки %d/%d", attempt, _MAX_ATTEMPTS ) return