Идемпотентность и версионирование 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+json | URL ресурса не меняется | труднее тестировать и отлаживать вручную |
Параметром: ?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, сервер хранит пару ключ-результат сутки и на повтор возвращает сохранённый ответ, а на тот же ключ с другим телом — ошибку. Версионирую в пути, но сначала стараюсь обойтись совместимыми изменениями: добавлять необязательные поля, а не менять существующие. И всегда фиксирую политику: две поддерживаемых версии и срок отключения старой с уведомлением заранее.