Клиент видит timeout, но сервер мог уже применить команду. Цена автоматического retry — двойная запись, двойная отправка письма или два списания. Удалить повтор из клиента тоже нельзя: для чтения временный сбой должен переживаться. Причина в том, что транспорт сообщает о доставке байтов, а не о том, какой эффект зафиксирован на стороне приложения.
Разберём эту границу на локальном счётчике и HTTP-методах. Код не имитирует сложную базу: он показывает, что повторяемость — свойство контракта операции, а не только сетевого соединения. После примера отделим idempotency key от простого request id и назовём ограничения.
Транспорт не знает бизнес-эффекта
TCP или QUIC могут доставить поток, обнаружить потерю и закрыть соединение. RFC 9000 описывает потоки, контроль потока и состояния соединения, но полезная нагрузка остаётся данными приложения. Клиент может получить исключение после отправки последнего байта. Из этого нельзя вывести, была ли транзакция применена.
| Идентификатор | Кто создаёт | Задача |
|---|---|---|
| connection id | транспорт | сопоставить пакеты соединению |
| request id | клиент или входной сервис | связать лог одной попытки |
| idempotency key | клиент для операции | узнать повтор того же действия |
| resource id | доменная модель | какой объект изменяется |
Идемпотентность — не «повтор без ошибок»
Идемпотентность означает, что несколько одинаковых запросов имеют тот же ожидаемый эффект, что и один. Ответы могут различаться: первый 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 можно рассматривать | остановиться и проверить контракт |
| Есть общий deadline? | ограничить все попытки | ввести deadline |
| Сервер дедуплицирует ключ? | повторить с тем же key | не повторять побочный эффект |
| Ответ точно связан с записью? | сохранить result | проверить состояние отдельно |
Порядок проверки контракта
- Назвать доменное действие и его побочный эффект, не ограничиваясь HTTP-методом.
- Проверить правило повторения в RFC-контракте API и коде сервера.
- Для создания определить формат, срок и область уникальности idempotency key.
- Сделать тест «ответ потерян после записи» и проверить, что повтор возвращает тот же результат.
- Сохранить request id, operation key и номер попытки в структурированном логе.
- Описать ответ на несовпадающее тело и истёкший ключ.
Ограничения и следующий шаг
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-клиента и не описывает поведение конкретной библиотеки.