Learning Platform
Глоссарий Troubleshooting
Урок 06.02 · 25 мин
Начальный
idempotencysafetyretryetagif-matchidempotency-key

Идемпотентность и 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 (он меняет ресурс).

Матрица safety и idempotency для HTTP методов (RFC 9110)
HTTP метод
Safe -- не меняет состояние сервера
Idempotent -- повтор даёт тот же результат
Можно ли retry-ить при сетевой ошибке
GETЧтение ресурса. Не меняет ничего
Да
Да
Да
HEADТолько headers без body. Идентично GET по семантике
Да
Да
Да
OPTIONSОпции -- какие методы поддерживает ресурс. Используется в CORS preflight
Да
Да
Да
PUTПолное замещение ресурса. Повторный PUT с тем же body даёт то же состояние
Нет -- меняет ресурс
Да -- повтор оставит то же состояние
Да
DELETEУдаление ресурса. Повторный DELETE на удалённый ресурс -- обычно 404, но мир не сломан
Нет
Да
Да
POSTСоздание ресурса (часто). Повтор создаёт ВТОРОЙ ресурс
Нет
Нет
Только с Idempotency-Key
PATCHЧастичное обновление. По стандарту не idempotent, на практике -- зависит от семантики
Нет
Нет (по стандарту)
Только с Idempotency-Key

Почему 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()

Что происходит при сетевой ошибке:

Race condition: сервер обработал, но клиент не получил ответ
ETL Client
API Server
Database
POST /ordersINSERT order #789OK, id=789201 Created (потерян)POST /orders (retry)INSERT order #790

Исход: клиент получил один номер заказа, у магазина в БД два, деньги списаны дважды. Виноват не код retry — виновата семантика POST. POST не идемпотентен, retry POST без защиты — баг by design.


Решение: Idempotency-Key

Stripe в 2015 году ввёл стандарт, который сейчас все копируют — header Idempotency-Key. Идея:

  1. Клиент генерирует уникальный ключ (UUID) для каждой “логической операции”.
  2. При retry клиент шлёт тот же ключ.
  3. Сервер проверяет: если ключ уже видел — возвращает закэшированный ответ предыдущего запроса, не выполняет операцию.
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 с тем же ключом вернёт тот же ответ из кэша на сервере. Один заказ, одно списание.

WARNING

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 concurrency через If-Match: безопасный update без локов
Client A
Client B
Server
GET /users/123200 ETag: v42GET /users/123200 ETag: v42PUT If-Match: v42200 OK ETag: v43PUT If-Match: v42412 Precondition Failed

Это называется “optimistic” потому, что сервер не блокирует ресурс на время чтения (как делал бы pessimistic locking). Он надеется, что конфликтов не будет, а если будут — клиент пересоздаст запрос.

TIP

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):

  1. Генерирует UUID при первом вызове.
  2. При сетевой ошибке (requests.exceptions.ConnectionError, Timeout) — retry с тем же ключом.
  3. После 3 неудач — кидает наверх.

Это базовый паттерн, который пишет каждый DE.


DE-контекст: где это критично

  1. Webhook delivery: провайдер (Stripe, GitHub) гарантирует at-least-once доставку. Каждый webhook может прийти 2+ раза. Твой обработчик должен быть idempotent — обычно через event_id из payload как dedup key.
  2. Airflow tasks: при ретраях task runs могут дублировать API calls. Если task делает POST без Idempotency-Key — есть шанс дубля при повторе.
  3. Запись в Data Warehouse: INSERT дублирует, MERGE/UPSERT — idempotent. Тот же принцип на уровне SQL.
  4. Пагинация курсором 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 без локов.

Проверка знанийKnowledge check
ОтветAnswer

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В чём разница между safe и idempotent методом по RFC 9110?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4