3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1fa65eba6b |
fix(tg): связь с Telegram не встаёт колом, ответ оператора не теряется
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 чистые. Прокси намеренно не добавлялся: замер был на восьми запросах, это не статистика, и решение инфраструктурное. Если обрывы останутся — мерить сотней попыток отдельно. |
||
|
|
1f85ef7d4e |
fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
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
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей (PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile, roles.yaml, auth_session.py не тронуты. - sentry_scrub.py: новая scrub_payment_request_body — вырезает event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64 кладёт полное тело запроса в request.data, send_default_pii=False это НЕ гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS: customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey. - main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала, before_send и before_send_transaction. Мотивирующий инцидент: на соседнем продукте вчера закрыли только error-канал, transaction остался без обработчика вообще. - ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное отключение — backstop против шторма запросов остаётся, подпись проверяется уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией). - request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware внешний относительно rbac_guard и читает сырой X-Authenticated-User — спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin). - smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401 (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра. Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор (не substring — комментарии в этих же файлах сами упоминают before_send_transaction) на проводку обоих каналов во всех точках инициализации, 400 запросов notify без единого 429 + контроль что общий лимитер по-прежнему активен на других путях, notify вне user_events даже со спуфнутым X-Authenticated-User: admin. |
||
|
|
b579fa4ced |
feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот доставляет ответ клиенту. Полный лог переписки в Postgres. Отдельный контейнер на long-polling, а не webhook в tradein-backend: не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API. Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage. Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате, поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор. Безопасность (найдено ревью, воспроизведено эмпирически): - токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет; утекал в GlitchTip через locals стек-фреймов (include_local_variables по умолчанию True) и через span data HttpxIntegration. Закрыто include_local_variables=False + regex-редактор в before_send (обе формы: /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub. - httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в docker logs каждые 30с. Приглушён до WARNING. Надёжность: - kill-switch при пустом токене — idle-блокировка, не exit(0): при restart: unless-stopped выход с любым кодом даёт рестарт-луп. unless-stopped выбран сознательно — только он гарантирует автозапуск после ребута VPS. - stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше, чем докрутится long-poll (30с) и отработает drain (100с). - сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset: иначе сессия в failed-transaction не давала сохранить offset, апдейт переигрывался и зеркалился в топик по кругу. 152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента одним DELETE. Ретенция — follow-up. Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS, без них воркер штатно висит в idle. Порядок — в DEPLOY.md. Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked, throttle-окно шапки, redaction токена во всех формах event). |