Представь: пользователь нажимает кнопку «Оплатить», интернет на секунду пропадает, приложение показывает ошибку. Он жмёт ещё раз. Деньги списались дважды. Поддержка завалена жалобами, репутация падает. Это классическая проблема неидемпотентного API.

Идемпотентность — это свойство операции, при котором повторное выполнение с теми же параметрами даёт тот же результат, что и первое. Простой пример из математики: умножение на 1 идемпотентно — сколько бы раз ты ни умножал число на 1, результат не изменится. Установить значение переменной x = 5 тоже идемпотентно: хоть десять раз выполни, x останется 5.

В контексте API это означает: если клиент отправил запрос, получил непонятный ответ (таймаут, обрыв соединения) и отправил тот же запрос повторно — на сервере ничего лишнего не произошло.

Какие HTTP-методы идемпотентны

Спецификация HTTP чётко разделяет методы по этому признаку.

Идемпотентные методы:

  • GET — читает данные, ничего не меняет
  • HEAD — то же самое, только без тела ответа
  • PUT — полностью заменяет ресурс; если сделать это дважды с теми же данными, ресурс останется таким же
  • DELETE — удаляет ресурс; повторный DELETE на уже удалённый ресурс вернёт 404, но состояние системы не изменится
  • OPTIONS, TRACE — вспомогательные, тоже идемпотентны

Неидемпотентные:

  • POST — создаёт новый ресурс или запускает действие; каждый вызов может создать новую запись
  • PATCH — зависит от реализации; относительные изменения (+1 к счётчику) не идемпотентны, абсолютные (установить значение = 42) — да

Но тут важно понять: идемпотентность по спецификации — это обещание, а не гарантия. Ты можешь написать PUT-эндпоинт, который каждый раз создаёт новую запись в логе. Формально это нарушение, но технически ничто не мешает. Поэтому идемпотентность — это прежде всего контракт, который ты выстраиваешь осознанно.

Где проблема реально возникает

Сеть ненадёжна. Это аксиома. Пакеты теряются, соединения рвутся, прокси отваливаются. Клиент может получить таймаут в трёх ситуациях:

  1. Запрос до сервера не дошёл — сервер ничего не обработал
  2. Запрос дошёл, сервер обработал, но ответ потерялся — клиент думает, что запрос провалился
  3. Сервер начал обрабатывать, но упал на середине

Во втором случае повторный запрос без идемпотентности создаст дублирующую операцию. Деньги списаны, заказ создан дважды, письмо отправлено два раза.

Особенно критично это в трёх сценариях:

  • Платёжные операции
  • Отправка уведомлений (email, SMS, push)
  • Создание уникальных сущностей (пользователь, заказ, документ)

Идемпотентные ключи — главный инструмент

Как сделать POST-запрос идемпотентным? Добавить уникальный ключ операции.

Клиент генерирует Idempotency-Key — уникальный идентификатор этой конкретной операции — и передаёт его в заголовке:

POST /payments
Idempotency-Key: a3f8c2d1-7b4e-4f9a-b2c3-d4e5f6a7b8c9
Content-Type: application/json

{
  "amount": 5000,
  "currency": "RUB",
  "recipient": "acc_123"
}

Сервер при получении запроса:

  1. Проверяет, есть ли уже сохранённый ответ для этого ключа
  2. Если есть — возвращает его без повторной обработки
  3. Если нет — обрабатывает запрос и сохраняет результат

Это простая схема, но дьявол в деталях.

Где хранить ключи

Ключи с результатами нужно хранить достаточно долго, чтобы перекрыть все разумные сценарии повтора. Stripe хранит 24 часа. Для большинства API хватает от 1 до 7 дней.

Идеальный вариант — Redis с TTL:

import redis
import json

r = redis.Redis()

def handle_payment(idempotency_key, payload):
    cache_key = f"idem:{idempotency_key}"
    
    # Проверяем кэш
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Обрабатываем
    result = process_payment(payload)
    
    # Сохраняем на 24 часа
    r.setex(cache_key, 86400, json.dumps(result))
    
    return result

Если Redis нет — подойдёт таблица в базе данных с уникальным индексом по ключу.

Что делать с параллельными запросами

Представь: клиент отправил запрос, не дождался ответа и тут же отправил тот же запрос с тем же ключом. Теперь два запроса приходят на сервер почти одновременно, и ни у одного ещё нет сохранённого результата.

Без защиты оба пройдут обработку — получим дубль.

Решение — блокировка. Когда начинаешь обрабатывать запрос с ключом, ставишь distributed lock (тот же Redis SET NX). Второй запрос с тем же ключом ждёт или получает 409 Conflict с пояснением, что операция уже в процессе.

lock_key = f"lock:{idempotency_key}"

# Пробуем захватить блокировку на 30 секунд
if not r.set(lock_key, 1, nx=True, ex=30):
    return {"error": "operation_in_progress", "retry_after": 5}, 409

try:
    result = process_payment(payload)
    r.setex(cache_key, 86400, json.dumps(result))
    return result
finally:
    r.delete(lock_key)

Как генерировать идемпотентные ключи на клиенте

Клиент отвечает за генерацию ключа. Самый простой вариант — UUID v4. Он случайный, достаточно уникальный, и его сложно случайно повторить.

// JavaScript
const idempotencyKey = crypto.randomUUID();

// При повторе запроса используем тот же ключ
await fetch('/payments', {
  method: 'POST',
  headers: {
    'Idempotency-Key': idempotencyKey,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(payload)
});

Важно: ключ генерируется один раз для одной пользовательской операции и переиспользуется только при повторах из-за ошибки сети. Пользователь нажал «Оплатить» — сгенерировали ключ, сохранили локально. Получили таймаут — повторяем с тем же ключом. Успешный ответ или явная бизнес-ошибка — ключ больше не нужен.

Валидация ключа на сервере

Что делать, если клиент прислал тот же Idempotency-Key, но с другими данными? Например, первый раз amount: 5000, второй раз amount: 9999?

Это ошибка клиента. Нужно вернуть 422 Unprocessable Entity или 400 Bad Request с внятным описанием:

{
  "error": "idempotency_key_reuse",
  "message": "This idempotency key was already used with different request body"
}

Обязательно сохраняй хэш тела запроса вместе с результатом — иначе не сможешь сравнить.

PATCH и относительные изменения

С PATCH надо быть аккуратнее. Если запрос выглядит как:

{ "action": "increment", "field": "views", "value": 1 }

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

Альтернатива — переформулировать операцию в абсолютных значениях:

{ "views": 42 }

Но это требует, чтобы клиент знал текущее значение, что не всегда удобно.

Идемпотентность на уровне базы данных

Иногда можно обойтись без Redis и лишней логики — заложить идемпотентность прямо в схему данных.

Пример: при создании заказа используем уникальный составной индекс:

CREATE UNIQUE INDEX idx_orders_idempotency 
ON orders (user_id, idempotency_key);

Тогда при попытке вставить дубль база вернёт ошибку уникальности. В коде перехватываем её и возвращаем уже созданный заказ:

try:
    order = Order.create(user_id=user_id, idempotency_key=key, ...)
except UniqueViolationError:
    order = Order.get(user_id=user_id, idempotency_key=key)
    
return order

Это надёжно, атомарно и не требует отдельного кэша. Минус — не подходит для операций, которые не создают записи в БД (например, отправка email).

Как отвечать на повторные запросы

Когда возвращаешь кэшированный ответ, хорошая практика — добавить заголовок, который сообщает клиенту, что это повтор:

HTTP/1.1 200 OK
Idempotency-Replayed: true
Content-Type: application/json

{ "payment_id": "pay_abc123", "status": "completed" }

Статус-код должен совпадать с оригинальным ответом. Если первый запрос вернул 201 Created, повторный тоже должен вернуть 201, а не 200.

Пример из реальной практики

Stipe — один из лучших примеров реализации идемпотентности. Они используют заголовок Idempotency-Key, хранят результаты 24 часа, явно документируют поведение и возвращают Idempotency-Replayed: true.

Yandex Pay и многие российские платёжные провайдеры используют схожий подход, иногда называя ключ RequestId или ExternalId — суть та же.

Когда мы в REEXY проектируем API для интернет-магазинов или сервисов с платёжной интеграцией, идемпотентность платёжных эндпоинтов — это не опция, это обязательное требование. Без неё первый же сбой у платёжного провайдера превращается в инцидент с двойными списаниями.

Типичные ошибки

Слишком короткий TTL. Если хранишь ключи час, а клиент ретраит через 2 часа (например, пользователь закрыл приложение и открыл снова) — дубль пройдёт.

Нет блокировки при параллельных запросах. Без distributed lock два одновременных запроса с одним ключом могут оба пройти обработку.

Клиент генерирует новый ключ при каждом ретрае. Тогда вся защита бессмысленна. Нужно явно прописать в документации SDK: ключ сохраняется на стороне клиента и переиспользуется.

Идемпотентность только для happy path. Если первый запрос вернул 500, нужно ли кэшировать этот ответ? Зависит от ситуации. Ошибки валидации (400) — да, кэшируй. Внутренние ошибки сервера (500) — лучше не кэшировать, чтобы клиент мог повторить попытку после исправления проблемы на сервере.

Итого

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

Для большинства проектов достаточно трёх вещей:

  • UUID на клиенте, сгенерированный один раз
  • Redis с TTL на сервере для хранения результатов
  • Distributed lock для защиты от параллельных дублей

Если API работает с деньгами, документами или любыми уникальными сущностями — без идемпотентности не обходиться. Это то, что отличает продакшн-готовый API от прототипа, который ломается при первом сбое сети.