gendesign/tradein-mvp/backend/app/tgbot_main.py
bot-backend 165c4a5edd
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 17s
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 6m59s
fix(obs): убрать шум выключенных платежей и трейсы health-проб proxy_pool
Два источника шума в error-ленте и логах:

1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503
   "payments are disabled" из payments.py._require_enabled, которые бьёт
   внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk
   StarletteIntegration репортит любой HTTPException с кодом из 5xx как
   error-событие, даже когда FastAPI штатно обработал исключение и вернул
   корректный ответ. Добавлен before_send-фильтр
   drop_payments_disabled_event (app/observability/sentry_scrub.py),
   матчащий по (status_code=503, detail="payments are disabled") через
   hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие
   503. Подключён во всех трёх точках инициализации sentry_sdk.init
   (app/main.py — единственный реальный источник события,
   scheduler_main.py и tgbot_main.py — belt-and-suspenders для
   единообразия, по образцу scrub_payment_request_body). Само поведение
   ручки не меняется — 503 остаётся, фильтруется только репортинг в
   трекер.

2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера)
   не попадал ни под TimeoutException, ни под ConnectError и падал в
   generic except Exception с exc_info=True — 184 строки полного
   traceback в сутки на штатный провал health-пробы, хотя итоговая
   сводка checked/ok/failed и так его учитывает. Добавлена отдельная
   ветка except httpx.ProxyError с логом в одну строку (узел + причина
   текстом исключения, без трейса). Логика самой пробы, аренды узлов и
   правил пула не изменена.

Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает
целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации),
tests/services/test_proxy_pool.py (ProxyError логируется одной строкой
без exc_info, счётчики healthcheck не ломаются).

Refs #3471
2026-09-12 14:03:39 +03:00

203 lines
10 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.

"""Standalone entrypoint для Telegram support-bridge воркера (#tgsupport).
Зачем отдельный процесс/контейнер: `getUpdates` long-polling держит открытый
HTTP-запрос к Telegram до 30с за раз в бесконечном цикле — деплой основного API
(docker restart tradein-backend) не должен обрывать эту петлю на середине, как и
API не должен блокироваться долгим poll'ом. Тот же паттерн, что и
`scheduler_main.py` (#1182) для scraper'ов — отдельный контейнер с тем же образом,
другая команда.
Запуск: python -m app.tgbot_main
Kill-switch: TELEGRAM_BOT_TOKEN пуст (дефолт) → воркер логирует «disabled» и
блокируется на `wait_for_shutdown()` (idle, ~0 CPU) — НЕ `sys.exit(0)`. Сервис в
compose поднят с `restart: unless-stopped`, который рестартует контейнер
независимо от кода выхода — чистый exit(0) без токена дал бы бесконечный
рестарт-луп. Idle-блокировка держит процесс живым (автозапуск после ребута VPS
работает штатно через restart-policy) без CPU-луп и без спама рестартов;
SIGTERM просто убивает процесс — восстанавливать здесь нечего (bridge-задача
не запущена).
"""
from __future__ import annotations
import asyncio
import logging
import os
import signal
from contextlib import suppress
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
from app.services.tgbot.bridge import run_poll_loop
from app.services.tgbot.client import TelegramClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# httpx INFO-логи печатают ПОЛНЫЙ request URL, включая Telegram Bot API токен
# в пути (https://api.telegram.org/bot<id>:<secret>/...) — `httpx: HTTP Request:
# POST https://api.telegram.org/bot<TOKEN>/getMe "HTTP/1.1 401 Unauthorized"`.
# При бесконечном long-polling'e это боевой токен в `docker logs` каждые ~30с.
# WARNING+ у httpx не логирует URL запроса (#tgsupport review, воспроизведено).
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# Тот же safety-net паттерн, что scheduler_main.py — ниже docker stop_grace_period.
_DRAIN_TIMEOUT_S = 100.0
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). Только integrations
# без Starlette/FastAPI — здесь нет ASGI-приложения (тот же выбор что scheduler_main).
if settings.glitchtip_dsn:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_pii_event,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
Токен утекает ДВУМЯ независимыми векторами, которые
`include_local_variables=False` ниже и этот хук закрывают вместе:
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
2. `HttpxIntegration` кладёт полный request URL в span `data` (не только
traceback) — `traces_sample_rate=0.0` спасает СЕЙЧАС, но молча
перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор
— belt-and-suspenders на случай #1 (если include_local_variables
случайно вернут) И на span data.
Payments-disabled drop и платёжный body-wipe — belt-and-suspenders: этот
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
сегодня, реальный источник 503 — app/main.py), но тот же обработчик
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
единообразия со всеми точками инициализации sentry_sdk в проекте.
"""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
include_local_variables=False,
before_send=_before_send,
before_send_transaction=_before_send,
integrations=[
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
logger.info("GlitchTip monitoring enabled (tgbot_main)")
def _should_run() -> bool:
"""Kill-switch: TELEGRAM_BOT_TOKEN не задан → бот выключен (dev/staging без секрета)."""
return bool(settings.telegram_bot_token)
async def _run_bridge() -> None:
# `async with` — чтобы пул keep-alive соединений закрывался при любом выходе
# из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение).
# Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и
# заставляло каждый long-poll начинаться с TCP+TLS-хендшейка.
async with TelegramClient(settings.telegram_bot_token) as client:
await run_poll_loop(client, SessionLocal)
async def _await_bridge(task: asyncio.Task[None]) -> None:
"""Кооперативный SIGTERM-drain — идентичная семантика scheduler_main._await_scheduler.
`run_poll_loop` сам проверяет `shutdown_requested()` между итерациями (между
getUpdates-вызовами) — long-polling запрос к Telegram (до 30с) докручивается,
затем цикл выходит сам. Safety-net здесь на случай зависшего HTTP-вызова.
"""
shutdown_waiter = asyncio.create_task(wait_for_shutdown())
try:
await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED)
finally:
shutdown_waiter.cancel()
with suppress(asyncio.CancelledError):
await shutdown_waiter
if task.done():
task.result()
logger.info("tgbot_main: bridge task exited cleanly")
return
logger.info(
"tgbot_main: SIGTERM-drain — waiting up to %.0fs for current poll iteration to finish",
_DRAIN_TIMEOUT_S,
)
try:
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
logger.info("tgbot_main: bridge drained and exited cleanly")
except TimeoutError:
logger.warning(
"tgbot_main: drain exceeded %.0fs grace — hard-cancelling bridge task",
_DRAIN_TIMEOUT_S,
)
task.cancel()
with suppress(asyncio.CancelledError):
await task
async def _run() -> None:
task = asyncio.create_task(_run_bridge())
loop = asyncio.get_running_loop()
def _on_signal(signum: int) -> None:
logger.info("tgbot_main: signal %d received — requesting cooperative drain", signum)
request_shutdown()
try:
loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM))
loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT))
except NotImplementedError:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("tgbot_main: loop.add_signal_handler not supported (Windows dev)")
await _await_bridge(task)
if shutdown_requested():
logger.info("tgbot_main: bridge drained cleanly (SIGTERM)")
else:
logger.info("tgbot_main: bridge task exited")
if __name__ == "__main__":
if not _should_run():
# NOT sys.exit(0): compose service has `restart: unless-stopped`, который
# рестартует контейнер независимо от кода выхода — чистый exit(0) без
# токена дал бы бесконечный рестарт-луп. Idle-блокировка вместо этого:
# ~0 CPU, SIGTERM просто убивает процесс (нечего дренировать).
logger.warning(
"tgbot_main: TELEGRAM_BOT_TOKEN не задан — бот выключен, "
"блокируемся на idle (не exit, чтобы не было рестарт-лупа с restart:unless-stopped)"
)
asyncio.run(wait_for_shutdown())
else:
asyncio.run(_run())