Симптом на backend выглядит как два почти одинаковых POST с одним key. Если каждый request сразу вызывает обработчик, две вкладки или retry после медленного соединения создадут две записи. Если же сервис хранит только boolean seen=true, следующий request не знает, был ли первый ещё в работе, закончился ошибкой или успел создать результат до падения процесса. Цена — либо дубль эффекта, либо неопределённый ответ, который фронтенд не может объяснить пользователю.
Причина в том, что идемпотентность — не флаг в middleware, а договор о состоянии операции. Для автора июня 2020 года это стык HTTP, хранилища и delivery: один key резервирует право обработать конкретный intent; fingerprint отделяет настоящий retry от другого payload; terminal запись хранит ровно тот результат, который можно вернуть повтору. В этой статье используются PostgreSQL 12 и псевдокод как учебная граница. Они не описывают базу или API этого блога и не обещают готовый production endpoint.
У ключа всегда есть scope
Строка ключа не обязана быть уникальной во всей вселенной. Нужен scope, в котором её интерпретирует сервис. Для учебной заявки это разумная тройка: аутентифицированный actor_id, операция demo-request.create и сам idem_key. Тогда одинаковая строка, пришедшая от другого пользователя или в другой операции, не открывает чужой результат и не блокирует несвязанную команду. Scope нельзя строить на X-Client-Attempt: попытка описывает доставку, а не бизнес-эффект.
Fingerprint добавляется к scope, потому что случайное или ошибочное повторное использование key опаснее, чем конфликт. Сервис канонически выбирает именно те поля, которые определяют эффект, получает их hash и сохраняет его при первом request. Повтор с тем же key и тем же fingerprint вправе получить сохранённый ответ. Повтор с тем же key, но другим fingerprint получает conflict и не запускает новое действие. Форма канонизации — часть контракта: порядок полей JSON, пробелы и незначимые display-поля не должны случайно менять решение, но это не повод скрывать от команды, какие поля действительно значимы.
Минимальная запись результата
| Поле | Зачем нужно | Чего поле не доказывает | Проверка |
|---|---|---|---|
actor_id + operation + idem_key | scope и уникальный захват intent | глобальную уникальность всех клиентов | уникальное ограничение базы содержит все три части |
payload_hash | разделяет retry и другой payload | корректность самой канонизации | изменённое значимое поле даёт conflict до обработчика |
state | различает in_progress и terminal | что внешний эффект уже согласован | параллельный request не начинает второй обработчик |
status_code + response_body | даёт повтору тот же наблюдаемый результат | что ответ дошёл до первого клиента | готовый retry возвращает сохранённый result ID |
expires_at | задаёт окно deduplication и уборки | безопасность позднего повтора после очистки | срок больше максимального retry-budget и оговорён в API |
Не требуется сохранять полный сырой request, заголовки авторизации или все диагностические поля. Для replay обычно хватает статуса, минимального ответа, стабильно выбранного result ID и безопасной причины отказа. Это уменьшает риск положить в таблицу данные, которые затем прочитает не тот сотрудник или которые нельзя хранить так долго. Но слишком бедная запись тоже вредна: если в terminal state нет ответа, retry снова вынужден гадать, что показать пользователю.
Уникальность должна жить там, где конкурируют запросы
Проверка if (!cache.has(key)) в одном Node-процессе не защищает второй процесс, другой pod или рестарт. Поэтому минимальная защита находится в хранилище с уникальным ограничением. В PostgreSQL 12 UNIQUE и INSERT ... ON CONFLICT позволяют одной транзакции выиграть создание строки, а второй увидеть, что место уже занято. После этого второй request читает запись и выбирает ветку replay, in-progress или conflict; он не «пробует ещё раз» вызвать основной обработчик.
-- Учебная схема PostgreSQL 12, не миграция конкретного проекта.
CREATE TABLE request_idempotency (
actor_id bigint NOT NULL,
operation varchar(80) NOT NULL,
idem_key varchar(128) NOT NULL,
payload_hash char(64) NOT NULL,
state varchar(16) NOT NULL,
status_code integer,
response_body jsonb,
created_at timestamp NOT NULL,
expires_at timestamp NOT NULL,
UNIQUE (actor_id, operation, idem_key)
);
INSERT INTO request_idempotency (
actor_id, operation, idem_key, payload_hash, state, created_at, expires_at
) VALUES (
:actorId, :operation, :key, :payloadHash, "in_progress", now(), :expiresAt
)
ON CONFLICT (actor_id, operation, idem_key) DO NOTHING
RETURNING state, payload_hash;
SQL выше намеренно не выдаёт полный production schema. Типы actor, TTL, hash и response зависят от проекта. Существеннее порядок: уникальность проверяет база, а не приложение; только владелец вставленной строки продолжает к действию; конкурент сначала читает уже существующее решение. Если команда использует другое хранилище, ей нужно воспроизвести именно эту атомарную развилку, а не перенести название поля idem_key в незащищённую коллекцию.
Четыре ветки одного key
| Наблюдение | Причина | Проверка | Действие HTTP-слоя |
|---|---|---|---|
| Вставка прошла | key ещё не занят в данном scope | текущая транзакция владеет записью | обработать intent и сохранить terminal результат |
Та же hash, completed | первый запрос уже закончен | есть status и минимальное response body | вернуть сохранённый HTTP-результат без нового эффекта |
Та же hash, in_progress | первый обработчик ещё не завершил договор | нет terminal state | вернуть документированный conflict/статус ожидания; не запускать второй обработчик |
| Другая hash | key ошибочно привязан к другому payload | сравнение выполнено до business action | вернуть 409 Conflict и потребовать новый intent |
В RFC 7231 статус 409 Conflict выражает конфликт с текущим состоянием ресурса. В 2020 году отдельного утверждённого HTTP-стандарта для idempotency key ещё не было, поэтому нельзя приписывать этому коду универсальную семантику. API должен в документации назвать, означает ли 409 payload mismatch, выполняющийся запрос или оба случая с различимым машинным полем. Клиент в любом случае не создаёт новый key автоматически: сначала он понимает, какой именно конфликт увидел.
Завершаем запись тем же решением, которое увидит клиент
После захвата slot обработчик выполняет бизнес-действие, затем сохраняет terminal status и body. Для операции, эффект которой целиком лежит в той же базе, полезно поставить запись результата и сам объект в одну транзакцию. Тогда после commit либо видны оба факта, либо ни один. Именно здесь idempotency становится проверяемой: второй request находит созданную запись и возвращает тот же requestId, а не создаёт ещё один.
// Учебный псевдокод. db.transaction определяет границу выбранной БД.
async function acceptCreate(db, input) {
const slot = await db.transaction(async (tx) => {
const inserted = await tx.insertIdempotencyIfAbsent({
actorId: input.actorId,
operation: "demo-request.create",
key: input.key,
payloadHash: hashCanonicalPayload(input.payload),
});
if (inserted) return { kind: "owner" };
const saved = await tx.readIdempotency(input.actorId, "demo-request.create", input.key);
if (saved.payloadHash !== hashCanonicalPayload(input.payload)) {
return { kind: "conflict" };
}
if (saved.state === "completed") return { kind: "replay", saved: saved };
return { kind: "in_progress" };
});
if (slot.kind !== "owner") return slot;
const result = await db.transaction(async (tx) => {
const created = await createDemoRequest(tx, input.payload);
await tx.finishIdempotency(input.actorId, "demo-request.create", input.key, created);
return created;
});
return { kind: "created", result: result };
}
Псевдокод не раскрывает внутреннюю реализацию createDemoRequest, не открывает настоящий HTTP-сервер и не показывает библиотеку базы. В учебной ветке внутреннее создание результата и finishIdempotency лежат в одной транзакции: после commit можно вернуть сохранённый code и безопасное body. Если выбранное действие нельзя включить в эту транзакцию, команда должна прямо назвать, какой этап остаётся внешним и как будет восстанавливаться запись in_progress.
Внешний эффект не становится атомарным от локальной таблицы
Самая дорогая ошибка появляется, когда сервис отправил запрос наружу, а затем не успел записать completed. При рестарте локальная строка может остаться in_progress, хотя внешняя система уже создала объект. Таймер очистки не решает проблему: удалить ключ и выполнить действие снова означает сознательно создать возможный дубль. Здесь нужен второй договор на внешней границе — стабильный идентификатор объекта, свой idempotency key у поставщика или путь проверки результата по business ID.
Это ограничение меняет дизайн retry. Для внутренней SQL-записи можно говорить о транзакции. Для вызова по сети разумнее сначала выбрать устойчивый внешний ключ, записать намерение, затем сделать вызов и иметь восстановительный маршрут для in_progress. В 2020 году для небольшого сервиса не обязательно вводить event streaming или большую платформу. Достаточно честно назвать, где заканчивается транзакция базы и кто владеет повтором после обрыва именно на внешнем hop-е.
Срок хранения — часть временного бюджета
Запись key нельзя удалить сразу после 201. Первый ответ мог потеряться, браузер мог сделать retry через паузу, а proxy — закончить свой путь позже клиента. Окно хранения должно быть больше максимального пользовательского budget, разрешённой задержки retry и известных промежуточных timeout для этой операции. Слишком короткий TTL превращает поздний retry в новый effect; слишком длинный срок без политики расширяет таблицу и удерживает результат дольше нужного. Поэтому API фиксирует срок и задаёт отдельную уборку только terminal записей, для которых поздний повтор уже запрещён контрактом.
Маршрут проверки механизма
- Выбрать scope: субъект, операция и key. Убедиться, что одинаковый key другого пользователя не читает и не блокирует чужой результат.
- Согласовать canonical payload и fingerprint. Изменить одно значимое поле в фикстуре и проверить, что обработчик не запускается повторно.
- Добавить уникальное ограничение в выбранное устойчивое хранилище и воспроизвести два одновременных INSERT с одним scope/key.
- Проверить ветки
completed,in_progressи conflict: каждая возвращает документированный HTTP-ответ без второго эффекта. - Для внутреннего эффекта определить транзакционную границу. Для внешнего — отдельно назвать стабильный идентификатор и способ восстановить зависшую запись.
- Задать retention длиннее retry-budget, затем проверить очистку terminal записей на тестовом времени; не удалять in-progress как способ спрятать сбой.
Границы механизма
Таблица не заменяет авторизацию, rate limit, валидацию payload и защиту от перегрузки. Она также не делает ключ секретом и не даёт гарантии exactly once между независимыми системами. Её более скромная задача: в одном scope сервис отличает повтор завершённого intent от второго параллельного выполнения и от попытки подменить payload. Этого достаточно, чтобы timeout превратился из повода послать второй POST в конкретную запись, состояние и проверку.
В пакете не поднимались PostgreSQL, HTTP-прокси, browser или реальный сервис. SQL, hash, TTL и ответы — учебные конструкции; адреса, пользовательские данные и credentials отсутствуют. Перед внедрением нужно проверить версию базы, режим изоляции транзакций, реальный объём response body, способ canonicalization, политику хранения и поведение внешних зависимостей при обрыве после принятия запроса.
Проверяемые источники
- RFC 7231: HTTP/1.1 Semantics and Content — стандарт HTTP, актуальный в 2020 году: семантика idempotent methods, статусы 409/503 и поле Retry-After; позже заменён RFC 9110
- IETF draft-idempotency-header-01, январь 2020 — исторический work in progress, а не утверждённый RFC: описывает ключ, fingerprint, повтор завершённой операции и конфликт параллельного повтора
- PostgreSQL 12: Constraints — официальное описание уникальных ограничений; уникальность ключа должна проверяться хранилищем, а не только памятью одного процесса
- PostgreSQL 12: INSERT и ON CONFLICT — официальная семантика INSERT ... ON CONFLICT для атомарного захвата уникальной записи в выбранной базе