Что происходит, когда API не идемпотентен
Пользователь нажимает кнопку «Оплатить». Интернет моргает, ответа нет. Он нажимает снова. И ещё раз. Итог — тройное списание с карты.
Это классическая проблема неидемпотентного API. И она стоит реальных денег — и разработчику, и пользователю.
Идемпотентность — свойство операции, при котором многократное её выполнение даёт тот же результат, что и однократное. Хоть 10 раз отправь одинаковый запрос — состояние системы не изменится.
Почему это важно? Потому что сети ненадёжны. Таймауты, обрывы, retry-логика на клиенте, балансировщики, которые дублируют запросы — это реальность. Без идемпотентности твой API превращается в бомбу замедленного действия.
HTTP-методы: кто идемпотентен по умолчанию
В HTTP идея идемпотентности встроена на уровне спецификации.
Идемпотентные методы:
- GET — читает данные, не меняет состояние. 100 одинаковых запросов — 100 одинаковых ответов.
- HEAD — то же, что GET, но без тела.
- PUT — заменяет ресурс целиком.
PUT /users/42 с одними и теми же данными всегда оставляет пользователя в одном состоянии.
- DELETE — удаляет ресурс. Первый запрос удаляет, последующие возвращают 404, но состояние системы не меняется — ресурса нет и нет.
- OPTIONS — только метаданные.
Неидемпотентные методы:
- POST — создаёт новую сущность. Два одинаковых
POST /orders создадут два заказа.
- PATCH — частично обновляет ресурс. Зависит от реализации:
PATCH /balance с {"add": 100} при двойном вызове добавит 200 вместо 100.
Но HTTP-спецификация только говорит, что PUT и DELETE должны быть идемпотентными. Это не магия — это ответственность разработчика. Если твой DELETE при повторном вызове бросает 500 вместо 404 — это сломанный контракт.
Проблемы с POST и PATCH
POST — самый частый источник боли. Именно с ним происходят двойные платежи, дублированные заказы, повторные уведомления.
Типичный сценарий: клиент отправляет POST /payments, сеть падает до того, как пришёл ответ. Клиент не знает — запрос дошёл или нет? Он повторяет. Если сервер не защищён — деньги списались дважды.
С PATCH похожая история, но хитрее. Есть два типа операций:
- Абсолютные —
{"name": "Иван"}. Идемпотентны сами по себе: хоть 10 раз примени — имя будет «Иван».
- Относительные —
{"increment_views": 1}. Неидемпотентны: каждый вызов увеличивает счётчик.
Второй случай — ловушка. Легко не заметить и сломать логику прямо в продакшне.
Идемпотентные ключи — главный инструмент
Решение для POST и сложных PATCH — идемпотентный ключ (Idempotency-Key). Это уникальный идентификатор запроса, который клиент генерирует сам и передаёт в заголовке.
POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Content-Type: application/json
{
"amount": 1500,
"currency": "RUB",
"recipient": "user_42"
}
Сервер при получении такого запроса:
- Проверяет, есть ли в хранилище этот ключ.
- Если нет — выполняет операцию, сохраняет ключ и ответ.
- Если есть — возвращает сохранённый ответ без повторного выполнения.
Клиент может смело ретраить запрос с тем же ключом — операция выполнится ровно один раз. Именно этот подход используют Stripe, Adyen и большинство платёжных систем. Stripe хранит ключи 24 часа — этого достаточно для любых сетевых сценариев.
Как хранить идемпотентные ключи
Технически это несложно. Нужна таблица или Redis-хэш с ключом, статусом и кешированным ответом.
Схема в PostgreSQL:
CREATE TABLE idempotency_keys (
key UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
request_path TEXT NOT NULL,
payload_hash TEXT NOT NULL,
response_status INT,
response_body JSONB,
locked_at TIMESTAMPTZ
);
Вариант в Redis:
SET idempotency:a8098c1a '{ "status": 201, "body": {...} }' EX 86400
Несколько важных деталей:
Время жизни ключа. 24–48 часов — стандарт. Хранить вечно нет смысла: это раздует хранилище, а ретрай через неделю — уже не ретрай, а новая операция.
Привязка к пользователю. Ключ должен быть связан с конкретным пользователем. UUID v4 с user_id в пространстве имён — достаточно надёжно.
Атомарность при параллельных запросах. Если два одинаковых запроса пришли одновременно — нужна атомарная операция: INSERT ... ON CONFLICT DO NOTHING в PostgreSQL или SET NX в Redis. Первый запрос получает блокировку, второй ждёт или получает ответ из кеша.
Атомарность — где чаще всего ломается
Частая ошибка: проверяешь ключ → выполняешь операцию → сохраняешь ключ. Между шагом 2 и 3 сервер падает. Ключ не сохранился, операция выполнилась. Следующий ретрай — двойное выполнение.
Правильный порядок: сначала занять слот для ключа (атомарно), потом выполнить операцию, потом записать результат.
def handle_payment(idempotency_key: str, payload: dict):
result = redis.set(
f"idempotency:{idempotency_key}",
"processing",
nx=True, # Only if Not eXists
ex=86400
)
if result is None:
cached = redis.get(f"idempotency:{idempotency_key}")
if cached == "processing":
return Response(status=202, body={"status": "processing"})
return Response(status=200, body=json.loads(cached))
payment_result = process_payment(payload)
redis.set(
f"idempotency:{idempotency_key}",
json.dumps(payment_result),
ex=86400
)
return Response(status=200, body=payment_result)
Что возвращать при повторном запросе
Возвращай тот же ответ, что вернулся при первом успешном выполнении. Если первый запрос вернул 201 Created — возвращай 201. Клиент получает идентичный ответ и не знает, был ли это ретрай или оригинал. Это и есть идемпотентность с точки зрения клиента.
Если первый запрос провалился с 500 — мнения расходятся. Stripe в этом случае повторно выполняет операцию. Логика: 500 означает, что сервер не уверен в результате, лучше попробовать снова. Для платёжных операций это разумно.
DELETE: про статусы 404 и 204
Pлохая реализация DELETE:
DELETE /users/42 → 200 OK
DELETE /users/42 → 500 Internal Server Error
Хорошая:
DELETE /users/42 → 204 No Content
DELETE /users/42 → 404 Not Found
404 при повторном DELETE — это нормально и не нарушает идемпотентность. Состояние системы одинаково: пользователя нет. Ошибка 500 — это уже другая история. Некоторые команды возвращают 204 при любом DELETE, включая повторный — тоже валидный подход. Главное — зафиксировать поведение в документации.
Что делать, если пришёл другой payload с тем же ключом
Клиент поменял сумму и отправил повторно с тем же Idempotency-Key? Правильный ответ — 422 Unprocessable Entity с объяснением. Один ключ — одна операция.
Для этого при первом запросе сохраняй хэш от payload. При повторном — сравнивай:
payload_hash = hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()
stored = redis.hgetall(f"idempotency:{key}")
if stored and stored["payload_hash"] != payload_hash:
return Response(
status=422,
body={"error": "Idempotency key reuse with different payload"}
)
Идемпотентность в микросервисах
В монолите это задача одного сервиса. В микросервисах — сложнее.
Представь: Payment Service получил запрос, создал запись, отправил событие в Notification Service. Ретрай пришёл — Payment Service вернул кешированный ответ, но событие улетело снова. Пользователь получает два письма.
Решения:
-
Outbox Pattern. Сохраняй события в той же транзакции, что и основные данные. Отдельный воркер отправляет их из outbox. Идемпотентный ключ проверяется до записи в outbox.
-
Идемпотентные потребители. Каждый сервис в цепочке сам обрабатывает дублирование. Notification Service проверяет, не отправлял ли он уже уведомление с данным payment_id.
-
Saga с компенсирующими транзакциями. Для сложных сценариев с откатами.
Комбинация Outbox + идемпотентные потребители закрывает большинство кейсов без лишней сложности.
Как тестировать идемпотентность
Идемпотентность легко забыть протестировать — она не ломает happy path. Проверяй специально:
- Отправь запрос с ключом → 201 Created.
- Отправь тот же запрос с тем же ключом → должен прийти 201 с тем же телом.
- Проверь в БД: запись одна, не две.
- Отправь тот же ключ с другим payload → должен прийти 422.
- Дай ключу истечь, отправь снова → должна создаться новая запись.
- Симулируй параллельные запросы с одним ключом → только одна запись в БД.
Пункт 6 особенно важен — race condition в идемпотентности находят не сразу, а в продакшне в 3 ночи.
Когда идемпотентность не нужна
Не каждую операцию нужно делать идемпотентной. Запросы на чтение идемпотентны по природе. Логи и метрики — намеренно не идемпотентны, дублирование там некритично. Аналитические события — часто тоже.
Сосредоточься на операциях с деньгами, созданием ресурсов и внешними интеграциями. Это места, где двойное выполнение стоит реальных денег или пользовательского доверия.
Практические правила
- Используй UUID v4 для ключей — простой, достаточно уникальный, без зависимостей.
- Храни ключи в Redis с TTL — быстро, просто, не засоряет основную БД.
- Для критичных операций дублируй в PostgreSQL — Redis без persistence может потерять данные при рестарте.
- Документируй поведение в OpenAPI: какие эндпоинты принимают Idempotency-Key, как долго хранится ключ, что происходит при конфликте payload.
- Логируй ретраи — это ценная аналитика о проблемах с сетью у клиентов.
В REEXY при проектировании API для клиентских проектов идемпотентность закладывается сразу для всех операций с оплатой и созданием заказов. Дешевле сделать правильно на старте, чем разгребать дублирование в продакшне и объяснять клиентам двойные списания.