Представь: пользователь нажимает кнопку «Оплатить», интернет на секунду пропадает, приложение показывает ошибку. Он жмёт ещё раз. Деньги списались дважды. Поддержка завалена жалобами, репутация падает. Это классическая проблема неидемпотентного API.
Идемпотентность — это свойство операции, при котором повторное выполнение с теми же параметрами даёт тот же результат, что и первое. Простой пример из математики: умножение на 1 идемпотентно — сколько бы раз ты ни умножал число на 1, результат не изменится. Установить значение переменной x = 5 тоже идемпотентно: хоть десять раз выполни, x останется 5.
В контексте API это означает: если клиент отправил запрос, получил непонятный ответ (таймаут, обрыв соединения) и отправил тот же запрос повторно — на сервере ничего лишнего не произошло.
Какие HTTP-методы идемпотентны
Спецификация HTTP чётко разделяет методы по этому признаку.
Идемпотентные методы:
GET — читает данные, ничего не меняет
HEAD — то же самое, только без тела ответа
PUT — полностью заменяет ресурс; если сделать это дважды с теми же данными, ресурс останется таким же
DELETE — удаляет ресурс; повторный DELETE на уже удалённый ресурс вернёт 404, но состояние системы не изменится
OPTIONS, TRACE — вспомогательные, тоже идемпотентны
Неидемпотентные:
POST — создаёт новый ресурс или запускает действие; каждый вызов может создать новую запись
PATCH — зависит от реализации; относительные изменения (+1 к счётчику) не идемпотентны, абсолютные (установить значение = 42) — да
Но тут важно понять: идемпотентность по спецификации — это обещание, а не гарантия. Ты можешь написать PUT-эндпоинт, который каждый раз создаёт новую запись в логе. Формально это нарушение, но технически ничто не мешает. Поэтому идемпотентность — это прежде всего контракт, который ты выстраиваешь осознанно.
Где проблема реально возникает
Сеть ненадёжна. Это аксиома. Пакеты теряются, соединения рвутся, прокси отваливаются. Клиент может получить таймаут в трёх ситуациях:
- Запрос до сервера не дошёл — сервер ничего не обработал
- Запрос дошёл, сервер обработал, но ответ потерялся — клиент думает, что запрос провалился
- Сервер начал обрабатывать, но упал на середине
Во втором случае повторный запрос без идемпотентности создаст дублирующую операцию. Деньги списаны, заказ создан дважды, письмо отправлено два раза.
Особенно критично это в трёх сценариях:
- Платёжные операции
- Отправка уведомлений (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"
}
Сервер при получении запроса:
- Проверяет, есть ли уже сохранённый ответ для этого ключа
- Если есть — возвращает его без повторной обработки
- Если нет — обрабатывает запрос и сохраняет результат
Это простая схема, но дьявол в деталях.
Где хранить ключи
Ключи с результатами нужно хранить достаточно долго, чтобы перекрыть все разумные сценарии повтора. 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 от прототипа, который ломается при первом сбое сети.