fix(tradein): идемпотентная отправка сообщения в поддержку (#3471) #3495

Merged
lekss361 merged 3 commits from feat/3471-support-send-idempotency into main 2026-09-12 12:33:09 +00:00
Collaborator

Summary

  • Идемпотентность отправки сообщения в веб-чат поддержки (#3471). Важно, что именно закрывается: доставка в Telegram уже состоялась и строка закоммичена, но ОТВЕТ до клиента не дошёл (обрыв на обратном пути / клиентский таймаут), либо пользователь дважды нажал "отправить" по одной и той же ещё не отрисовавшейся отправке. Потеря самого запроса на плече Selectel -> api.telegram.org НЕ дедуплицируется этим PR и не должна — неудачный send_message ничего не пишет в БД, повтор клиента после отказа это законная первая попытка.
  • Ключ: Idempotency-Key от клиента (фронт теперь реально его шлёт — useSendSupportMessage генерирует crypto.randomUUID() на намерение отправить и переиспользует его на ретраях/повторных кликах) ИЛИ, для клиентов без заголовка, детерминированный fallback-отпечаток sha256(identity|текст|минутное окно) — у fallback два честных изъяна (описаны в докстринге _resolve_idempotency_key), которые снимаются самим фактом использования заголовка.
  • Pre-check резолвит тред и ищет уже записанное сообщение с этим ключом ДО похода в Telegram — иначе повтор всё равно слал бы второе зеркало в топик.
  • Гонку двух одновременных запросов с одним ключом закрывает INSERT ... ON CONFLICT DO NOTHING на partial unique индексе (thread_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND direction='in' в record_inbound — не read-then-write. Конфликт больше не assert-ится (это прод-путь ПОСЛЕ доставки в Telegram, except SQLAlchemyError в support.py его не ловит) — явная ветка с логом; проигравшая гонку копия зеркала (topic_message_id) логируется, а не пропадает молча.
  • Новая миграция 301_web_support_message_idempotency_key.sql (idempotent DDL, проверена на живой БД ревьюером).

Known limitations (следующим заходом, не в этом PR)

  • Осиротевшее зеркало проигравшего гонку — сейчас только warning-лог, без пометки в самом Telegram-топике.
  • Срок жизни клиентского idempotency-ключа не ограничен на уровне БД (уникален в рамках треда навсегда).

Test plan

  • uv run ruff check — зелёный на изменённых файлах
  • uv run pytest tests/test_support.py — 50 passed
  • Регрессия подтверждена откатом: временно вернул support.py/web_support_storage.py к forgejo/main, прогнал новые idempotency-тесты — все 4 упали (AttributeError), затем восстановил патч

🤖 Generated with Claude Code

https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG

## Summary - Идемпотентность отправки сообщения в веб-чат поддержки (#3471). Важно, что именно закрывается: доставка в Telegram уже состоялась и строка закоммичена, но ОТВЕТ до клиента не дошёл (обрыв на обратном пути / клиентский таймаут), либо пользователь дважды нажал "отправить" по одной и той же ещё не отрисовавшейся отправке. Потеря самого запроса на плече Selectel -> api.telegram.org НЕ дедуплицируется этим PR и не должна — неудачный `send_message` ничего не пишет в БД, повтор клиента после отказа это законная первая попытка. - Ключ: `Idempotency-Key` от клиента (фронт теперь реально его шлёт — `useSendSupportMessage` генерирует `crypto.randomUUID()` на намерение отправить и переиспользует его на ретраях/повторных кликах) ИЛИ, для клиентов без заголовка, детерминированный fallback-отпечаток `sha256(identity|текст|минутное окно)` — у fallback два честных изъяна (описаны в докстринге `_resolve_idempotency_key`), которые снимаются самим фактом использования заголовка. - Pre-check резолвит тред и ищет уже записанное сообщение с этим ключом ДО похода в Telegram — иначе повтор всё равно слал бы второе зеркало в топик. - Гонку двух одновременных запросов с одним ключом закрывает `INSERT ... ON CONFLICT DO NOTHING` на partial unique индексе `(thread_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND direction='in'` в `record_inbound` — не read-then-write. Конфликт больше не `assert`-ится (это прод-путь ПОСЛЕ доставки в Telegram, `except SQLAlchemyError` в support.py его не ловит) — явная ветка с логом; проигравшая гонку копия зеркала (topic_message_id) логируется, а не пропадает молча. - Новая миграция `301_web_support_message_idempotency_key.sql` (idempotent DDL, проверена на живой БД ревьюером). ## Known limitations (следующим заходом, не в этом PR) - Осиротевшее зеркало проигравшего гонку — сейчас только warning-лог, без пометки в самом Telegram-топике. - Срок жизни клиентского idempotency-ключа не ограничен на уровне БД (уникален в рамках треда навсегда). ## Test plan - [x] `uv run ruff check` — зелёный на изменённых файлах - [x] `uv run pytest tests/test_support.py` — 50 passed - [x] Регрессия подтверждена откатом: временно вернул support.py/web_support_storage.py к `forgejo/main`, прогнал новые idempotency-тесты — все 4 упали (AttributeError), затем восстановил патч 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
bot-backend added 1 commit 2026-09-12 12:00:45 +00:00
fix(tradein): идемпотентная отправка сообщения в поддержку (#3471)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Failing after 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m0s
091befc9ff
Сеть Selectel -> api.telegram.org теряет заметную долю коротких запросов,
поэтому браузерный ретрай/двойной клик/переотправка по таймауту при отправке
в веб-чат поддержки создавали ВТОРУЮ строку в web_support_messages И второе
зеркало в support-топике Telegram, а не только дубль в БД.

Ключ идемпотентности (миграция 301, колонка idempotency_key +
partial unique индекс (thread_id, idempotency_key) WHERE direction='in'):
- явный заголовок Idempotency-Key от клиента, если он есть и валидной формы;
- иначе детерминированный fallback-отпечаток sha256(identity|текст|минутное
  окно) — старые клиенты без заголовка продолжают работать без изменений.

Pre-check резолвит тред по identity и ищет существующее inbound-сообщение с
этим ключом ДО похода в Telegram (не только до записи в БД) — иначе повтор
всё равно отправил бы второе зеркало, даже если бы вторая строка в БД не
создавалась. Гонку двух одновременных запросов с одним ключом закрывает
INSERT ... ON CONFLICT DO NOTHING на уникальном индексе в
web_support_storage.record_inbound (не read-then-write), а не сам pre-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
bot-backend added 1 commit 2026-09-12 12:02:30 +00:00
fix(sql): lock_timeout у миграции ключа идемпотентности
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 7m56s
1b595a0091
Проверка миграций в CI требует его для блокирующего DDL: без ограничения
ALTER встаёт в очередь за чужой сессией и уводит за собой все последующие
обращения к таблице (#2752).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
bot-backend added 1 commit 2026-09-12 12:24:34 +00:00
fix(tradein): включаем idempotency-key на фронте, чиним assert-crash и честность докстрингов (#3471)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 7m35s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m10s
d5c876e3d0
Ревью PR #3495 нашло, что механизм был мёртвым кодом: фронт не отправлял
Idempotency-Key ни в одном запросе, весь прод-трафик шёл по ненадёжному
fallback-отпечатку. Плюс два "assert" в record_inbound после ON CONFLICT
давали AssertionError (не ловится except SQLAlchemyError) уже ПОСЛЕ
доставки в Telegram — под `python -O` assert и вовсе исчезает.

- useSupportChat.ts: useSendSupportMessage генерирует Idempotency-Key
  (crypto.randomUUID()) на намерение отправить, переиспользует его при
  повторной отправке ТОГО ЖЕ текста, сбрасывает на успехе.
- web_support_storage.record_inbound: assert -> явные ветки с логом;
  логируем отброшенный topic_message_id проигравшего гонку (не молча).
- Докстринги/комментарии переписаны честно: что именно закрывает
  pre-check (ответ клиенту потерян / двойной клик после успеха), а что
  НЕ закрывает (сетевую потерю на плече Selectel -> Telegram — там
  сообщение просто не доставлено, повтор это законная первая попытка).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
lekss361 merged commit 4c8c02cce5 into main 2026-09-12 12:33:09 +00:00
lekss361 deleted branch feat/3471-support-send-idempotency 2026-09-12 12:33:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3495
No description provided.