Merge pull request 'Алерты GlitchTip: медленный отказ Telegram отвечает 502 и уходит в фон, а не обрывается на 10-й секунде' (#3555) from fix/glitchtip-delivery into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-17 09:22:08 +00:00
commit 6a6fc7031d
2 changed files with 52 additions and 14 deletions

View file

@ -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).

View file

@ -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: