Logo
Overview

Идемпотентность в REST API: повторные запросы, ключ идемпотентности и защита от дублей

August 14, 2026
7 min read

Идемпотентность — это не про «сервер ленится и ничего не делает». Это про то, что повторный запрос должен приводить к тому же результату, что и первый, а не наделать дел дважды. Пока вы пишете GET-методы, проблема кажется надуманной. Но как только в системе появляются деньги, списания и заказы — цена дубля становится очень конкретной: клиент платит дважды, а вы потом месяц разбираете жалобы в саппорте.

Если вы проектируете REST API с нуля, загляните в разбор stateless и stateful API — идемпотентность напрямую связана с тем, как сервер помнит или не помнит предыдущие запросы. А про коды ответов, которые мы будем упоминать дальше, есть отдельный справочник HTTP-кодов.

Что такое идемпотентность простыми словами

Идемпотентность — свойство операции, при котором многократное выполнение одного и того же запроса даёт тот же результат, что и однократное.

Представьте копирование файла. Сколько бы раз вы ни нажали «копировать» поверх одного и того же файла, результат один — файл перезаписан. Второе копирование не создаёт второй файл и не накапливает что-то лишнее. Это идемпотентность в чистом виде.

А теперь представьте кнопку «оплатить» в интернет-магазине. Интернет моргнул, страница повисла, вы нажали ещё раз. Два нажатия — два списания. Вот тут идемпотентность уже не абстракция из учебника, а то, что отделяет рабочую платёжку от потока возвратов.

Примечательно, что в REST идемпотентность зашита на уровне самих HTTP-методов. Часть методов идемпотентна по определению, часть — нет. И путаница между ними ломает API на ровном месте.

Идемпотентные и неидемпотентные HTTP-методы

МетодИдемпотентный?Безопасный (safe)?Что делает
GETДаДаЧитает данные, ничего не меняет
HEADДаДаКак GET, но без тела ответа
PUTДаНетПолностью заменяет ресурс
DELETEДаНетУдаляет ресурс
PATCHЗависит от реализацииНетЧастично обновляет ресурс
POSTНетНетСоздаёт ресурс или запускает действие

Разберём самую скользкую строчку — PATCH. Если тело PATCH описывает конкретное значение («установи статус заказа в “оплачен”»), повторный вызов безвреден. Но если тело — это инкремент («увеличь баланс на 100»), то каждый повтор добавляет ещё 100. Такой PATCH неидемпотентен. Всё решает семантика, которую вы сами заложили в метод.

Безопасный не значит идемпотентный

Стоит развести два термина, которые часто склеивают. Безопасный метод не меняет состояние сервера вовсе — это про GET и HEAD. Идемпотентный метод меняет состояние, но повтор даёт тот же результат — это PUT и DELETE.

DELETE — показательный пример. Первый вызов удаляет ресурс, второй вернёт 404, потому что удалять уже нечего. Состояние после второго вызова то же, что после первого (ресурса нет), поэтому метод идемпотентен. Хотя состояние он, мягко говоря, меняет.

Почему на практике всё ломается

Если бы клиенты всегда дожидались ответа сервера, а сеть никогда не рвалась — идемпотентность осталась бы параграфом в спецификации. В реальности всё иначе.

Типичный сценарий: клиент отправил POST /payments, сервер обработал запрос и списал деньги, но ответ 201 Created потерялся где-то по дороге. Клиент честно ждёт таймаут и, не получив ничего, повторяет запрос. Сервер, который «не помнит» первый вызов, списывает деньги ещё раз. Второй платёж — дубль, о котором клиент узнает только из выписки.

Это прямое следствие того, что REST — stateless. Сервер принципиально не хранит историю запросов, каждый вызов для него нов. Отсюда и растёт потребность в механизме, который добавляет «память» туда, где она критична, — на платежах, создании заказов, начислении бонусов.

Ключ идемпотентности — главный инструмент

Решение — ключ идемпотентности (Idempotency-Key). Клиент генерирует уникальный идентификатор операции и передаёт его в заголовке. Сервер запоминает пару «ключ → результат» и при повторном запросе с тем же ключом не выполняет операцию заново, а возвращает сохранённый результат.

100%
graph TD
  A["Клиент отправляет POST /payments с заголовком Idempotency-Key: pay_123"] --> B["API-сервер принимает запрос"]
  B --> C{"В хранилище уже есть результат для ключа pay_123?"}
  C -->|"Нет"| D["Выполнить операцию: списать деньги"]
  D --> E["Сохранить ключ и результат в хранилище идемпотентности"]
  E --> F["Вернуть 201 Created (платёж id=1001)"]
  C -->|"Да"| G["Прочитать сохранённый результат"]
  G --> H["Вернуть 200 OK (тот же id=1001)"]
  F --> I["Клиент получил ответ"]
  H --> I
  style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style C fill:#f0a500,stroke:#c88400,color:#fff
  style D fill:#50c878,stroke:#3a9a5c,color:#fff
  style E fill:#7b68ee,stroke:#5a4db2,color:#fff
  style F fill:#50c878,stroke:#3a9a5c,color:#fff
  style G fill:#e0e0e0,stroke:#999,color:#333
  style H fill:#e0e0e0,stroke:#999,color:#333
  style I fill:#4a90d9,stroke:#2c5f8a,color:#fff

Диаграмма показывает два пути обработки. Сервер в первую очередь смотрит, есть ли в хранилище результат для пришедшего ключа. Нет — выполняет операцию, сохраняет результат и возвращает 201 Created. Есть — не трогает бизнес-логику, а просто отдаёт сохранённый ответ со статусом 200 OK. Ключевой момент: дорогая операция (списание денег) выполняется ровно один раз, сколько бы запросов ни прилетело.

Разбор на платежах: что происходит с ключом и без него

Посмотрим на тот самый сценарий с потерянным ответом, но уже с ключом идемпотентности.

100%
sequenceDiagram
  participant C as Клиент
  participant S as API-сервер
  participant DB as База данных
  Note over C,S: Ключ идемпотентности: pay_123
  C->>S: POST /payments (Idempotency-Key: pay_123)
  S->>DB: SELECT по ключу pay_123
  DB-->>S: записи нет
  S->>DB: INSERT платёж (id=1001, ключ pay_123)
  DB-->>S: сохранено
  S-->>C: 201 Created (id=1001)
  Note over C,S: Ответ потерялся в сети, клиент повторяет запрос
  C->>S: POST /payments (тот же ключ pay_123)
  S->>DB: SELECT по ключу pay_123
  DB-->>S: найдено id=1001
  S-->>C: 200 OK (id=1001), без дубля

На диаграмме два запроса с одним и тем же ключом pay_123. Первый проходит полный путь: сервер проверяет хранилище, ничего не находит, создаёт платёж id=1001 и возвращает 201. Ответ теряется, клиент повторяет запрос с тем же ключом. На этот раз сервер находит запись в хранилище и просто возвращает 200 OK с тем же id=1001. Деньги списались один раз, клиент получил корректный ответ.

Без ключа второй запрос был бы воспринят как новый платёж — и на счету клиента появилась бы вторая операция. Дубль, возврат, недовольный клиент.

Где хранить ключ и сколько

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

Что важно держать в голове:

  • Уникальность ключа. Индекс должен быть уникальным, иначе параллельные запросы с одним ключом пройдут оба, и защита развалится. Гонка двух одинаковых POST — вполне реальная ситуация, когда клиент шлёт ретраи агрессивно.
  • Срок жизни (TTL). Хранить ключи вечно не нужно. Обычно хватает 24–48 часов — столько живут самые долгие ретраи. Дальше ключ можно чистить, иначе таблица разрастётся.
  • Привязка к пользователю. Один и тот же ключ у разных пользователей не должен конфликтовать. Либо включайте user_id в состав индекса, либо заставьте клиента генерировать действительно глобально уникальный ключ (например, UUID).
  • Хранить не только id, но и сам ответ. Если операция вернула тело, сохраните его целиком — при повторном запросе клиент должен получить ровно то же, что и в первый раз.

Отдельная тема — что отвечать, когда клиент присылает тот же ключ, но с другим телом запроса. Это конфликт: ключ говорит «операция уже была», а тело говорит «но другая». Хорошая практика — вернуть 409 Conflict или 422, чтобы клиент понял: с ключом что-то не так, переиспользовать его с другим содержимым нельзя.

Паттерны защиты от дублей без ключа

Ключ идемпотентности — самый честный инструмент, но не единственный. Иногда дубль можно отсечь и другими способами.

  • Уникальные ограничения в БД. Если у заказа есть естественно уникальный идентификатор (номер договора, внешний id из CRM), уникальный индекс не даст вставить вторую строку. Сервер ловит ошибку и возвращает уже существующий ресурс.
  • Идемпотентность через PUT вместо POST. PUT с полным адресом ресурса идемпотентен по своей природе. Если клиент может сам придумать идентификатор ресурса, лучше PUT /orders/{id}, чем слепой POST /orders.
  • Дедупликация на уровне брокера сообщений. В асинхронных системах с Kafka повторные доставки отсеивают по id сообщения — по сути тот же ключ, но на уровне инфраструктуры.
  • Оптимистичная блокировка. Через версии ресурса или ETag — защита скорее от конфликтующих правок, чем от дублей, но в связке с остальным работает.

Главное — не надеяться на авось. Одна потерянная в сети секунда на платёжном эндпоинте без защиты от дублей рано или поздно превратится в двойное списание. И это будет самый неприятный баг из всех, потому что он молчит: снаружи всё выглядит как обычный запрос.

Заключение

Идемпотентность — это не прихоть и не академическое требование из спецификации. Это страховка от дублей в мире, где сеть падает, ответы теряются, а клиенты жмут кнопки повторно. Для GET, PUT и DELETE она зашита в сами методы. А вот POST — точку, где чаще всего крутятся деньги, — приходится защищать вручную.

Ключ идемпотентности решает задачу элегантно: один ключ — одна операция, сколько бы запросов ни пришло. Добавьте к этому уникальный индекс в базе и разумный TTL, и вы забудете о двойных списаниях как о классе проблем.

PS. Если ваш платёжный эндпоинт до сих пор принимает голый POST без ключа — это не «потом перепишем». Это мина, которая уже тикает.