All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
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 8m54s
GlitchTip не ретраит вебхуки (#3157) — is_sent проставляется безусловно сразу после HTTP-ответа приёмника. При отказе Telegram синхронная попытка отвечала 502 и текст алерта пропадал безвозвратно (TRADE-IN-3F7, 28.08.2026; сеть до Telegram с хоста теряет ~каждый четвёртый запрос — замер 12.09). 502 при отказе Telegram ОСТАВЛЕН как есть — он задуман осознанно (#3456) как честный сигнал отправителю. Меняется судьба самого текста: перед возвратом 502 доставка ставится в фон через starlette.background.BackgroundTask на самом JSONResponse (app.tasks.glitchtip_alert_retry.retry_forward_alert), а не через FastAPI BackgroundTasks-зависимость — та привязывает задачи только к ответу, который вернул сам хендлер, а `raise HTTPException` строит отдельный ответ в exception-мидлваре, и такая задача не выполнилась бы вовсе (воспроизведено тестом при первой попытке реализации). Celery в проекте нет: ни app/celery_app.py, ни зависимости celery в backend/pyproject.toml не существует — бутстрап полноценной очереди с воркером вне границ этой задачи (новый контейнер/брокер). Фон использует штатную "воркерную" ретрай-политику TelegramClient.send_message (5 попыток, backoff до 30s) плюс свой внешний потолок в 3 попытки, чтобы недоставляемый алерт не крутился вечно — при исчерпании сдаётся с ERROR-логом текста. Переиспользует существующее форматирование (_build_message) и общий клиент приложения, без дублирования и новых переменных окружения. Refs #3471, #3157
105 lines
6.8 KiB
Python
105 lines
6.8 KiB
Python
"""Фоновая пересылка 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
|