Вы нажимаете «Сохранить» один раз. Запрос завершается временной ошибкой, повторяется и в итоге проходит. Но в списке появляются две одинаковые заметки. Откуда взялась вторая?
Представим небольшой сервис, который создаёт заметку на каждый запрос. Этот самостоятельный пример на JavaScript теряет ответ после записи и сравнивает два клиента, вызывающих один и тот же сервис.
Расположите запись и сбой на одной временной шкале
Откройте скачанный пример и сначала прочитайте имитацию транспорта, а затем цикл повторов. Транспорт вызывает сервис заметок, фиксирует полученный ID и лишь после этого выбрасывает ResponseLost. Когда клиент получает ошибку, первая попытка уже изменила сохранённые данные.
| Шаг | Что видит клиент | Сохранённые заметки |
|---|---|---|
| Первая попытка выполняет запись | Ожидание ответа | Одна |
| Транспорт теряет ответ | Ошибка | Одна |
| Клиент отправляет запрос снова | Снова ожидание | Зависит от ключа и поиска на сервере |
| Приходит второй ответ | Успех | Одна или две |
Сбой намеренно расположен после записи. Если перенести его перед вызовом, проверяться будет другой случай, а дубликат исчезнет из теста. Транспорт выбрасывает ошибки детерминированно, без сети и таймеров.
Создайте ключ операции до входа в цикл
Два клиента отличаются только местом создания ключа:
function saveUnsafe(send, title, newKey) {
return createWithRetry(send, title, () => newKey());
}
function save(send, title, newKey) {
const key = newKey();
return createWithRetry(send, title, () => key);
}
В saveUnsafe callback создаёт новый ключ при каждой отправке из цикла. Сервис воспринимает запросы как две разные операции и создаёт две заметки. В save один ключ сохраняется до повторов, поэтому обе попытки передают одинаковые идентификатор и заголовок.
В примере используются пронумерованные строки, чтобы разницу было проще увидеть. Они обозначают логическую операцию сохранения, а не отдельную попытку передачи или сам заголовок. Два независимых сохранения с одинаковыми заголовками получают разные ключи. Проверьте время жизни генератора: даже переменная с названием «ключ идемпотентности» может создаваться заново внутри обёртки повторных попыток.
Проследите ключ до поиска на сервере
Постоянный ключ работает здесь потому, что сервис проверяет запись в completed до добавления заметки. Параметр deduplicate включён по умолчанию; один тест отключает эту проверку:
if (deduplicate && completed.has(key)) {
const previous = completed.get(key);
if (previous.title !== title)
throw new Error('key reused with another title');
return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;
При совпадении ключа и заголовка возвращается ранее созданная заметка. Если с тем же ключом приходит другой заголовок, сервис выбрасывает ошибку до новой записи. Заголовок — всё содержимое запроса в этом примере, поэтому сравнение охватывает весь ввод.
Запись хранит и входные данные, и результат. Проверьте ранний возврат: если выполнение продолжится до добавления, дубликат всё равно появится. Тест исправленной версии проверяет возвращённый объект и итоговое число заметок. Ещё один тест воспроизведения отключает дедупликацию на сервере: даже с постоянным клиентским ключом создаются две заметки.
Запустите сбои и сравните сохранённые результаты
Скачайте retry-idempotency.mjs. Запустите его в Node.js 22 или новее, без установки зависимостей:
node --test retry-idempotency.mjs
Все шесть тестов проходят. Два теста со словом «reproduce» в названии проходят, когда намеренно внесённая ошибка проявляется. createWithRetry допускает всего две попытки и повторяет только имитируемую потерю ответа. Конфликт заголовков останавливает выполнение после одной попытки.
| Сценарий | Отправки | Сохранённые заметки | Результат для вызывающего кода |
|---|---|---|---|
| Новый ключ при повторе | 2 | 2 | Вторая заметка, note-2 |
| Тот же ключ, результат используется повторно | 2 | 1 | Первая заметка, note-1 |
| Два независимых сохранения | 2 | 2 | Два разных ID |
| Другой заголовок с существующим ключом | Ещё 1 | Всего 1 | Отказ |
| Потеряны оба ответа | 2 | 1 | Ошибка после второй попытки |
| Тот же ключ без дедупликации | 2 | 2 | Вторая заметка |
Пятая строка полезна при чтении обработки ошибок: клиент завершается с ошибкой, хотя заметка уже существует. Проверки рассматривают сохранённые данные, ключи попыток и возвращённые ID. Проверка только успешного ответа могла бы пропустить повторную запись.
Найдите те же пять мест в приложении
- Найдите действие пользователя и определите, какие попытки относятся к одной задуманной операции.
- Найдите создание ключа и проследите его через каждую обёртку повторов и аргументы запроса.
- Откройте саму запись и отметьте ошибки, возможные после её завершения.
- Проследите поиск на сервере, сравнение входных данных и возврат предыдущего результата.
- Сопоставьте предел попыток и итоговую ветку ошибки с сохранённым состоянием.
Запишите соответствующие места в коде. Если обёртка повторно запускает callback, создающий ключ, прочитайте и его. Более полный путь от маршрута до хранилища разобран в статье как проследить HTTP-запрос по коду.
Отделяйте пример в памяти от гарантий сохранности
Сервис хранит всё в Map и массиве внутри одного процесса. Поиск и запись выполняются синхронно. Пример не проверяет транзакции базы данных, параллельные worker-процессы, перезапуски или восстановление сохранённых данных и не даёт гарантии exactly-once для рабочей системы.
В реальной реализации нужно определить область действия ключей, сравнение входных данных, срок хранения записей, обработку одновременных попыток и способ совместно сохранить запись и результат для повторного ответа при сбое. Этот клиент также забывает ключ после завершения сохранения. Для ручного повтора позже или перезагрузки страницы нужно отдельно продумать время жизни операции. Эти вопросы требуют чтения настоящего хранилища и клиента. Руководство по чтению кода поможет держать связанные вызовы и определения рядом во время разбора.