feat(glitchtip): фоновая ретрай-доставка алерта в Telegram при отказе синхронной попытки
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
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
This commit is contained in:
parent
1eee4b955d
commit
9eb42607b9
3 changed files with 293 additions and 4 deletions
|
|
@ -50,11 +50,14 @@ from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.tgbot.client import TelegramError
|
from app.services.tgbot.client import TelegramError
|
||||||
from app.services.tgbot.shared import get_telegram_client
|
from app.services.tgbot.shared import get_telegram_client
|
||||||
|
from app.tasks.glitchtip_alert_retry import retry_forward_alert
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -184,16 +187,28 @@ def _verify_secret(provided: str) -> None:
|
||||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
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(
|
async def glitchtip_webhook(
|
||||||
request: Request,
|
request: Request,
|
||||||
secret: Annotated[str, Query()] = "",
|
secret: Annotated[str, Query()] = "",
|
||||||
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str] | JSONResponse:
|
||||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
— ОТДЕЛЬНАЯ тема от 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``) — этот
|
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||||
хендлер сам делает единственную проверку секрета.
|
хендлер сам делает единственную проверку секрета.
|
||||||
|
|
||||||
|
|
@ -231,8 +246,27 @@ async def glitchtip_webhook(
|
||||||
except TelegramError:
|
except TelegramError:
|
||||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
||||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
# тоже «переслать не смогли», и отвечать на неё надо задуманным 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")
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
|
||||||
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
105
tradein-mvp/backend/app/tasks/glitchtip_alert_retry.py
Normal file
|
|
@ -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
|
||||||
150
tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
Normal file
150
tradein-mvp/backend/tests/test_glitchtip_alert_retry.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue