DarkRiDDeR15 мин

Под капотом идемпотентности: ключ, fingerprint и сохранённый HTTP-результат

HTTPBackendPostgreSQL

Симптом на 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-поля не должны случайно менять решение, но это не повод скрывать от команды, какие поля действительно значимы.

Вертикальная диаграмма состояний одной записи key: новый запрос атомарно резервирует in_progress, параллельный повтор получает conflict, завершённая операция хранит status и body для replay, а mismatch payload останавливается до эффекта
Ключ не кэширует «успех вообще». Он связывает один scope, один fingerprint и одно terminal решение, которое может быть повторно показано клиенту.

Минимальная запись результата

Что хранит учебная запись идемпотентности
ПолеЗачем нужноЧего поле не доказываетПроверка
actor_id + operation + idem_keyscope и уникальный захват 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/статус ожидания; не запускать второй обработчик
Другая hashkey ошибочно привязан к другому 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 записей, для которых поздний повтор уже запрещён контрактом.

Маршрут проверки механизма

  1. Выбрать scope: субъект, операция и key. Убедиться, что одинаковый key другого пользователя не читает и не блокирует чужой результат.
  2. Согласовать canonical payload и fingerprint. Изменить одно значимое поле в фикстуре и проверить, что обработчик не запускается повторно.
  3. Добавить уникальное ограничение в выбранное устойчивое хранилище и воспроизвести два одновременных INSERT с одним scope/key.
  4. Проверить ветки completed, in_progress и conflict: каждая возвращает документированный HTTP-ответ без второго эффекта.
  5. Для внутреннего эффекта определить транзакционную границу. Для внешнего — отдельно назвать стабильный идентификатор и способ восстановить зависшую запись.
  6. Задать 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 для атомарного захвата уникальной записи в выбранной базе