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