Дедупликация повторных запросов в чатах и формах без вреда для UX
Дедупликация повторных запросов помогает убрать двойные отправки из чатов и форм, сохранить UX и не терять данные при сбоях сети и очередей.

Почему появляются двойные отправки
Повторная отправка чаще возникает не из-за "невнимательного пользователя", а из-за обычного поведения интерфейса, сети и серверной части. Человек нажал кнопку один раз, не увидел ответа за секунду и нажал снова. Система делает то же самое: клиент, мобильное приложение или очередь могут повторить запрос автоматически.
Самая частая причина - молчаливый интерфейс. Кнопка не меняет состояние, индикатор загрузки не появляется, сообщение в чате не рисуется сразу. Пользователь видит пустоту и жмет еще раз. Для него это одна попытка довести действие до конца. Для сервера - уже две.
Вторая частая причина - таймаут. Клиент отправил запрос, сервер начал обработку, но ответ не успел вернуться вовремя. Приложение решает, что запрос "пропал", и повторяет его. Проблема в том, что первый запрос к этому моменту уже мог создать запись, отправить письмо или сохранить сообщение в чат. Второй приходит через пару секунд и делает то же самое еще раз.
На мобильной сети это случается еще чаще. Соединение может оборваться после отправки данных, но до получения ответа. Телефон переключился между Wi-Fi и LTE, приложение восстановило сессию и отправило тот же пакет заново. Пользователь видит одно действие, а у вас уже два почти одинаковых запроса с близким временем.
С очередями и воркерами история похожая. Многие очереди работают по правилу "доставим хотя бы один раз". Если воркер обработал событие, но не успел подтвердить это перед сбоем, очередь отдаст то же событие повторно. Так и появляются повторы из очередей: второй запуск задачи, повторное списание, дубль уведомления или еще одно сообщение в диалоге.
Иногда источник повтора неочевиден. Одна и та же форма может уйти из веба и мобильного клиента почти одновременно. Чат может отправить сообщение локально, а потом повторить его после реконнекта. Без дедупликации повторных запросов такие случаи выглядят как "редкие странности", хотя это обычная цена работы в ненадежной сети и распределенной системе.
Правило простое: если действие проходит через кнопку, сеть или очередь, повтор рано или поздно появится.
Как понять, что это повтор, а не новое действие
Повтор почти никогда не выглядит как точная копия. У него может отличаться время, номер попытки, сетевой заголовок или технический trace id. Сравнивать такие поля почти бесполезно. Ловить нужно само действие пользователя.
Самый надежный способ - дать каждому действию свой id еще на клиенте. Пользователь нажал "Отправить" один раз, и приложение сразу создало action_id. Если сеть подвисла, мобильный клиент повторил запрос или очередь прислала его еще раз, этот же action_id идет дальше по всей цепочке. Тогда сервер видит не "еще один похожий запрос", а ту же самую попытку.
Одного id мало, если клиент иногда теряет его или создает новый при повторе. Поэтому полезно сверять и смысл запроса. Обычно достаточно смотреть на автора действия, адресата или объект, содержимое запроса и экран, на котором это произошло.
В чатах окно повтора обычно короткое. Если один и тот же пользователь отправил одно и то же сообщение в тот же диалог через 5-10 секунд, это часто дубль из-за плохой сети или двойного нажатия. Но через пару минут это уже может быть осознанный повтор. В формах окно часто длиннее. Заявка на кредит, оплата или заказ могут прилететь повторно и через минуту, и через десять, если человек несколько раз жмет кнопку после долгой загрузки.
Поэтому окно дедупликации лучше хранить отдельно для каждого типа действия. Для чатов важна скорость и мягкое поведение. Для форм важнее защита от второго списания, второго заказа или второй регистрации. Одно общее правило для всех сценариев обычно ломает либо UX, либо бизнес-логику.
Если сервер узнал тот же action_id, не стоит отвечать новой ошибкой. Лучше вернуть прошлый результат. Для пользователя это выглядит спокойно: сообщение уже отправлено, форма уже принята, задача уже создана. Такой ответ убирает лишнюю тревогу и не заставляет гадать, сработало ли действие.
Рабочая схема выглядит так: клиент создает id, сервер хранит его вместе с результатом, а повторный запрос получает тот же ответ. Именно так идемпотентность запросов помогает отличать двойные отправки форм и повторные сообщения в чатах от нового действия, даже если повторы из очередей приходят позже и в другом порядке.
Как защититься и не испортить UX
Человек жмет "Отправить" второй раз не потому, что хочет создать дубль. Обычно интерфейс просто молчит слишком долго. Для пользователя тишина выглядит так же, как сбой. Поэтому дедупликация повторных запросов должна идти вместе с понятной обратной связью.
Кнопку отправки лучше блокировать сразу после нажатия, но ненадолго. Часто хватает пары секунд или ожидания первого ответа от сервера. Если сеть подвисла дольше, верните кнопку и покажите, что происходит. Кнопка, которая зависла навсегда, раздражает сильнее, чем редкая двойная отправка.
Что должен видеть человек
После отправки экран должен простыми словами объяснять состояние запроса. Обычно хватает таких сообщений:
- "Отправляем..."
- "Запрос принят, ждем ответ"
- "Связь оборвалась. Повторить?"
- "Мы уже получили этот запрос"
Последнее сообщение особенно полезно там, где мобильная сеть часто скачет. Если сервер уже принял первый запрос, второй не должен выглядеть как новая ошибка. Лучше сразу показать, что система узнала повтор и не создала дубль. В чате это один пузырь сообщения со статусом доставки, а не два одинаковых сообщения подряд.
Сильно помогает и черновик. Если человек набрал длинный текст в чате или заполнил форму, а связь пропала, не заставляйте его начинать заново. Сохраняйте текст локально после заметных изменений. Для формы обычно хватает полей и вложений. Для чата - текста и выбранных файлов.
Нормальный сценарий выглядит так: пользователь отправляет сообщение с телефона в дороге, сеть рвется, он жмет кнопку еще раз. Первый запрос уже дошел до сервера, второй пришел с тем же ключом идемпотентности. Интерфейс не показывает красную ошибку и не создает второе сообщение. Он спокойно пишет: "Сообщение уже отправлено".
Так двойные отправки форм и повторные сообщения в чатах исчезают для системы и почти не заметны для человека. Пользователь не думает про ретраи, очереди и сетевые сбои. Он просто видит, что запрос не потерялся.
Схема для клиента, сервера и очереди
Рабочая схема начинается на клиенте, а не на сервере. Если пользователь нажал кнопку в форме два раза, потерял сеть или приложение решило повторить отправку само, система должна считать это одним действием, пока не доказано обратное.
Для этого клиент создает уникальный id еще до первого запроса. Один и тот же id он отправляет при каждом повторе, даже если запрос ушел заново через мобильную сеть, retry в SDK или повторную попытку после таймаута.
- Клиент генерирует
request_idдо отправки и хранит его рядом с черновиком действия. - Сервер принимает запрос, ищет этот id в хранилище и сразу понимает, был ли такой вызов раньше.
- Если это первая попытка, сервер выполняет действие, сохраняет итоговый ответ и помечает запрос как обработанный.
- Если приходит повтор с тем же id, сервер не создает вторую запись, а возвращает тот же ответ, который сохранил после первой попытки.
- Если сервер ставит задачу в очередь, он передает тот же id дальше, а воркер пишет его в свои логи и в результат обработки.
Такой порядок дает предсказуемое поведение. Пользователь видит один результат вместо двух заказов, двух сообщений или двух списаний.
На сервере важно хранить не только сам факт повтора, но и итог первой обработки. Иначе вы поймаете дубль, но не сможете честно ответить клиенту тем же телом и тем же статусом. На практике это ломает UX: интерфейс думает, что запрос не удался, хотя сервер уже все сделал.
С очередями та же логика. Если брокер или воркер делает повтор, новый id придумывать нельзя. Иначе одно действие распадается на несколько независимых событий, и дедупликация перестает работать в самом дорогом месте - после записи в базу или вызова внешнего сервиса.
Логи тоже должны держаться за один id по всей цепочке: клиент, API, очередь, воркер, внешний вызов. Если команда отправляет LLM-запросы через AI Router, этот же id полезно передавать до вызова модели и в аудит-логи. Тогда спорный случай разбирается быстро: видно, где был первый запрос, где случился retry и почему пользователь увидел повтор.
Что менять в чатах
В чате единица действия - это сообщение, а не нажатие на кнопку "Отправить". Как только пользователь отправил текст, клиент должен создать message_id и держать его до конца жизни этого сообщения: при перерисовке экрана, повторном подключении и любом retry. Если UI создал новый id после обновления компонента, дубль вы сделали сами.
Дедупликация повторных запросов в чате обычно сводится к одному правилу: один смысловой ввод пользователя равен одному id. Этот id нужно отправлять на сервер вместе с текстом, а сервер должен сохранять результат по нему. Если сеть дернулась и клиент повторил запрос, сервер не создает вторую запись, а возвращает уже известное сообщение и его текущий статус.
Плохой вариант встречается часто: произошел таймаут, UI не дождался ответа и создал новое сообщение с тем же текстом. Пользователь видит две одинаковые реплики, а модель отвечает на обе. Гораздо лучше оставить один пузырь в истории и менять только его состояние: "отправляется", "доставлено" или "ошибка".
Что делать со стримингом
При стриминге легко спутать сбой транспорта с новым вводом пользователя. Если SSE или WebSocket оборвался на середине ответа, это еще не новое сообщение. Клиенту лучше повторно привязаться к уже существующему response_id или запросить текущее состояние ответа, а не запускать генерацию заново.
Новый ответ нужен только тогда, когда пользователь отправил новый текст с новым message_id. Обрыв потока, повторный ACK или повторная доставка чанка не должны создавать новую реплику в истории.
На практике обычно хватает четырех правил: хранить message_id на клиенте до подтверждения или явной ошибки, при retry отправлять тот же id, привязывать поток ответа к конкретному сообщению и показывать в истории один элемент, даже если сеть несколько раз переподключилась.
Для LLM-чата это особенно заметно. Пользователь отправил "Сделай краткое резюме письма", мобильная сеть моргнула, приложение переотправило запрос. Если id остался прежним, сервер вернет то же сообщение, а история останется чистой. Если id сменился, вы получите два одинаковых вопроса, двойной расход токенов и путаницу в диалоге.
Что менять в формах и фоновых задачах
У форм и фоновых задач одна и та же проблема: пользователь или сеть думают, что запрос пропал, и отправляют его снова. Если сервер не видит связь между этими попытками, появляются лишние заявки, повторные списания или второй запуск долгой работы.
Формы
Когда пользователь нажал "Отправить", закрепите за этой попыткой постоянный id и держите его до перезагрузки страницы или явного изменения данных. Если браузер пошлет запрос еще раз из-за двойного клика, слабой мобильной сети или автоповтора в клиентском коде, сервер увидит тот же id и поймет, что это не новое действие.
Отпечаток формы тоже полезен, но собирать его нужно аккуратно. Не включайте туда поля, которые меняются сами: время отправки, случайный nonce, служебный счетчик или порядок полей после повторного рендера. Иначе два одинаковых запроса станут выглядеть разными.
Новая отправка должна появляться только после явного изменения данных. Если человек открыл форму, нажал кнопку два раза и ничего не поменял, это та же попытка. Если он исправил телефон или комментарий, это уже новое действие, и id нужно сгенерировать заново.
Для UI правило короткое: создайте id в момент первой отправки, храните его, пока данные не изменились, не меняйте id при retry и показывайте понятный статус вроде "отправляем" или "уже принято".
Фоновые задачи
Если форма ставит задачу в очередь, передавайте тот же id дальше, без подмены на каждом этапе. Один и тот же id должен дойти до API, очереди и воркера. Иначе форма будет защищена, а очередь все равно запустит работу дважды.
Для долгих задач храните состояние: "принято", "в работе", "готово", "ошибка". Тогда повторный запрос не стартует процесс заново, а возвращает текущий статус или уже готовый результат. Это особенно важно для тяжелых операций вроде batch-обработки, fine-tuning или массовой оценки ответов моделей.
Тот же подход полезен и в AI Router: если клиент повторил запрос, внутренняя задача не должна второй раз запускать одну и ту же работу и тратить лишние ресурсы. Один id, одно состояние, один результат.
Частые ошибки
Самая частая ошибка проста: дедупликация живет только на фронтенде. Кнопка блокируется, спиннер крутится, второй клик не проходит, и кажется, что все под контролем. Но мобильная сеть может переотправить запрос, серверный retry может сработать сам, а очередь иногда отдает одно и то же сообщение повторно. Если сервер не проверяет идемпотентность запросов, защита на экране мало что решает.
Еще одна частая ошибка - неверное окно дедупликации. Короткое окно хорошо выглядит в тестах на стабильном Wi-Fi, но в реальной жизни человек может потерять сеть в лифте, дождаться переподключения и случайно отправить тот же запрос через 20-40 секунд. Если запись о первом запросе уже исчезла, система примет дубль как новое действие.
Слишком длинное окно тоже мешает. Пользователь может намеренно отправить то же сообщение в чат повторно или заново заполнить форму спустя время. Если система слишком долго считает действие старым, она начнет гасить честные повторные действия.
Проблемы появляются и тогда, когда новый id создается на каждом retry. В тестах все выглядит нормально, но при первом таймауте одно действие распадается на несколько независимых запросов. Сервер уже не может понять, что перед ним повтор.
Еще одна ошибка - хранить только факт дедупликации, но не результат первой обработки. В таком случае сервер распознает дубль, но отвечает чем-то вроде "уже было" вместо прежнего тела ответа и статуса. Пользователь снова не понимает, прошло действие или нет.
И наконец, не теряйте id между API, очередью и воркером. Это случается чаще, чем кажется. На входе у вас одна попытка, а внутри системы внезапно появляются два разных события с двумя разными идентификаторами. После этого повторы из очередей уже не склеиваются.
Короткий сценарий из практики
Пользователь едет в метро и с телефона отправляет форму: например, заявку на обратный звонок или обращение в поддержку. Он нажимает кнопку один раз, видит крутилку, но связь в тоннеле рвется. Экран не получает ответ вовремя и показывает привычный текст: попробуйте еще раз.
Человек нажимает кнопку повторно. Для него это одно действие. Для системы это уже два почти одинаковых запроса, которые пришли с разницей в несколько секунд.
Если защита сделана плохо, сервер создаст две заявки. Потом обе попадут в очередь, оператор увидит копии, а пользователь может получить два звонка вместо одного. На бумаге это выглядит как мелочь. На деле такие дубли портят учет, тратят время поддержки и просто раздражают.
Нормальный сценарий работает иначе. Приложение отправляет запрос с одним и тем же client_request_id. Повторная отправка уходит с тем же id, даже если пользователь нажал еще раз. Сервер находит этот id в хранилище идемпотентности и понимает, что действие уже приняли. Вместо новой записи он возвращает прошлый статус или готовый ответ. Очередь получает одну задачу, а не две.
Пользователь чаще всего даже не замечает, что система отработала повтор. Он просто видит понятный итог: форма принята, номер заявки тот же, второй дубль не появился. Такая дедупликация не спорит с поведением человека. Она исходит из простого факта: в плохой сети люди нажимают кнопку еще раз.
Для поддержки разница тоже заметна сразу. В CRM лежит одна заявка, а не пачка одинаковых карточек. Оператор не тратит время на сверку, какую из копий закрывать. Очередь не гоняет лишнюю работу, а фоновые обработчики не шлют повторные уведомления.
Так и должна работать дедупликация в проде: тихо, предсказуемо и без наказания пользователя за плохую связь.
Быстрая проверка перед запуском
Перед релизом устройте короткий тест с одним и тем же id запроса. Отправьте запрос три раза: сразу, через 2-3 секунды и после искусственного таймаута. Первый вызов должен пройти как новый, а два следующих система должна распознать как повтор.
Для чата это проверяется быстро. В истории должно остаться одно сообщение пользователя и один ответ, даже если кнопка отправки нажалась несколько раз или мобильная сеть дернула соединение. Если чат рисует дубликат, значит клиент или сервер слишком поздно сверяет id.
С формой проверка строже. Один и тот же запрос должен создать одну запись в базе и только один job в очереди. Часто команда смотрит только на базу, видит порядок и успокаивается, а потом обнаруживает, что очередь получила две одинаковые задачи. В итоге письмо уходит дважды, документ генерируется дважды или лимит списывается повторно.
Проверьте четыре вещи:
- сервер возвращает тот же результат для повторной отправки с тем же id
- чат показывает одно сообщение, а не копии одного текста
- форма создает одну запись и ставит в очередь один job
- логи сохраняют id запроса без раскрытия персональных данных
Отдельно откройте логи и проверьте, что там видно решение по каждому запросу: новый он или повторный. Если вы маскируете PII, убедитесь, что в логах не остаются телефон, email, ИИН, адрес и другие поля, по которым можно узнать человека.
Проверьте и срок хранения id. Если система забывает id слишком рано, повторы из очередей или нестабильной сети пройдут как новые запросы. Если хранить id слишком долго, пользователь не сможет повторить уже нормальное действие спустя время. Для чата часто хватает короткого окна. Для фоновой задачи окно обычно нужно дольше.
Если этот тест проходит без дублей в интерфейсе, без второй записи в базе и без лишнего job, схема уже выглядит здоровой. Это хороший минимум перед запуском, особенно там, где ошибка стоит денег или портит разговор с пользователем.
Что сделать дальше
Начните с одного решения, без которого вся схема быстро расползется: определите, где рождается id действия. Для формы это обычно клиент до первой отправки. Для чата чаще всего id тоже лучше создавать на клиенте для каждого сообщения, даже если сеть пропала и приложение попробует отправить его снова. Для фоновой задачи id нередко создает сервер при постановке в очередь, но дальше этот id уже нельзя менять.
Если этого правила нет, дедупликация превращается в набор случайных проверок. Сегодня вы ловите двойной клик, завтра ловите retry от мобильной сети, а послезавтра получаете повтор из очереди с новым техническим идентификатором и уже не можете связать его с первым запросом.
После этого зафиксируйте простую таблицу правил. Она должна жить не в голове одного разработчика, а в задаче, ADR или короткой внутренней памятке. Для каждого сценария укажите, кто создает id, сколько он живет, что считается повтором и какой ответ система возвращает при дубле. Отдельно пропишите, как id проходит через API, очередь, воркер и логи.
И еще одно практичное правило: сохраняйте не только id, но и результат первой обработки. Иначе вы распознаете повтор, но не сможете ответить спокойно и последовательно.
Если сделать только эти шаги, большая часть дублей уйдет уже на старте. А там, где повтор все же случится, система обработает его как штатный случай, а не как аварию.