gendesign/tradein-mvp/backend/tests/test_glitchtip_webhook.py
bot-backend 1fa65eba6b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 5m19s
fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
Замер прода за сутки 12.09.2026: 576 строк `network error` в логе `tradein-tgbot`
и 7 полных исчерпаний бюджета ретраев, после которых падала итерация poll loop.
Три причины, все подтверждены на коде и в рантайме.

## Ответ оператора мог пропасть навсегда

`process_update` заканчивался безусловным `finally: save_offset(update_id)`.
Замысел верный — «ядовитый» апдейт не должен блокировать поток, — но он не
отличал неисправимый апдейт от транзиентного сетевого отказа. Оператор отвечает
клиенту в топике, `copy_message` падает по сети, `TelegramNetworkError` улетает
в общий `except Exception`, offset сдвигается. Telegram этот апдейт больше не
отдаст, `record_message` не выполнился, оператор уверен, что ответил. Следа нет
нигде, кроме строчки в логе.

Теперь `process_update` возвращает `bool`. На `TelegramNetworkError` делается
`rollback()`, offset НЕ сохраняется, возвращается `False`, и `run_poll_loop`
прерывает разбор пачки — offset у Telegram единая «высшая отметка», подтверждение
любого следующего апдейта неявно подтвердило бы и этот. Остаток пачки Telegram
отдаст заново.

Переигрывания ограничены сверху `_MAX_NETWORK_REPLAYS = 3`: без потолка «вечно
недоставляемый» апдейт заклинил бы очередь навсегда, а это хуже потери одного
сообщения. На потолке offset всё-таки двигается, но с `logger.error` и с
`chat_id`/`message_id`, по которым человек найдёт ответ в топике и перешлёт
руками. Текст переписки в лог по-прежнему не идёт.

Дубли: `TelegramNetworkError` означает исчерпанный бюджет ретраев, при этом
запрос мог дойти до Telegram, а ответ потеряться. Переигрывание тогда доставит
сообщение второй раз. Это осознанный at-least-once компромисс — дубль видят и
клиент, и оператор, а тихая потеря не видна никому. Полная идемпотентность по
паре (update_id, target_chat_id) потребовала бы новой персистентной таблицы ради
редкого случая; вместо неё число дублей жёстко ограничено сверху.

Ветка `except TelegramApiError` с разбором `error_code == 403` («бот заблокирован»)
не тронута — там повтор действительно ничего не изменит.

## Таймаут задавался скаляром, поэтому connect ждал сорок секунд

`httpx.AsyncClient(timeout=effective_timeout)` разворачивается в
connect=read=write=pool. Для `getUpdates` бюджет ответа 40 секунд (30 держит
Telegram плюс запас), и те же 40 секунд уходили на установку соединения — при
живом connect в 0.036 секунды. Худший цикл: четыре попытки по 40 секунд плюс
backoff, около трёх минут, в течение которых бот не видит ответов оператора.
В логе это ровно те разрывы: 06:40:10, 06:42:22, 06:43:35.

Теперь `httpx.Timeout(connect=5, read=<бюджет вызывающего>, write=10, pool=5)`,
значения в именованных константах. Запас `+10s` у `get_updates` относится к read,
докстринг поправлен.

## Клиент создавался заново на каждую попытку

`httpx.AsyncClient` стоял ВНУТРИ цикла ретраев — keep-alive не было вовсе: полный
TCP+TLS-хендшейк на каждый запрос и на каждый повтор, и заново кидался кубик
«встанет ли коннект». Для long-polling это была основная статья сетевых отказов.
Плюс три HTTP-ручки создавали `TelegramClient` на каждый входящий запрос.

Теперь один ленивый переиспользуемый `AsyncClient` на экземпляр, с `aclose()` и
`async with`. Общий клиент приложения живёт в новом `app/services/tgbot/shared.py`,
создаётся и закрывается в lifespan; воркер бота держит свой на время поллинга.
`keepalive_expiry` задан явно: дефолт httpx — 5 секунд, и с ним пул не давал бы
ничего там, где нужнее всего. Poll loop переиспользует соединение и так, а вот
веб-поддержка шлёт раз в минуты и за 5 секунд теряла бы его каждый раз. Плата за
длинный keep-alive — шанс взять из пула закрытое той стороной соединение; httpx
отдаёт это как `RemoteProtocolError`, который ретраится с #3457.

## Уведомления оператору шли с воркерным бюджетом внутри poll loop

Обе отправки в топик («бот заблокирован», «веб-чат не поддерживает медиа») звались
без своего бюджета, то есть с дефолтом в 5 ретраев и backoff до 30 секунд. Одна
такая отправка стопорила весь цикл на минуты, а её отказ решал судьбу апдейта.
Вынесены в `_notify_topic` с узким бюджетом и собственным `except`: провал
вторичного действия больше не отменяет основную ветку.

## Тесты

`tests/services/tgbot/test_shared.py` — новый, на жизненный цикл общего клиента.
В `test_bridge.py` — сетевой отказ оставляет offset нетронутым и апдейт
переигрывается, потолок разблокирует поток, отказ уведомления не отменяет основную
ветку, прежнее поведение на 403 не изменилось. В `test_client.py` — раздельные
таймауты доезжают до httpx per-request, два вызова используют один `AsyncClient`,
`aclose()` его закрывает.

Прогон по затронутым файлам: 127 passed. Ruff check и format чистые.

Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика,
и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
2026-09-12 10:13:44 +03:00

339 lines
13 KiB
Python
Raw 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.

"""Offline-тесты приёмника GlitchTip webhook-алертов
(POST /api/v1/trade-in/ops/glitchtip-webhook) — app/api/v1/glitchtip.py.
Проверяет HTTP-контракт: успешная пересылка issue-/uptime-алертов в
Telegram (клиент замокан), отказ без валидного секрета, отказ при
несконфигурированных настройках, обрезка длинного текста под лимит Telegram
(4096), graceful-обработка неизвестной формы payload (НЕ 500).
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 json
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 TelegramApiError, TelegramNetworkError
_SECRET = "test-shared-secret"
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
@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/сети."""
calls: ClassVar[list[dict[str, Any]]] = []
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 1}
def __init__(self, _token: str) -> None:
pass
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FakeTelegramClient.calls.append(kwargs)
if isinstance(_FakeTelegramClient._response, Exception):
raise _FakeTelegramClient._response
return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 1}
# Ручка берёт ОБЩИЙ клиент приложения (#tg-connection-resilience), а не
# создаёт свой на запрос — подменяем аксессор, а не класс.
monkeypatch.setattr(
glitchtip_module, "get_telegram_client", lambda: _FakeTelegramClient("fake-token")
)
return _FakeTelegramClient
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
return TestClient(app)
_ISSUE_PAYLOAD = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "ValueError: something broke",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/issues/123/",
"text": "app/services/foo.py in bar",
"color": "#e03131",
"fields": [
{"title": "Project", "value": "tradein-backend", "short": True},
{"title": "Environment", "value": "production", "short": True},
],
"mrkdown_in": ["text"],
}
],
}
_UPTIME_PAYLOAD = {
"text": "GlitchTip Uptime Alert",
"attachments": [
{
"title": "gendsgn.ru",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/uptime/1/",
"text": "The monitored site has gone down.",
"image_url": None,
"color": None,
"fields": None,
"mrkdown_in": None,
}
],
}
# ── успешная пересылка ──────────────────────────────────────────────────────
def test_issue_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert r.json() == {"status": "ok"}
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert call["chat_id"] == -1004443088679
assert call["message_thread_id"] == 158
assert "ValueError: something broke" in call["text"]
assert "tradein-backend" in call["text"] # Project field
assert "errors.gendsgn.ru" in call["text"]
def test_uptime_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_UPTIME_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert "GlitchTip Uptime Alert" in call["text"]
assert "gone down" in call["text"]
assert call["message_thread_id"] == 158
def test_telegram_failure_returns_502_not_500(
client: TestClient, _fake_telegram_client: Any
) -> None:
_FakeTelegramClient._response = TelegramApiError("sendMessage", 400, "chat not found")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 502
assert r.status_code != 500
def test_telegram_unreachable_returns_502_not_500(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""Прод 11.09.2026, 01:35 и 01:38 MSK: два 500 на этой ручке.
Telegram не ответил вовсе, клиент отдавал сырой `httpx.ConnectTimeout`, он
пролетал мимо `except TelegramApiError` — и вместо задуманного 502 наружу
уходил необработанный 500 (#3456). Отказ площадки и её недоступность для
отправителя алерта неразличимы: переслать не смогли и там, и там.
"""
_FakeTelegramClient._response = TelegramNetworkError("sendMessage", "ConnectTimeout", 4)
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 502, "недоступный Telegram снова отдаёт 500"
# ── auth ─────────────────────────────────────────────────────────────────────
def test_missing_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret=wrong-value", json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_secret_accepted_from_header_without_query(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""#3154: секрет можно прислать заголовком — тогда он не течёт в access-log."""
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": _SECRET})
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_wrong_header_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD, headers={"X-GlitchTip-Secret": "wrong-value"})
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_query_secret_still_accepted_as_fallback(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""GlitchTip 6.1.6 заголовков не шлёт вовсе — query-путь обязан работать."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_secret_not_configured_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert r.status_code != 500
assert _fake_telegram_client.calls == []
def test_bot_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
def test_alerts_chat_id_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", 0)
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
# ── обрезка длинного текста ─────────────────────────────────────────────────
def test_long_payload_truncated_to_telegram_limit(
client: TestClient, _fake_telegram_client: Any
) -> None:
huge_payload = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "Huge issue",
"title_link": "https://errors.gendsgn.ru/x",
"text": "x" * 10000,
}
],
}
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=huge_payload)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
assert sent_text.endswith("(обрезано)")
def test_unknown_form_huge_raw_body_truncated(
client: TestClient, _fake_telegram_client: Any
) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=("x" * 10000).encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
# ── неизвестная форма payload — НЕ 500 ──────────────────────────────────────
def test_unknown_json_shape_forwarded_with_marker(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""Ни `text`, ни `attachments` — форма, которую GlitchTip НЕ шлёт сегодня,
но контракт задачи требует не падать 500, а переслать как есть."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json={"some_field": "some_value", "n": 42})
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "some_value" in sent_text
def test_non_json_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=b"not-json-at-all {{{",
headers={"content-type": "text/plain"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "not-json-at-all" in sent_text
def test_json_array_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
"""Валидный JSON, но не объект (top-level list) — тоже неизвестная форма."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=[1, 2, 3])
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_empty_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", content=b"")
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
# ── формат сообщения ─────────────────────────────────────────────────────────
def test_message_format_json_roundtrip(client: TestClient, _fake_telegram_client: Any) -> None:
"""Sanity: убеждаемся, что тестовый payload реально валиден как JSON (не
полагаемся на literal dict без проверки сериализации)."""
body = json.dumps(_ISSUE_PAYLOAD)
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=body.encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text