From bc0ef9536ebe5128a2e92f2d5e5e34b5e5884244 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:40:56 +0500 Subject: [PATCH] =?UTF-8?q?fix(glitchtip):=20=D0=BC=D0=B5=D0=B4=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20Telegram=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20502=20=D0=B4=D0=BE=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20GlitchTip=20(#3157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронная пересылка алерта была ограничена таймаутом одного HTTP-запроса (8 с), а попыток после ретранслятора (#3471) больше одной: relay, прямой путь, пауза 2 с, повтор. 16.09.2026 16:42 UTC отказ шёл медленно, GlitchTip (aiohttp ClientTimeout total=10) оборвал запрос на 10-й секунде, Caddy записал status=0, а хендлер досчитал 200 уже разорванному клиенту: uvicorn такой ответ выбрасывает вместе со строкой access-log. Исход у отправителя и приёмника расходился, 502 и фоновая доставка не срабатывали. Вся синхронная попытка теперь под asyncio.timeout(7 с). TimeoutError идёт тем же путём, что TelegramError: logger.exception, 502, фоновая доставка. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/api/v1/glitchtip.py | 33 ++++++++++++------- .../tests/test_glitchtip_alert_retry.py | 33 +++++++++++++++++-- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py index 0539698e..7de099de 100644 --- a/tradein-mvp/backend/app/api/v1/glitchtip.py +++ b/tradein-mvp/backend/app/api/v1/glitchtip.py @@ -43,6 +43,7 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре from __future__ import annotations +import asyncio import json import logging import secrets @@ -74,6 +75,14 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)" # timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. _INTERACTIVE_SEND_TIMEOUT_S = 8.0 _INTERACTIVE_SEND_MAX_RETRIES = 1 +# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос, +# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор +# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую + +# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0), +# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному +# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и +# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело. +_INTERACTIVE_SEND_DEADLINE_S = 7.0 class GlitchTipAttachment(BaseModel): @@ -234,19 +243,21 @@ async def glitchtip_webhook( # до api.telegram.org перед каждой отправкой. Живёт в lifespan. client = get_telegram_client() try: - await client.send_message( - chat_id=settings.telegram_alerts_chat_id, - text=text, - message_thread_id=settings.telegram_alerts_topic_id or None, - # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не - # может легально висеть воркерные минуты ретраев. - timeout=_INTERACTIVE_SEND_TIMEOUT_S, - max_retries=_INTERACTIVE_SEND_MAX_RETRIES, - ) - except TelegramError: + async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S): + await client.send_message( + chat_id=settings.telegram_alerts_chat_id, + text=text, + message_thread_id=settings.telegram_alerts_topic_id or None, + # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не + # может легально висеть воркерные минуты ретраев. + timeout=_INTERACTIVE_SEND_TIMEOUT_S, + max_retries=_INTERACTIVE_SEND_MAX_RETRIES, + ) + except (TelegramError, TimeoutError): # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не - # 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный + # 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок + # `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный # сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого # текст алерта пропал бы бесследно, поэтому доставку ставим в фон # (#3471, см. app.tasks.glitchtip_alert_retry). diff --git a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py index 48b3fe3e..92b6d370 100644 --- a/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py +++ b/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py @@ -15,6 +15,7 @@ import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import asyncio +import time from typing import Any, ClassVar import pytest @@ -46,12 +47,13 @@ class _FakeTelegramClient: """Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети. `responses` — очередь: каждый вызов `send_message` берёт следующий элемент - (dict = успех, Exception = отказ), позволяя смоделировать «первая попытка не - удалась, повторная фоном прошла». + (dict = успех, Exception = отказ, float = зависнуть на столько секунд, потом + успех), позволяя смоделировать «первая попытка не удалась, повторная фоном + прошла». """ calls: ClassVar[list[dict[str, Any]]] = [] - responses: ClassVar[list[dict[str, Any] | Exception]] = [] + responses: ClassVar[list[dict[str, Any] | Exception | float]] = [] def __init__(self, _token: str = "fake-token") -> None: pass @@ -61,6 +63,9 @@ class _FakeTelegramClient: outcome = _FakeTelegramClient.responses.pop(0) if isinstance(outcome, Exception): raise outcome + if isinstance(outcome, float): + await asyncio.sleep(outcome) + return {"message_id": 1} return outcome @@ -109,6 +114,28 @@ def test_sync_failure_queues_background_retry_and_still_returns_502( assert _fake_telegram_client.calls[0]["text"] == _fake_telegram_client.calls[1]["text"] +def test_slow_telegram_answers_502_before_sender_gives_up( + client: TestClient, _fake_telegram_client: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """#3157, прод 16.09.2026 16:42 UTC: отказ Telegram был медленным (relay 8 с + + напрямую 5 с + пауза 2 с), GlitchTip оборвал запрос на 10-й секунде, 502 и + фоновой доставки не было. Медленный отказ обязан идти тем же путём, что быстрый.""" + # GlitchTip 6.1.6 apps/alerts/webhooks.py send_webhook: ClientTimeout(total=10). + assert glitchtip_module._INTERACTIVE_SEND_DEADLINE_S <= 8.0 + monkeypatch.setattr(glitchtip_module, "_INTERACTIVE_SEND_DEADLINE_S", 0.2) + _fake_telegram_client.responses = [5.0, {"message_id": 2}] + + started = time.monotonic() + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + elapsed = time.monotonic() - started + + assert r.status_code == 502 + assert elapsed < 2.0 + # Фон отработал: зависшая синхронная попытка + доставка фоном тем же текстом. + assert len(_fake_telegram_client.calls) == 2 + 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: -- 2.45.3