Что происходит, когда 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 похожая история, но хитрее. Есть два типа операций:

  1. Абсолютные{"name": "Иван"}. Идемпотентны сами по себе: хоть 10 раз примени — имя будет «Иван».
  2. Относительные{"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"
}

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

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

Клиент может смело ретраить запрос с тем же ключом — операция выполнится ровно один раз. Именно этот подход используют 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 вернул кешированный ответ, но событие улетело снова. Пользователь получает два письма.

Решения:

  1. Outbox Pattern. Сохраняй события в той же транзакции, что и основные данные. Отдельный воркер отправляет их из outbox. Идемпотентный ключ проверяется до записи в outbox.

  2. Идемпотентные потребители. Каждый сервис в цепочке сам обрабатывает дублирование. Notification Service проверяет, не отправлял ли он уже уведомление с данным payment_id.

  3. Saga с компенсирующими транзакциями. Для сложных сценариев с откатами.

Комбинация Outbox + идемпотентные потребители закрывает большинство кейсов без лишней сложности.

Как тестировать идемпотентность

Идемпотентность легко забыть протестировать — она не ломает happy path. Проверяй специально:

  1. Отправь запрос с ключом → 201 Created.
  2. Отправь тот же запрос с тем же ключом → должен прийти 201 с тем же телом.
  3. Проверь в БД: запись одна, не две.
  4. Отправь тот же ключ с другим payload → должен прийти 422.
  5. Дай ключу истечь, отправь снова → должна создаться новая запись.
  6. Симулируй параллельные запросы с одним ключом → только одна запись в БД.

Пункт 6 особенно важен — race condition в идемпотентности находят не сразу, а в продакшне в 3 ночи.

Когда идемпотентность не нужна

Не каждую операцию нужно делать идемпотентной. Запросы на чтение идемпотентны по природе. Логи и метрики — намеренно не идемпотентны, дублирование там некритично. Аналитические события — часто тоже.

Сосредоточься на операциях с деньгами, созданием ресурсов и внешними интеграциями. Это места, где двойное выполнение стоит реальных денег или пользовательского доверия.

Практические правила

  • Используй UUID v4 для ключей — простой, достаточно уникальный, без зависимостей.
  • Храни ключи в Redis с TTL — быстро, просто, не засоряет основную БД.
  • Для критичных операций дублируй в PostgreSQL — Redis без persistence может потерять данные при рестарте.
  • Документируй поведение в OpenAPI: какие эндпоинты принимают Idempotency-Key, как долго хранится ключ, что происходит при конфликте payload.
  • Логируй ретраи — это ценная аналитика о проблемах с сетью у клиентов.

В REEXY при проектировании API для клиентских проектов идемпотентность закладывается сразу для всех операций с оплатой и созданием заказов. Дешевле сделать правильно на старте, чем разгребать дублирование в продакшне и объяснять клиентам двойные списания.