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
Замер прода за сутки 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 чистые. Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика, и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно.
339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""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
|