DarkRiDDeR15 мин

Почему retry иногда удваивает данные: идемпотентность на уровне протокола

НадёжностьАрхитектура

Клиент видит timeout, но сервер мог уже применить команду. Цена автоматического retry — двойная запись, двойная отправка письма или два списания. Удалить повтор из клиента тоже нельзя: для чтения временный сбой должен переживаться. Причина в том, что транспорт сообщает о доставке байтов, а не о том, какой эффект зафиксирован на стороне приложения.

Разберём эту границу на локальном счётчике и HTTP-методах. Код не имитирует сложную базу: он показывает, что повторяемость — свойство контракта операции, а не только сетевого соединения. После примера отделим idempotency key от простого request id и назовём ограничения.

Транспорт не знает бизнес-эффекта

TCP или QUIC могут доставить поток, обнаружить потерю и закрыть соединение. RFC 9000 описывает потоки, контроль потока и состояния соединения, но полезная нагрузка остаётся данными приложения. Клиент может получить исключение после отправки последнего байта. Из этого нельзя вывести, была ли транзакция применена.

Три разных идентификатора
ИдентификаторКто создаётЗадача
connection idтранспортсопоставить пакеты соединению
request idклиент или входной сервиссвязать лог одной попытки
idempotency keyклиент для операцииузнать повтор того же действия
resource idдоменная моделькакой объект изменяется
Матрица границ надёжности: транспорт, HTTP-метод, ключ операции, запись и подтверждение результата.
Матрица не утверждает доставку конкретного запроса. Она показывает, какой слой отвечает на какой вопрос.

Идемпотентность — не «повтор без ошибок»

Идемпотентность означает, что несколько одинаковых запросов имеют тот же ожидаемый эффект, что и один. Ответы могут различаться: первый 200, второй 204 или 404 в зависимости от контракта. Поэтому тест проверяет состояние и правила сервера, а не только код ответа.

Для POST дедупликация часто строится на idempotency key. Сервер сохраняет результат по ключу и возвращает его при повторе, не создавая новый объект. Ключ должен быть привязан к смысловой операции и иметь срок хранения, иначе одинаковая строка через месяц случайно подавит новую команду. Этот срок и область уникальности являются частью API-контракта.

Учебный сервер с защитой от дубля

Код ниже принимает только локальные POST-запросы с заголовком Idempotency-Key. Вход — ключ и JSON-тело, но учебный сервер читает только ключ: это специально оставленная граница примера. Ожидаемый результат — два ответа с одним номером записи: второй запрос возвращает сохранённый результат. Это runnable пример поведения endpoint, а не готовая замена транзакции или базы данных.

import { createServer } from 'node:http';

const results = new Map();
let nextId = 1;

const server = createServer((request, response) => {
  if (request.method !== 'POST') { response.writeHead(405); response.end(); return; }
  const key = request.headers['idempotency-key'];
  if (!key) { response.writeHead(400); response.end('key required'); return; }
  if (!results.has(key)) results.set(key, { id: nextId++, state: 'created' });
  response.writeHead(200, { 'content-type': 'application/json' });
  response.end(JSON.stringify(results.get(key)));
});

server.listen({ host: '127.0.0.1', port: 0 }, async () => {
  const endpoint = 'http://127.0.0.1:' + server.address().port;
  const options = { method: 'POST', headers: { 'Idempotency-Key': 'order-42' }, body: '{}' };
  console.log(await (await fetch(endpoint, options)).json());
  console.log(await (await fetch(endpoint, options)).json());
  server.close();
});

Обе строки имеют один и тот же id. В примере Map живёт в памяти процесса, поэтому перезапуск сервера уничтожит историю. В настоящем сервисе ключ должен проверяться атомарно рядом с записью результата, а тело и параметры операции должны входить в проверку совпадения. Один заголовок без серверной дедупликации ничего не гарантирует.

Где начинается непредсказуемость

Если ключ повторно прислали с другим телом, сервер должен отклонить запрос или явно выбрать правило. Молча вернуть старый результат опасно: клиент может подумать, что новый адрес уже сохранён. Если срок хранения закончился, повтор того же ключ становится новой операцией. Эти варианты должны быть в контракте, тестах и журналах.

Request id не заменяет idempotency key. Он различает попытки, но каждый retry обычно получает новый request id. Ключ операции остаётся прежним и связывает попытки в одну семантическую команду. В логах полезно хранить оба поля, а также номер попытки и итоговое состояние.

Как выбирать поведение

Вопрос до автоматического retry
ВопросДаНет
Операция безопасна или идемпотентна?retry можно рассматриватьостановиться и проверить контракт
Есть общий deadline?ограничить все попыткиввести deadline
Сервер дедуплицирует ключ?повторить с тем же keyне повторять побочный эффект
Ответ точно связан с записью?сохранить resultпроверить состояние отдельно

Порядок проверки контракта

  1. Назвать доменное действие и его побочный эффект, не ограничиваясь HTTP-методом.
  2. Проверить правило повторения в RFC-контракте API и коде сервера.
  3. Для создания определить формат, срок и область уникальности idempotency key.
  4. Сделать тест «ответ потерян после записи» и проверить, что повтор возвращает тот же результат.
  5. Сохранить request id, operation key и номер попытки в структурированном логе.
  6. Описать ответ на несовпадающее тело и истёкший ключ.

Ограничения и следующий шаг

Map в примере не даёт атомарности при нескольких процессах, не переживает рестарт и не защищает от бесконечного роста. QUIC не решает дедупликацию, а HTTP-метод не раскрывает внутреннюю транзакцию. Для платежей и других критичных действий нужен отдельный контракт, тест отказа и согласованное хранилище результата.

Следующим шагом добавьте к одному endpoint тест с повтором одного ключа и другим телом. Ожидаемый результат должен быть явным: конфликт или тот же ответ, но не новая запись. После этого только выбирайте retry policy для клиента.

Проверяемые источники

  • RFC 9110 — HTTP Semantics — IETF Standards Track, June 2022. Задаёт семантику безопасных и идемпотентных методов, статусов и поля Retry-After. Граница применимости: Не выбирает политику повтора для конкретного API и не гарантирует отсутствие побочных эффектов.
  • RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport — IETF Standards Track, May 2021. Показывает, что транспортное управление потоками и потерями отделено от прикладной семантики запроса. Граница применимости: Не является инструкцией по retry HTTP-клиента и не описывает поведение конкретной библиотеки.