Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
19.10.2026
•
Обновлено:
19.10.2026

Идемпотентность API: как Idempotency-Key защищает от повторного создания заказов

Максим Быков

Сетевой таймаут при отправке POST /orders ставит клиентское приложение в состояние неопределенности: соединение оборвалось, но сервер мог успеть обработать запрос, списать средства, зарезервировать товар на складе и зафиксировать транзакцию в базе данных. Слепой повторный запрос в такой ситуации создает классическую проблему дублирования заказа и двойного списания денег.

Стандартные HTTP-методы GET, PUT и DELETE идемпотентны по спецификации — повторный вызов приводит к тому же состоянию системы. Метод POST неидемпотентен: каждый новый запрос трактуется сервером как команда создать новый ресурс. Чтобы сделать повторные попытки (retry) безопасными при сбоях сети, в архитектуру REST API внедряют заголовок Idempotency-Key.


Что такое Idempotency-Key и как устроен базовый контракт

Idempotency-Key — это HTTP-заголовок, с помощью которого клиент передает уникальный идентификатор конкретной операции. Если сервер получает запрос с ключом, который он уже видел, он не выполняет бизнес-логику заново, а возвращает сохраненный результат предыдущей обработки.

POST /api/v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer sec_token_xyz
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json

{
  "items": [{"id": 42, "quantity": 1}],
  "currency": "RUB",
  "amount": 1500
}

Принцип работы согласно IETF Draft

Спецификация IETF (The Idempotency-Key HTTP Header Field) регламентирует поведение API при передаче этого заголовка:

  1. Ключ связывается с конкретной мутирующей операцией.
  2. Сервер сохраняет статус выполнения и итоговый ответ (HTTP-код, заголовки, тело).
  3. При повторе запроса с идентичным ключом и телом сервер отдает сохраненный ответ без повторного вызова сервисов оплаты или создания сущностей в БД.

Почему ключ генерирует клиент, а не сервер

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

Клиент генерирует случайный идентификатор локально (обычно это UUID v4) непосредственно в момент инициации действия пользователем (например, при нажатии кнопки «Оплатить») и сохраняет его на время выполнения транзакции.

Область действия ключа (Scope) и изоляция пользователей

Ключ идемпотентности не должен быть глобальным в рамках всей системы. Его область действия (Scope) всегда привязывается к контексту авторизации:

  • Идентификатор пользователя (user_id) или арендатора (tenant_id);
  • Метод и путь эндпоинта (POST /api/v1/orders).

Изоляция по пользователю исключает коллизии UUID между разными клиентами и предотвращает атаки, при которых один пользователь пытается перехватить ответ на чужой запрос, угадав или сгенерировав чужой ключ.


Модель хранения: какие данные необходимо сохранять

Для корректной работы механизма на стороне сервера создается отдельное хранилище записей идемпотентности.

Состав записи

Каждая запись в таблице или кэше идемпотентности должна содержать следующие поля:

Поле Тип Назначение
scope String / UUID Контекст пользователя или API-ключа (tenant_id:user_id)
idempotency_key String Значение переданного HTTP-заголовка
request_path String Метод и путь эндпоинта
request_hash String Контрольная сумма тела запроса (SHA-256)
status Enum Текущий статус: PROCESSING, COMPLETED, FAILED
response_code Integer HTTP-статус сохраненного ответа (например, 201, 400, 500)
response_body JSON / Text Тело ответа для возврата клиенту
created_at Timestamp Время создания записи
locked_until Timestamp Время истечения распределенной блокировки (для защиты от зависаний)
CREATE TABLE idempotency_records (
    scope VARCHAR(128) NOT NULL,
    idempotency_key VARCHAR(255) NOT NULL,
    request_path VARCHAR(255) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    response_code INT NULL,
    response_body JSONB NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    PRIMARY KEY (scope, idempotency_key, request_path)
);

CREATE INDEX idx_idempotency_ttl ON idempotency_records (created_at);

Выбор хранилища: PostgreSQL против Redis

Выбор между реляционной базой данных и in-memory хранилищем зависит от архитектуры сервиса:

  • PostgreSQL / MySQL: Позволяет выполнять запись об идемпотентности в единой ACID-транзакции с созданием заказа. Это исключает рассинхронизацию (когда заказ создался, а кэш идемпотентности упал). Минус — дополнительная нагрузка на дисковую подсистему и необходимость очистки устаревших строк через фоновый процесс или партиционирование.
  • Redis: Обеспечивает минимальные задержки и автоматическое удаление по TTL (Time To Live). Минус — при использовании двух независимых баз (Redis для ключей, Postgres для заказов) возникает проблема распределенного состояния (Dual Writes), требующая аккуратной обработки сбоев.

Срок хранения ключей (TTL) определяется SLA сервиса. В платежных шлюзах он обычно составляет от 24 до 48 часов. По истечении срока запись удаляется, и повторный запрос с тем же ключом обрабатывается как новый.


Обработка сложных сценариев и граничных случаев

Простая проверка «есть ли ключ в базе» не защищает от реальных проблем распределенных систем. Сервер должен корректно реагировать на конфликты параметров, гонки запросов и внутренние ошибки.

1. Таймаут сети и безопасный повтор

Клиент отправляет POST /orders, сервер создает заказ в БД, но при отправке ответа сетевой стек обрывает соединение. Клиент получает Connection Timeout и делает повторный запрос с тем же Idempotency-Key и тем же телом. Сервер находит запись в статусе COMPLETED, пропускает бизнес-логику и сразу возвращает клиенту оригинальный 201 Created с тем же телом, что и в первый раз.

Клиент                    Сервер                   База данных
  |                         |                           |
  |--- POST /orders ------->|                           |
  |    (Key: uuid-1)        |--- INSERT Order --------->|
  |                         |    INSERT Idempotency (OK)|
  |    [Network Timeout]    |                           |
  |x- - - - - - - - - - - - |                           |
  |                         |                           |
  |--- POST /orders ------->|                           |
  |    (Key: uuid-1)        |--- SELECT Idempotency --->|
  |                         |<-- Status: COMPLETED -----|
  |<-- 201 Created ---------| (Бизнес-логика не вызвана)|

2. Конкурентные запросы (Race Condition) и код 409 Conflict

Если пользователь делает «двойной клик» или мобильное приложение отправляет два запроса одновременно по разным каналам связи с одинаковым ключом, возникает состояние гонки.

Если оба запроса одновременно выполнят SELECT, они не увидят завершенной записи и попытаются списать средства дважды. Для предотвращения гонки первый запрос должен атомарно зафиксировать состояние PROCESSING (через INSERT ... ON CONFLICT в SQL или команду Redis SET resource_key my_lock NX EX 30).

Второй запрос, обнаружив активный статус PROCESSING, отклоняется с кодом 409 Conflict (согласно IETF Draft) либо переходит в режим контролируемого ожидания снятия блокировки до завершения первой операции.

3. Несовпадение Payload и код 422 Unprocessable Content

Ключ идемпотентности привязан к одной конкретной операции. Если клиент отправляет запрос с существующим ключом, но изменяет тело запроса (например, сумму или список товаров), сервер не должен возвращать старый ответ или создавать новый заказ.

Сервер вычисляет SHA-256 хэш нормализованного JSON-тела входящего запроса и сравнивает его с request_hash, сохраненным в базе. При несовпадении сервер возвращает ошибку 422 Unprocessable Content (или 400 Bad Request в зависимости от реализации API) с сообщением о несоответствии полезной нагрузки ключу.

4. Ошибки 5xx первого запроса

Если во время первой попытки произошел сбой инфраструктуры (HTTP 500/503), поведение зависит от архитектурной политики:

  • Если транзакция не откатилась или результат зафиксирован, сервер сохраняет ответ 500 и возвращает его детерминированно при повторе.
  • Если транзакция была полностью и безопасно откатана без побочных эффектов, запись идемпотентности удаляется, позволяя клиенту повторить вызов с тем же ключом.

Пошаговый алгоритм работы сервера

При получении запроса middleware или контроллер API выполняет следующие действия:

[Входящий POST запрос]
         │
         ▼
[Извлечь Idempotency-Key] ──(Нет ключа)──► [Обычная неидемпотентная обработка]
         │
         ▼
[Вычислить SHA-256(Payload)]
         │
         ▼
[Атомарная попытка захвата: INSERT INTO idempotency_records]
         │
 ┌───────┴────────────────────────┐
 │ Успешно                        │ Ключ уже существует
 ▼                                ▼
[Статус: PROCESSING]             [Проверить request_hash]
 │                                │
 │                                ├──(Хэш не совпал)──► [422 Unprocessable Content]
 │                                │
 │                                └──(Хэш совпал)──┐
 │                                                 ▼
 │                                        [Проверить статус записи]
 │                                                 │
 │                                                 ├──(PROCESSING)──► [409 Conflict]
 │                                                 │
 │                                                 └──(COMPLETED)───► [Вернуть сохраненный Response]
 ▼
[Выполнить создание заказа и списание]
 │
 ┌───────┴───────────────┐
 │ Успех                 │ Ошибка бизнес-логики
 ▼                       ▼
[Сохранить 201 Created]  [Сохранить код ошибки]
[Статус: COMPLETED]      [Статус: FAILED / COMPLETED]
 │                       │
 └───────┬───────────────┘
         ▼
[Отдать ответ клиенту]
  1. Валидация: Извлечь заголовок Idempotency-Key. Если заголовок отсутствует, выполнить операцию стандартно (или вернуть 400, если заголовок обязателен).
  2. Хеширование: Вычислить SHA-256 отпечаток от тела запроса.
  3. Атомарная регистрация: Попытаться вставить запись со статусом PROCESSING в хранилище.
    • Если запись успешно создана — перейти к шагу 4 (выполнение бизнес-логики).
    • Если запись уже существует — извлечь сохраненную строку:
      • Сравнить хэши тела. Если хэши не совпадают — вернуть 422 Unprocessable Content.
      • Если статус PROCESSING — вернуть 409 Conflict.
      • Если статус COMPLETED — вернуть сохраненный response_code и response_body.
  4. Выполнение операции: Вызвать платежный шлюз и сохранить заказ в БД.
  5. Фиксация результата: Обновить статус записи идемпотентности на COMPLETED, записать итоговый HTTP-код и тело ответа.
  6. Ответ: Отправить сформированный HTTP-ответ клиенту.

Интеграционное тестирование механизма без реальных платежей

Для верификации идемпотентности не требуются реальные платежные шлюзы: внешние зависимости изолируются с помощью моков (WireMock, Mock-серверы), а проверки фокусируются на поведении слоя API и базы данных.

1. Тест на восстановление после таймаута

Сценарий: Клиент имитирует обрыв соединения и повторяет вызов.

  • Шаг 1: Отправить POST /api/v1/orders с ключом key-test-1.
  • Шаг 2: Зафиксировать создание 1 заказа в БД и успешный ответ 201 Created с {"order_id": 101}.
  • Шаг 3: Отправить второй запрос POST /api/v1/orders с тем же ключом key-test-1 и тем же телом.
  • Ожидаемый результат: Сервер возвращает 201 Created и {"order_id": 101}. Количество заказов в БД остается равным 1. Мок платежного шлюза зафиксировал ровно 1 вызов API списания.

2. Тест на состояние гонки (Race Condition)

Сценарий: Одновременная отправка двух запросов с одинаковым ключом в параллельных потоках.

  • Шаг 1: Сконфигурировать мок внешнего сервиса с задержкой ответа (sleep 500ms).
  • Шаг 2: Запустить два параллельных HTTP-запроса с одинаковым Idempotency-Key: race-key-1 и одинаковым телом.
  • Ожидаемый результат: Ровно один поток получает 201 Created (или оба получают 201, если реализовано ожидание), второй поток получает 409 Conflict. В базе данных создается строго одна запись заказа.

3. Тест на мутацию полезной нагрузки (Payload Tampering)

Сценарий: Повторное использование ключа с другими параметрами.

  • Шаг 1: Отправить POST /api/v1/orders с ключом key-test-2 и суммой 1000.
  • Шаг 2: Дождаться ответа 201 Created.
  • Шаг 3: Отправить POST /api/v1/orders с тем же ключом key-test-2, но с суммой 2000.
  • Ожидаемый результат: Сервер возвращает HTTP-статус 422 Unprocessable Content с описанием ошибки: несовпадение тела запроса для указанного ключа.

Частые ошибки при реализации идемпотентности

При проектировании механизма часто допускаются архитектурные просчеты:

  1. Проверка ключа без блокировки (Time-of-check to time-of-use): Разделение логики на шаги SELECT (проверка) и последующий INSERT (сохранение) вне атомарной транзакции открывает окно для состояния гонки. Защиту гарантирует только уникальный индекс в БД или атомарная команда в кэше (SET NX).
  2. Отсутствие валидации Payload: Сохранение только связки «ключ — ответ» без сверки хэша тела запроса позволяет ошибочному клиенту подставить чужой ключ под совершенно другие параметры заказа.
  3. Хранение ключей без привязки к Scope: Глобальная уникальность ключа приводит к коллизиям и потенциальной утечке данных между разными пользователями API.
  4. Передача конфиденциальных данных в ключе: Заголовок Idempotency-Key логируется в access-логах веб-серверов. Помещение в него номеров карт, телефонов или секретных токенов нарушает требования безопасности. Ключ должен быть псевдослучайной последовательностью (UUID).

Часто задаваемые вопросы (FAQ)

Нужно ли сохранять ответ, если сервер вернул ошибку 500?

Если при первой попытке произошла внутренняя ошибка, но статус транзакции остался неопределенным, сохраненный ответ 500 возвращается клиенту для сохранения детерминированности. Если транзакция была гарантированно откатана без побочных эффектов, запись идемпотентности удаляют, позволяя клиенту повторить вызов.

Как долго сервер должен хранить ключи идемпотентности?

Срок хранения (TTL) определяется бизнес-требованиями сервиса. В большинстве платежных и e-commerce систем TTL составляет от 24 до 48 часов. По истечении этого времени повторный запрос с тем же ключом обрабатывается как абсолютно новый.

Что делать, если клиент изменил сумму заказа, но отправил старый ключ?

Сервер обязан вычислить хэш нового тела запроса, обнаружить несовпадение с сохраненным request_hash и вернуть статус 422 Unprocessable Content. Менять параметры существующей операции под старым ключом запрещено контрактом.

Защищает ли Idempotency-Key от двойного клика пользователя в интерфейсе?

Да, если клиентское приложение генерирует UUID ключа в момент открытия формы или первого клика на кнопку «Оплатить» и прикрепляет один и тот же ключ ко всем автоматическим или повторным попыткам отправки этой конкретной формы.

Можно ли использовать Idempotency-Key для методов GET и DELETE?

Методы GET и DELETE являются идемпотентными по спецификации HTTP: GET не меняет состояние системы, а повторный DELETE приводит к тому же результату (ресурс удален). Использование Idempotency-Key для них избыточно и применяется только к неидемпотентным методам (POST, иногда PATCH).


Заключение

Использование заголовка Idempotency-Key переводит обработку сетевых таймаутов из категории непредсказуемых сбоев в детерминированный процесс. Надежная реализация держится на четырех правилах: генерация ключа клиентом, изоляция по пользователю (scope), атомарная фиксация состояния для защиты от гонок и обязательное хеширование тела запроса. Внедрение этой схемы полностью исключает риск дублирования заказов и повторных списаний средств.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также