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

Ревью 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
This commit is contained in:
bot-backend 2026-09-12 15:24:21 +03:00
parent 1b595a0091
commit d5c876e3d0
3 changed files with 126 additions and 23 deletions

View file

@ -358,15 +358,25 @@ def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str)
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301). """Ключ идемпотентности inbound-отправки (#3471, миграция 301).
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key`
предпочтительный путь: клиент генерирует ключ ОДИН раз на "намерение ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
после таймаута) независимо от того, что именно менялось в UI между после таймаута) независимо от того, что именно менялось в UI между
попытками. Без заголовка (старые клиенты, п.5 требования они не должны попытками (см. `useSendSupportMessage` во фронтенде реальный трафик
сломаться) используем детерминированный отпечаток sha256(identity, текст, обязан идти этим путём). Fallback без заголовка детерминированный
минутное окно): типичный повтор (двойной клик, ретрай браузера) укладывается отпечаток sha256(identity, текст, минутное окно) существует ТОЛЬКО для
в секунды, минутное окно с запасом это покрывает, а любые два РАЗНЫХ по клиентов, которые заголовок не прислали (п.5 требования старое поведение
смыслу сообщения с одинаковым текстом, отправленные намеренно с разницей в не должно сломаться), и у него два честных изъяна, оба снимаются самим
пару минут, в одно не схлопнутся. фактом использования заголовка:
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
СХЛОПНУТСЯ в одно второе будет молча проглочено, клиент получит id
первого, оператор не увидит второе сообщение вообще;
2. окно не скользящие 60 секунд, а округление `time.time() // 60` вниз:
фактический срок дедупликации случаен от 0 до 60с в зависимости от
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
границе окна) эту защиту не получит.
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
(см. `useSendSupportMessage`).
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
@ -422,12 +432,21 @@ async def send_support_message(
if cooldown is not None: if cooldown is not None:
raise _too_many_failures_error(cooldown) raise _too_many_failures_error(cooldown)
# Идемпотентность (#3471): pre-check ДО похода в Telegram — повтор с тем же # Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
# ключом не должен слать второе зеркало в топик, не только не писать вторую # переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
# строку в БД. Тред может ещё не существовать (это первая отправка этого # успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
# ключа) — тогда сравнивать не с чем, идём в Telegram как обычно; сам факт # плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
# "нет треда" здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке # `send_message` не удался, ключ нигде не записан, и повтор клиента после
# (см. H1) — если бы он уже был записан под этим ключом, тред уже был бы. # неудачи это законная первая попытка. Реально закрывается другой, тоже
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text) idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
existing_thread_id = storage.find_thread_id(db, username) existing_thread_id = storage.find_thread_id(db, username)
if existing_thread_id is not None: if existing_thread_id is not None:

View file

@ -149,14 +149,74 @@ def record_inbound(
) )
if row is not None: if row is not None:
return dict(row) return dict(row)
# Конфликт пойман индексом (см. докстринг выше) — idempotency_key точно не
# NULL здесь: при NULL partial-индекс в конфликт не участвует вообще. # Конфликт пойман индексом — idempotency_key почти наверняка не NULL здесь
assert idempotency_key is not None # (при NULL partial-индекс в конфликт не участвует), но НЕ `assert`: это
existing = find_inbound_by_idempotency_key( # прод-путь ПОСЛЕ уже доставленного в Telegram сообщения (см. H1 в
db, thread_id=thread_id, idempotency_key=idempotency_key # app.api.v1.support), а `except SQLAlchemyError` в вызывающей стороне
# `AssertionError` не ловит — под `python -O` assert вдобавок исчезает
# молча и `existing` осталось бы `None`, что развалилось бы чуть ниже при
# сборке ответа. Вместо падения — явная ветка с логом и WORKING откатом.
existing = (
find_inbound_by_idempotency_key(db, thread_id=thread_id, idempotency_key=idempotency_key)
if idempotency_key is not None
else None
) )
assert existing is not None # конфликт по индексу гарантирует наличие строки if existing is not None:
return existing # Ожидаемый случай гонки (#3471): проигравший запрос уже отправил своё
# СОБСТВЕННОЕ зеркало в Telegram (свой topic_message_id) до того, как
# обнаружил конфликт здесь — эта копия зеркала теперь осиротела
# (реплай оператора на неё никуда не смаршрутизируется, т.к. строки
# для неё в БД нет). Осознанно не чиним это в этом PR (см. коммент
# выше по коду), но фиксируем в логе, а не молчим — ровно то же самое,
# для чего уже есть `_warn_operator_message_not_recorded` в другом месте.
logger.warning(
"web support: гонка по idempotency_key — зеркало topic_message_id=%s "
"(thread_id=%d) отправлено, но НЕ записано, победила строка id=%d",
topic_message_id,
thread_id,
existing["id"],
)
return existing
# Крайний случай (в норме недостижим при READ COMMITTED, которую использует
# этот сервис): индекс сообщил о конфликте, но повторное чтение строку не
# нашло. Логируем и пишем БЕЗ идемпотентности — NULL-ключ никогда не
# конфликтует сам с собой (partial-индекс его не видит), INSERT гарантированно
# пройдёт; для этого одного сообщения дедупликация отключается, но клиент
# получает корректный ответ вместо 500 после уже состоявшейся доставки.
logger.warning(
"web support: ON CONFLICT сообщил о конфликте (thread_id=%d, "
"idempotency_key=%s), но повторное чтение строки её не нашло — "
"записываем без идемпотентности",
thread_id,
"<set>" if idempotency_key is not None else None,
)
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, idempotency_key, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
},
)
.mappings()
.one()
)
return dict(row)
def find_thread_by_topic_message( def find_thread_by_topic_message(

View file

@ -42,6 +42,7 @@
* setQueryData-merge path that would be pure incidental complexity here. * setQueryData-merge path that would be pure incidental complexity here.
*/ */
import { useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api"; import { apiFetch, HTTPError } from "@/lib/api";
@ -129,13 +130,36 @@ export function useSupportUnread(enabled: boolean, scope: SupportScope = "auth")
export function useSendSupportMessage(scope: SupportScope = "auth") { export function useSendSupportMessage(scope: SupportScope = "auth") {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Идемпотентность (#3471): ОДИН `Idempotency-Key` на все повторы ОДНОГО
// намерения отправить. Бэкенд пишет строку/ключ ТОЛЬКО ПОСЛЕ успешной
// доставки в Telegram, поэтому это не про потери на плече Selectel ->
// api.telegram.org (неудачный send там — законная первая попытка) — это
// про то, что доставка УЖЕ состоялась, а ответ до клиента не дошёл (обрыв
// на обратном пути / клиентский таймаут) или пользователь дважды нажал
// "отправить" по одной и той же ещё не отрисовавшейся отправке. Без
// заголовка единственная защита — backend-fallback (минутный
// hash-отпечаток, `_resolve_idempotency_key` в app/api/v1/support.py), а
// он ненадёжен для осмысленно повторяющихся коротких сообщений ("+", "ок",
// два "да" подряд). Намерение отождествляем с текстом: тот же текст подряд —
// это ретрай ЭТОГО намерения, переиспользуем ключ; новый текст — новое
// намерение, новый UUID.
const intentRef = useRef<{ text: string; key: string } | null>(null);
return useMutation<SupportMessage, Error, string>({ return useMutation<SupportMessage, Error, string>({
mutationFn: (text) => mutationFn: (text) => {
apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, { if (!intentRef.current || intentRef.current.text !== text) {
intentRef.current = { text, key: crypto.randomUUID() };
}
return apiFetch<SupportMessage>(`${scopeBase(scope)}/messages`, {
method: "POST", method: "POST",
headers: { "Idempotency-Key": intentRef.current.key },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
}), });
},
onSuccess: () => { onSuccess: () => {
// Успех закрывает намерение — следующая отправка (даже с тем же текстом)
// обязана получить НОВЫЙ ключ, иначе она молча схлопнется с этой.
intentRef.current = null;
queryClient.invalidateQueries({ queryKey: messagesKey(scope) }); queryClient.invalidateQueries({ queryKey: messagesKey(scope) });
}, },
}); });