feat(glitchtip): фоновая ретрай-доставка алерта в Telegram при отказе синхронной попытки
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
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
This commit is contained in:
parent
1eee4b955d
commit
9eb42607b9
3 changed files with 293 additions and 4 deletions
|
|
@ -50,11 +50,14 @@ from datetime import UTC, datetime
|
|||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.tgbot.client import TelegramError
|
||||
from app.services.tgbot.shared import get_telegram_client
|
||||
from app.tasks.glitchtip_alert_retry import retry_forward_alert
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -184,16 +187,28 @@ def _verify_secret(provided: str) -> None:
|
|||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||
|
||||
|
||||
@router.post("/ops/glitchtip-webhook")
|
||||
@router.post("/ops/glitchtip-webhook", response_model=None)
|
||||
async def glitchtip_webhook(
|
||||
request: Request,
|
||||
secret: Annotated[str, Query()] = "",
|
||||
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, str] | JSONResponse:
|
||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||
|
||||
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
|
||||
сигнал отправителю), но ставит доставку в фон
|
||||
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
|
||||
``starlette.background.BackgroundTask`` на самом ответе) — GlitchTip вебхуки
|
||||
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
|
||||
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
|
||||
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
|
||||
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
|
||||
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре — задачи,
|
||||
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
|
||||
воспроизведено тестом, не гипотеза).
|
||||
|
||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||
хендлер сам делает единственную проверку секрета.
|
||||
|
||||
|
|
@ -231,8 +246,27 @@ async def glitchtip_webhook(
|
|||
except TelegramError:
|
||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
||||
# 500 из необработанного исключения (#3456).
|
||||
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
|
||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
||||
#
|
||||
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
|
||||
# background-задачи только к ответу, который вернул сам хендлер, а
|
||||
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
|
||||
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
|
||||
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
|
||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": "failed to forward alert to telegram"},
|
||||
background=BackgroundTask(
|
||||
retry_forward_alert,
|
||||
client,
|
||||
chat_id=settings.telegram_alerts_chat_id,
|
||||
text=text,
|
||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||
),
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Фоновая пересылка 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
|
||||
150
tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
Normal file
150
tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Тесты фоновой пересылки GlitchTip-алерта в Telegram после отказа синхронной
|
||||
попытки — app/api/v1/glitchtip.py (BackgroundTasks) + app/tasks/glitchtip_alert_retry.py.
|
||||
|
||||
Контекст (#3471, #3157): GlitchTip вебхуки не ретраит, поэтому отказ синхронной
|
||||
попытки не должен терять текст алерта. 502 на отказ Telegram остаётся как есть
|
||||
(#3456) — проверяем, что он остаётся ОДНОВРЕМЕННО с постановкой фоновой доставки.
|
||||
|
||||
NEVER touches real DB / real Telegram API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import asyncio
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import glitchtip as glitchtip_module
|
||||
from app.services.tgbot.client import TelegramNetworkError
|
||||
from app.tasks import glitchtip_alert_retry as retry_module
|
||||
|
||||
_SECRET = "test-shared-secret"
|
||||
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
|
||||
|
||||
_ISSUE_PAYLOAD = {
|
||||
"text": "GlitchTip Alert",
|
||||
"attachments": [{"title": "ValueError: something broke", "text": "app/services/foo.py"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token")
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679)
|
||||
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158)
|
||||
|
||||
|
||||
class _FakeTelegramClient:
|
||||
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.
|
||||
|
||||
`responses` — очередь: каждый вызов `send_message` берёт следующий элемент
|
||||
(dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не
|
||||
удалась, повторная фоном прошла».
|
||||
"""
|
||||
|
||||
calls: ClassVar[list[dict[str, Any]]] = []
|
||||
responses: ClassVar[list[dict[str, Any] | Exception]] = []
|
||||
|
||||
def __init__(self, _token: str = "fake-token") -> None:
|
||||
pass
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
|
||||
_FakeTelegramClient.calls.append(kwargs)
|
||||
outcome = _FakeTelegramClient.responses.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
_FakeTelegramClient.calls = []
|
||||
_FakeTelegramClient.responses = [{"message_id": 1}]
|
||||
monkeypatch.setattr(glitchtip_module, "get_telegram_client", lambda: _FakeTelegramClient())
|
||||
# Ретрай-модуль спит между попытками (_RETRY_DELAY_S=30s) — в тестах не ждём.
|
||||
monkeypatch.setattr(retry_module, "_RETRY_DELAY_S", 0.0)
|
||||
return _FakeTelegramClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _network_error() -> TelegramNetworkError:
|
||||
return TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
|
||||
|
||||
|
||||
# ── отказ синхронной попытки → фон + 502 ────────────────────────────────────
|
||||
|
||||
|
||||
def test_sync_failure_queues_background_retry_and_still_returns_502(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Синхронная попытка не удалась → задача уходит в фон, ответ ОСТАЁТСЯ 502
|
||||
(#3456 — 502 задуман осознанно, не подменяется молчаливым 200)."""
|
||||
_fake_telegram_client.responses = [_network_error(), {"message_id": 2}]
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 502
|
||||
# TestClient прогоняет BackgroundTasks синхронно перед возвратом ответа —
|
||||
# к этому моменту фоновая попытка уже отработала: 2 вызова (sync + retry).
|
||||
assert len(_fake_telegram_client.calls) == 2
|
||||
for call in _fake_telegram_client.calls:
|
||||
assert call["chat_id"] == -1004443088679
|
||||
assert call["message_thread_id"] == 158
|
||||
assert "ValueError: something broke" in call["text"]
|
||||
# Переиспользован тот же уже отформатированный текст — не пересобран заново.
|
||||
assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"]
|
||||
|
||||
|
||||
def test_sync_success_does_not_queue_background_retry(
|
||||
client: TestClient, _fake_telegram_client: Any
|
||||
) -> None:
|
||||
"""Успешная синхронная отправка НЕ ставит фоновую задачу — ровно один вызов."""
|
||||
_fake_telegram_client.responses = [{"message_id": 1}]
|
||||
|
||||
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_fake_telegram_client.calls) == 1
|
||||
|
||||
|
||||
# ── retry_forward_alert напрямую: потолок ретраев ───────────────────────────
|
||||
|
||||
|
||||
async def _run_retry(fake_client_cls: Any, responses: list[Any]) -> None:
|
||||
fake_client_cls.responses = list(responses)
|
||||
await retry_module.retry_forward_alert(
|
||||
fake_client_cls(), chat_id=-1, text="алерт", message_thread_id=158
|
||||
)
|
||||
|
||||
|
||||
def test_retry_succeeds_after_transient_failure(_fake_telegram_client: Any) -> None:
|
||||
asyncio.run(_run_retry(_fake_telegram_client, [_network_error(), {"message_id": 9}]))
|
||||
|
||||
assert len(_fake_telegram_client.calls) == 2
|
||||
|
||||
|
||||
def test_retry_gives_up_after_max_attempts_and_logs(
|
||||
_fake_telegram_client: Any, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""На потолке ретраев — сдаётся с ERROR-логом, а не молча и не бесконечно."""
|
||||
responses = [_network_error() for _ in range(retry_module._MAX_ATTEMPTS)]
|
||||
|
||||
with caplog.at_level("ERROR", logger=retry_module.logger.name):
|
||||
asyncio.run(_run_retry(_fake_telegram_client, responses))
|
||||
|
||||
assert len(_fake_telegram_client.calls) == retry_module._MAX_ATTEMPTS
|
||||
assert any("не удалось доставить" in rec.message for rec in caplog.records)
|
||||
Loading…
Add table
Reference in a new issue