Идемпотентность и версионирование API

Вопрос про идемпотентность — водораздел между junior и middle: он проверяет, думаете ли вы о том, что сеть ненадёжна.

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

Вопрос 1. Какие HTTP-методы идемпотентны и почему это важно?

Что проверяют. Понимаете ли вы, что «повторить запрос» — не исключительная ситуация, а норма: таймаут, обрыв связи, автоматический ретрай балансировщика, двойной клик пользователя.

МетодБезопасныйИдемпотентныйКомментарий
GETдадатолько читает
HEADдадакак GET, но без тела
PUTнетдазамена ресурса целиком: результат один и тот же
DELETEнетдавторой раз ресурса уже нет — состояние то же
POSTнетнеткаждый вызов создаёт новую сущность
PATCHнетзависит{"status":"paid"} — да; {"balance":"+100"} — нет

«Безопасный» (safe) и «идемпотентный» — разные вещи: безопасный вообще не меняет состояние, идемпотентный может менять, но повтор ничего не добавляет. Эту пару часто просят различить.

Вопрос 2. Клиент отправил платёж, получил таймаут и повторил запрос. Как не списать деньги дважды?

Что проверяют. Именно этот кейс — самый частый практический вопрос по интеграциям. Ответ «поставим кнопку неактивной» не засчитывается: проблема на сервере, а не в интерфейсе.

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

POST /payments HTTP/1.1
Host: api.shop.ru
Idempotency-Key: 7f1c0a83-2d1e-4d05-9a55-9a3f6e2b1c44
Content-Type: application/json

{ "orderId": 1024, "amount": 3650.00, "currency": "RUB" }

--- первый запрос ---
HTTP/1.1 201 Created
Location: /payments/98211
{ "id": 98211, "status": "captured" }

--- повтор с тем же ключом (после таймаута) ---
HTTP/1.1 200 OK
{ "id": 98211, "status": "captured" }      ← та же оплата, не новая

--- тот же ключ, но ДРУГОЕ тело ---
HTTP/1.1 422 Unprocessable Entity
{ "code": "IDEMPOTENCY_KEY_REUSE",
  "detail": "Ключ уже использован с другими параметрами" }

В требованиях это описывается явно, иначе разработчик реализует как придётся:

  • Кто генерирует ключ (клиент), формат (UUID v4), обязателен ли он.
  • Срок хранения ключа — например, 24 часа; после истечения тот же ключ считается новым запросом.
  • Поведение при повторе с другим телом — ошибка, а не тихая перезапись.
  • Поведение при повторе, пока первая операция ещё выполняется — 409 или ожидание.
  • Область уникальности ключа: глобально или в рамках клиента.

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

Вопрос 3. Как вы версионируете API?

Что проверяют. Отличаете ли вы ломающее изменение (breaking change) от совместимого и понимаете ли, что клиентов нельзя заставить обновиться одновременно.

Сначала — критерий. Ломающие изменения: удалить или переименовать поле, сделать необязательное поле обязательным, сузить формат или диапазон значений, поменять тип, добавить новое значение в enum, который клиент разбирает строго, изменить смысл поля при том же имени. Совместимые: добавить необязательное поле в ответ, добавить необязательный параметр запроса, добавить новый эндпоинт.

СпособПлюсыМинусы
В пути: /v1/ordersочевидно, легко маршрутизировать и логироватьформально ломает идею стабильного URI ресурса
В заголовке: Accept: application/vnd.shop.v2+jsonURL ресурса не меняетсятруднее тестировать и отлаживать вручную
Параметром: ?version=2простолегко потерять при кэшировании и проксировании

На практике побеждает версия в пути. Но главный ответ, который хотят услышать, звучит так: лучшая версия — та, которую не пришлось выпускать. Сначала стараются обойтись расширением: добавлять поля, а не менять; не удалять сразу, а помечать устаревшим. И обязательно — политика жизненного цикла: сколько версий поддерживаем одновременно (обычно две), за сколько предупреждаем об отключении (например, 6 месяцев), как уведомляем потребителей.

# фрагмент политики версионирования в спецификации
versioning:
  scheme: "path"          # /v1/, /v2/
  supported: ["v1", "v2"]
  deprecation:
    v1:
      announced: "2026-01-15"
      sunset: "2026-07-15"        # заголовок Sunset в ответах v1
      migration_guide: "/docs/migrate-v1-v2"
breaking_changes_policy:
  - "удаление или переименование поля"
  - "новое обязательное поле в запросе"
  - "новое значение enum"
non_breaking:
  - "новое необязательное поле в ответе"
  - "новый эндпоинт"

Типичные ошибки кандидатов

  • Считают POST идемпотентным «если на сервере есть проверка» — путают свойство метода и реализацию защиты.
  • Решают проблему двойного списания на фронтенде блокировкой кнопки. Ретрай сделает балансировщик, и кнопка не поможет.
  • Не описывают срок жизни ключа идемпотентности и поведение при повторе с другим телом.
  • Называют добавление нового значения в enum совместимым изменением — для строгого клиента это поломка.
  • Заводят новую версию API на каждое изменение и получают зоопарк из v7, который никто не может выключить.
  • Выпускают v2 без плана отключения v1 — старая версия живёт вечно.

Как ответить кратко

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

Проверьте себя
1. Какой набор методов идемпотентен?
AGET, POST, PUT
BGET, PUT, DELETE
CPOST, PATCH, DELETE
DВсе методы HTTP идемпотентны
2. Клиент отправил платёж, получил таймаут и повторил запрос. Как надёжнее всего избежать двойного списания?
AБлокировать кнопку в интерфейсе после первого клика
BПередавать ключ идемпотентности, по которому сервер вернёт результат первой операции
CОтключить автоматические повторы на балансировщике
DПроверять, не было ли платежа за последние 5 секунд
3. Какое изменение API является ломающим (breaking change)?
AДобавление нового необязательного поля в ответ
BДобавление нового эндпоинта
CДобавление нового значения в enum, который клиенты разбирают строго
DДобавление необязательного параметра запроса