Безопасность и идемпотентность методов
Урок про два свойства методов — безопасность и идемпотентность — и про то, почему они определяют, можно ли безопасно повторить запрос.
Безопасный (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защищают ключом идемпотентности.