Идемпотентность и safety: почему повтор PUT безопасен, а POST — нет
В прошлом уроке мы говорили о принципах REST. Один из них — uniform interface — требует использовать стандартные HTTP методы по их семантике. Семантика эта определяется двумя свойствами: safety и idempotency. Это не теоретические концепции — каждый раз когда твой ETL pipeline retry-ит запрос после Connection reset, ты опираешься на эти свойства. Если перепутаешь — повторишь POST на оплату и спишешь деньги дважды.
В этом уроке разберёмся, что такое safe и idempotent, какие методы какими свойствами обладают по RFC 9110, посмотрим на реальные баги дублирования заказов из-за retry, и научимся защищаться: через Idempotency-Key для POST и через optimistic concurrency на основе ETag для PUT.
Safety vs Idempotency: два разных свойства
Safe — метод не должен изменять состояние сервера. Многократный вызов GET оставляет сервер в том же состоянии, что и нулевой вызов. Кэши, логирование, инкремент счётчика просмотров — это side effects, не считаются.
Idempotent — многократный вызов даёт тот же результат, что один. Не “ничего не меняет” — а “повтор не меняет ничего сверх первого вызова”. Удалили запись — повторный DELETE говорит “уже удалено” (или 404), но не ломает мир.
Любой safe метод автоматически idempotent (раз ничего не меняет — то и повтор ничего не меняет). Обратное неверно: PUT idempotent, но не safe (он меняет ресурс).
Почему PUT идемпотентен, а POST — нет
Семантика по RFC 9110:
- PUT значит “помести этот ресурс по этому URL. Если ресурс уже там — замени полностью”. Клиент сам выбирает URL:
PUT /users/123создаёт или замещает пользователя 123. Повторил три раза — пользователь 123 всё ещё в том же состоянии. Idempotent. - POST значит “обработай этот payload по правилам этого ресурса”. Часто это создание новой сущности, и сервер сам выбирает URL (возвращает в
Location: /orders/789). Повторил — создалось ВТОРОЕ заказ. Не idempotent.
import requests
# PUT: клиент знает финальный URL, idempotent
requests.put("https://api.example.com/users/123",
json={"name": "Alice", "email": "[email protected]"})
# Повторил 5 раз -- пользователь 123 всё равно один, с теми же данными
# POST: сервер создаёт новый ресурс, не idempotent
r = requests.post("https://api.example.com/orders",
json={"product_id": 42, "quantity": 1})
print(r.headers["Location"]) # /orders/789
# Повторил 5 раз -- создалось 5 заказов, 5 раз списали деньги
Реальный баг: дублирование заказов при retry POST
Retries и rate limits: tenacity на практикеСценарий из жизни. ETL pipeline дёргает API создания заказа:
import requests
def create_order(product_id, quantity):
r = requests.post(
"https://api.shop.com/orders",
json={"product_id": product_id, "quantity": quantity},
timeout=30
)
r.raise_for_status()
return r.json()
Что происходит при сетевой ошибке:
Исход: клиент получил один номер заказа, у магазина в БД два, деньги списаны дважды. Виноват не код retry — виновата семантика POST. POST не идемпотентен, retry POST без защиты — баг by design.
Решение: Idempotency-Key
Stripe в 2015 году ввёл стандарт, который сейчас все копируют — header Idempotency-Key. Идея:
- Клиент генерирует уникальный ключ (UUID) для каждой “логической операции”.
- При retry клиент шлёт тот же ключ.
- Сервер проверяет: если ключ уже видел — возвращает закэшированный ответ предыдущего запроса, не выполняет операцию.
import uuid
import requests
def create_order_safe(product_id, quantity):
idempotency_key = str(uuid.uuid4()) # один раз на логическую операцию
for attempt in range(3):
try:
r = requests.post(
"https://api.shop.com/orders",
json={"product_id": product_id, "quantity": quantity},
headers={"Idempotency-Key": idempotency_key},
timeout=30
)
r.raise_for_status()
return r.json()
except requests.exceptions.RequestException:
if attempt == 2:
raise
continue
Теперь даже если первый POST успешно обработался на сервере, а ответ потерян в сети — второй POST с тем же ключом вернёт тот же ответ из кэша на сервере. Один заказ, одно списание.
Idempotency-Key должен генерироваться на стороне клиента, на одну логическую операцию (один заказ, одну транзакцию). Не на запрос, не на retry. Если каждый retry получит новый ключ — защита не работает. Сохраняй ключ в БД клиента до получения подтверждения от сервера.
Серверная сторона хранит ключи обычно 24 часа в Redis: SET idempotency:{key} {response_json} EX 86400. Если приходит запрос с известным ключом — возвращает body из Redis, не вызывает бизнес-логику.
Stripe, AWS, Square, PayPal — все используют этот паттерн. Имя header может варьироваться (Idempotency-Key, X-Idempotency-Key, Request-Id), идея та же.
Условные апдейты через ETag (optimistic concurrency)
Другая проблема: два клиента одновременно редактируют один ресурс.
# Client A читает пользователя
ra = requests.get("https://api.example.com/users/123")
user = ra.json() # {"name": "Alice", "email": "[email protected]"}
# Client B одновременно тоже читает
rb = requests.get("https://api.example.com/users/123")
# Обновляет email
requests.put("https://api.example.com/users/123",
json={"name": "Alice", "email": "[email protected]"})
# Client A не знает про изменение, шлёт свой PUT
requests.put("https://api.example.com/users/123",
json={"name": "Alice Updated", "email": "[email protected]"})
# Перезаписал чужие изменения! Lost update problem.
Решение — optimistic concurrency control через ETag и If-Match:
# Client A читает с ETag
ra = requests.get("https://api.example.com/users/123")
etag = ra.headers["ETag"] # "v42"
# Делает изменения локально, шлёт PUT с If-Match
r = requests.put(
"https://api.example.com/users/123",
json={"name": "Alice Updated"},
headers={"If-Match": etag}
)
if r.status_code == 412:
# Precondition Failed -- ресурс изменился между нашим GET и PUT
# Перечитать, перерешить конфликт, попробовать снова
print("Conflict: ресурс был изменён другим клиентом")
elif r.status_code == 200:
print("OK")
Это называется “optimistic” потому, что сервер не блокирует ресурс на время чтения (как делал бы pessimistic locking). Он надеется, что конфликтов не будет, а если будут — клиент пересоздаст запрос.
ETag — это любой uniq string, идентифицирующий версию ресурса. Часто это hash содержимого (md5(body)), version number из БД, или updated_at timestamp. Главное — он должен меняться при изменении ресурса.
Что про PATCH?
PATCH применяет частичный апдейт к ресурсу. По RFC 5789 PATCH не обязан быть idempotent — это зависит от семантики операции:
PATCH /counterс body{"increment": 1}— не idempotent, каждый PATCH добавляет 1.PATCH /users/123с body{"email": "[email protected]"}(JSON Merge Patch) — idempotent, повтор оставит то же email.
Junior рекомендация: для DE использовать только idempotent PATCH (replace-style, не increment-style). Если очень нужен неидемпотентный PATCH — защищай Idempotency-Key.
Попробуй сам
Подними mock-сервер httpbin и проверь поведение методов:
import requests
# httpbin.org -- playground для HTTP. Эндпоинт /anything возвращает то, что прислали
# GET идемпотентен -- повторяй сколько хочешь
for _ in range(3):
r = requests.get("https://httpbin.org/anything")
print(r.status_code, r.json()["method"]) # 200 GET (трижды)
# Реальный API с Idempotency-Key -- Stripe testmode
# (требует API key, но синтаксис такой)
import uuid
key = str(uuid.uuid4())
headers = {
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": key
}
# первый POST создаст charge
# r1 = requests.post("https://api.stripe.com/v1/charges", data=..., headers=headers)
# второй POST с тем же ключом -- вернёт тот же charge_id, не создаст новый
# r2 = requests.post("https://api.stripe.com/v1/charges", data=..., headers=headers)
# assert r1.json()["id"] == r2.json()["id"]
Сделай функцию safe_post(url, json, max_retries=3):
- Генерирует UUID при первом вызове.
- При сетевой ошибке (
requests.exceptions.ConnectionError,Timeout) — retry с тем же ключом. - После 3 неудач — кидает наверх.
Это базовый паттерн, который пишет каждый DE.
DE-контекст: где это критично
- Webhook delivery: провайдер (Stripe, GitHub) гарантирует at-least-once доставку. Каждый webhook может прийти 2+ раза. Твой обработчик должен быть idempotent — обычно через
event_idиз payload как dedup key. - Airflow tasks: при ретраях task runs могут дублировать API calls. Если task делает POST без Idempotency-Key — есть шанс дубля при повторе.
- Запись в Data Warehouse:
INSERTдублирует,MERGE/UPSERT— idempotent. Тот же принцип на уровне SQL. - Пагинация курсором vs offset: offset-пагинация неидемпотентна (если данные меняются между страницами — пропустишь или дублируешь записи). Cursor-based — стабильнее.
Killer takeaway
Safe = не меняет состояние сервера. Idempotent = повтор даёт тот же результат. По RFC 9110: GET/HEAD/OPTIONS — safe и idempotent (можно retry-ить смело). PUT/DELETE — idempotent, не safe (тоже можно retry). POST/PATCH — ни то ни другое (retry без защиты = баг). Защита: Idempotency-Key (UUID, генерируется клиентом, переиспользуется при retry, сервер дедуплицирует). Для конкурентных апдейтов: ETag + If-Match, ответ 412 при конфликте — optimistic concurrency без локов.