diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py index 9cf64598..0539698e 100644 --- a/tradein-mvp/backend/app/api/v1/glitchtip.py +++ b/tradein-mvp/backend/app/api/v1/glitchtip.py @@ -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"} diff --git a/tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py b/tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py new file mode 100644 index 00000000..3a698937 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py new file mode 100644 index 00000000..48b3fe3e --- /dev/null +++ b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py @@ -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)