Безопасность и идемпотентность методов

Урок про два свойства методов — безопасность и идемпотентность — и про то, почему они определяют, можно ли безопасно повторить запрос.

Безопасный (safe) метод не меняет состояние сервера. Идемпотентный метод при повторном выполнении с теми же данными даёт тот же результат, что и однократное.

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

Безопасные методы: GET и HEAD

Безопасный метод по контракту обещает: «я только читаю». GET и HEAD (а также OPTIONS) — безопасны. Это значит, что сколько бы раз вы их ни вызвали — состояние ресурса не изменится. Именно на этом обещании держится вся инфраструктура: прокси кэшируют ответы GET, браузеры предзагружают ссылки, роботы свободно их обходят.

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

Идемпотентность: GET, PUT, DELETE

Идемпотентность — про эффект на сервере, а не про идентичность ответа. Метод идемпотентен, если повтор не добавляет новых изменений сверх первого вызова.

  • GET идемпотентен тривиально: чтение ничего не меняет, повторяй сколько угодно.
  • PUT идемпотентен: «сделай ресурс таким». Применил один раз — ресурс стал таким. Применил ещё пять раз с тем же телом — он остаётся ровно таким же. Итоговое состояние одинаково после 1 и после 6 вызовов.
  • DELETE идемпотентен по состоянию: после первого вызова ресурс удалён, после повторных — он по-прежнему удалён. То, что повторный DELETE вернёт 404 вместо 204, идемпотентности не нарушает: важно конечное состояние сервера, а не одинаковость кода ответа.

Почему POST не идемпотентен

POST «создай новый подчинённый ресурс» — повтор создаёт ещё один ресурс. Два одинаковых POST /orders с одинаковым телом породят два заказа с разными id. Каждый вызов меняет состояние по-новому, поэтому POST и не безопасен, и не идемпотентен.

# Два одинаковых POST -> ДВА разных заказа
curl -X POST https://api.shop.test/orders -d '{"item":"mouse"}'  # -> 201, /orders/50
curl -X POST https://api.shop.test/orders -d '{"item":"mouse"}'  # -> 201, /orders/51

Сравните с PUT, где клиент сам задаёт URL:

# Два одинаковых PUT -> ОДИН и тот же заказ в одном состоянии
curl -X PUT https://api.shop.test/orders/50 -d '{"item":"mouse","qty":1}'  # -> 200
curl -X PUT https://api.shop.test/orders/50 -d '{"item":"mouse","qty":1}'  # -> 200, без изменений

Сводная таблица свойств

МетодБезопасныйИдемпотентныйКэшируемый
GETдадада
HEADдадада
OPTIONSдаданет
PUTнетданет
DELETEнетданет
POSTнетнетредко
PATCHнетне гарантируетсянет

Обратите внимание на PATCH: в общем случае он не идемпотентен. Патч {"qty": +1} (увеличить на единицу) при повторе увеличит дважды. А вот патч {"status": "shipped"} (установить значение) идемпотентен. Идемпотентность PATCH зависит от смысла операции, поэтому стандарт её не гарантирует.

Как работает под капотом

Зачем всё это на практике? Ради безопасных ретраев. Представьте: клиент отправил запрос, сервер его обработал, но ответ потерялся по дороге. Клиент не знает, дошло ли. Что делать?

Клиент            Сеть             Сервер
  |  PUT /orders/50  ----------->  |  применил, заказ обновлён
  |  X (ответ потерян) <-----------  |  200 OK ушёл, но не дошёл
  |  ??? повторить ???              |

Если метод идемпотентен (PUT), повтор безопасен: либо сервер не получил первый запрос и применит сейчас, либо получил — и повтор ничего не испортит, состояние то же. Поэтому балансировщики, клиентские библиотеки и очереди автоматически ретраят GET, PUT, DELETE при таймаутах и сетевых сбоях.

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

curl -X POST https://api.shop.test/payments \
  -H "Idempotency-Key: 7c3a9f12-payment-001" \
  -d '{"order": 50, "amount": 1990}'

Сервер запоминает ключ и при повторе с тем же ключом возвращает прежний результат, не создавая второй платёж. Так платёжные API (Stripe и др.) делают небезопасный по природе POST безопасным для ретраев.

Частые ошибки

  • Изменяющий GET: GET /orders/42/cancel отменяет заказ. Робот или префетч браузера вызовет это без ведома пользователя.
  • Путать идемпотентность с одинаковостью ответа: повторный DELETE даёт 404 вместо 204 — это нормально и идемпотентности не ломает.
  • Считать PATCH всегда идемпотентным: инкрементальные патчи (+1, append) при повторе ломают данные.
  • Ретраить голый POST без ключа идемпотентности — прямой путь к дублям и двойным списаниям.

Итог

  • Safe (GET, HEAD, OPTIONS) — не меняют значимого состояния, их кэшируют и предзагружают.
  • Идемпотентные (GET, PUT, DELETE) — повтор не добавляет изменений; важно конечное состояние, а не код ответа.
  • POST не идемпотентен: повтор создаёт дубль; PATCH идемпотентен не всегда.
  • Идемпотентные методы можно автоматически ретраить; небезопасные POST защищают ключом идемпотентности.
Проверьте себя
1. Клиент отправил PUT /orders/50, но ответ потерялся в сети. Можно ли безопасно повторить запрос?
AНет, повтор создаст второй заказ
BДа: PUT идемпотентен, повтор приведёт ресурс к тому же состоянию
CТолько если добавить ключ идемпотентности
DНет, повторять можно лишь GET
2. Почему POST не считается идемпотентным?
AПотому что у него всегда есть тело запроса
BПотому что он возвращает 201 вместо 200
CПотому что каждый вызов создаёт новый ресурс — два одинаковых POST дают два разных объекта
DПотому что его нельзя кэшировать
3. Повторный DELETE /orders/50 уже удалённого заказа вернул 404 вместо 204. Нарушает ли это идемпотентность?
AДа, ведь код ответа отличается от первого вызова
BНет: идемпотентность про конечное состояние сервера, а оно одинаково — ресурс удалён
CДа, нужно было вернуть 204 повторно
DЭто зависит от реализации сервера
4. Как сделать критичный POST /payments безопасным для повторов при сетевых сбоях?
AЗаменить его на GET
BДобавить заголовок Idempotency-Key, по которому сервер распознаёт и отбрасывает дубль
CВсегда возвращать 200 OK
DЗапретить ретраи на стороне клиента навсегда