gendesign/tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
bot-backend 9eb42607b9
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
feat(glitchtip): фоновая ретрай-доставка алерта в Telegram при отказе синхронной попытки
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
2026-09-12 14:11:01 +03:00

150 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты фоновой пересылки 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)