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

Идемпотентность — это свойство операции давать один и тот же результат независимо от того, сколько раз она была вызвана. Один раз, два, десять — итог одинаковый. Понятие пришло из математики, но в веб-разработке стало базовым требованием к любому серьёзному API.

Почему это вообще проблема

Сеть ненадёжна. Таймауты, обрывы соединения, медленные мобильные сети — всё это реальность. Клиент отправил запрос, ответа не получил и не знает: сервер его обработал или нет? Если не обработал — нужно повторить. Если обработал — повторный запрос может привести к дублю.

Это особенно критично для:

  • Платежей — двойное списание денег это уже не баг, это инцидент
  • Создания заказов — пользователь видит один заказ, в базе их два
  • Отправки уведомлений — человек получает одно письмо пять раз
  • Начисления бонусов — вместо 100 баллов клиент получает 500

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

Какие методы HTTP идемпотентны по умолчанию

Спецификация HTTP уже описывает, какие методы должны быть идемпотентными:

  • GET — читает данные, никак их не меняет. Хоть сто раз вызови — ответ тот же
  • HEAD — то же самое, только без тела
  • PUT — заменяет ресурс целиком. Повторный PUT с теми же данными даёт тот же результат
  • DELETE — удаляет ресурс. Второй DELETE вернёт 404, но состояние системы не изменится — ресурса всё равно нет
  • OPTIONS, TRACE — безопасные и идемпотентные

А вот POST и PATCH идемпотентными не являются. POST создаёт новый ресурс при каждом вызове. PATCH меняет часть ресурса, и повторный вызов может дать другой результат — например, если он инкрементирует счётчик.

Но PUT на практике используют реже, чем хотелось бы. Большинство реальных API строится вокруг POST-запросов, и именно их нужно делать идемпотентными вручную.

Idempotency Key — главный инструмент

Самый распространённый подход — идемпотентный ключ (idempotency key). Клиент генерирует уникальный идентификатор запроса и передаёт его в заголовке. Сервер запоминает, что запрос с таким ключом уже обработал, и при повторном вызове просто возвращает сохранённый результат.

Пример заголовка:

POST /api/payments
Idempotency-Key: 7f3d9b2a-1c4e-4f8a-b6d0-2e5f9c3a1b7d
Content-Type: application/json

{
  "amount": 1500,
  "currency": "RUB",
  "card_id": "card_abc123"
}

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

Второй запрос с тем же ключом — сервер находит ключ в базе, возвращает сохранённый ответ. Новый платёж не создаётся.

Так работают Stripe, Braintree, большинство платёжных провайдеров. Stripe прямо указывает в документации: генерируй ключ на стороне клиента, используй UUID или другой случайный идентификатор, храни его вместе с запросом.

Что должен делать сервер

  1. Проверить наличие ключа в заголовке
  2. Поискать этот ключ в хранилище (Redis, база данных)
  3. Если нашёл — вернуть сохранённый результат с тем же HTTP-статусом
  4. Если не нашёл — выполнить операцию, сохранить ключ и результат, вернуть ответ

Простая реализация на псевдокоде:

def handle_payment(request):
    idempotency_key = request.headers.get('Idempotency-Key')
    
    if idempotency_key:
        cached = redis.get(f'idem:{idempotency_key}')
        if cached:
            return Response(cached['body'], status=cached['status'])
    
    result = process_payment(request.body)
    
    if idempotency_key:
        redis.setex(
            f'idem:{idempotency_key}',
            86400,  # TTL: 24 часа
            {'body': result, 'status': 200}
        )
    
    return Response(result, status=200)

Сколько хранить ключи

Вечно — не нужно. Разумный TTL зависит от контекста:

  • Платёжные операции — 24-72 часа. Этого хватает для любых сетевых проблем
  • Создание заказов — 24 часа
  • Отправка уведомлений — несколько часов

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

Для хранения ключей лучше всего подходит Redis: быстрая запись/чтение, встроенный TTL, атомарные операции. Если Redis нет в стеке — подойдёт отдельная таблица в БД с индексом по ключу и полем expired_at.

Гонки и конкурентные запросы

Есть тонкий момент: что если два одинаковых запроса пришли одновременно, до того как первый успел записать ключ в хранилище?

Оба пройдут проверку, оба начнут обработку — и получится дубль. Это классическая race condition.

Решения:

Атомарная блокировка через Redis. Используй SET NX (set if not exists) с коротким TTL — это атомарная операция, которая гарантирует, что только один процесс «займёт» ключ:

SET idem:7f3d9b2a NX EX 30

Если команда вернула OK — этот процесс обрабатывает запрос. Если вернула nil — другой процесс уже работает, нужно подождать и повторить проверку.

Уникальный индекс в БД. Добавь уникальный индекс на колонку idempotency_key. Второй INSERT упадёт с ошибкой уникальности — поймай её и верни сохранённый результат.

Транзакции. Если используешь PostgreSQL, можно сделать всё в одной транзакции с INSERT ... ON CONFLICT DO NOTHING или ON CONFLICT DO UPDATE.

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

Кроме idempotency key на уровне API, полезно думать об идемпотентности на уровне самих данных.

Natural keys вместо суррогатных

Если операция имеет естественный идентификатор — используй его. Например, для платежа это может быть связка (user_id, order_id, amount). Уникальный индекс по этим полям защитит от дублей даже если ключ не передан.

UPSERT вместо INSERT

Вместо слепого INSERT используй UPSERT (INSERT ... ON CONFLICT ... DO UPDATE). Это делает операцию идемпотентной на уровне SQL:

INSERT INTO payments (order_id, amount, status)
VALUES (123, 1500, 'pending')
ON CONFLICT (order_id) 
DO UPDATE SET status = EXCLUDED.status
WHERE payments.status = 'pending';

Условные обновления

Вместо UPDATE payments SET status = 'paid' WHERE id = 123 пиши UPDATE payments SET status = 'paid' WHERE id = 123 AND status = 'pending'. Второй вызов ничего не изменит — статус уже не 'pending'.

Что возвращать при повторном запросе

Вопрос не такой очевидный, как кажется. Есть два подхода:

Тот же ответ с тем же статусом. Stripe делает именно так. Повторный запрос получает 200 OK с теми же данными, что и первый. Клиент не знает, был ли запрос выполнен впервые или из кэша — и не должен знать.

Специальный заголовок. Некоторые API добавляют заголовок X-Idempotent-Replayed: true, чтобы клиент понимал, что получил кэшированный ответ. Это полезно для дебага, но не меняет логику на стороне клиента.

Чего не стоит делать — возвращать 4xx с ошибкой «запрос уже выполнен». Клиент не сможет понять, успешно ли прошла операция в первый раз.

Идемпотентность в очередях сообщений

Если API работает через очереди — Kafka, RabbitMQ, SQS — идемпотентность нужна и там. Сообщение может быть доставлено несколько раз (at-least-once delivery), и обработчик должен это выдерживать.

Подход тот же: уникальный идентификатор сообщения, хранилище обработанных ID, проверка перед обработкой. Kafka с версии 0.11 поддерживает идемпотентную доставку на уровне продюсера — это убирает дубли на этапе записи, но не на этапе обработки консьюмером.

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

Простой чеклист:

  1. Отправь запрос дважды с одним ключом — убедись, что результат одинаковый и операция выполнена один раз
  2. Отправь запрос без ключа — убедись, что это либо запрещено (400 Bad Request), либо работает как обычно
  3. Дождись истечения TTL и отправь тот же ключ снова — операция должна выполниться заново
  4. Сымитируй конкурентные запросы — запусти два одновременных запроса с одним ключом и проверь, что дубля нет
  5. Проверь, что разные ключи создают разные операции

Для нагрузочного тестирования конкурентных сценариев подойдут k6 или Locust — они позволяют запускать параллельные запросы с контролем параметров.

Ошибки, которые убивают идемпотентность

Генерация ключа на сервере. Ключ должен генерировать клиент до отправки запроса. Если ключ генерирует сервер при получении запроса — это бессмысленно.

Короткий или предсказуемый ключ. UUID v4 — хороший выбор. Timestamp или порядковый номер — плохой: два разных пользователя могут случайно сгенерировать одинаковый ключ.

Игнорирование ключа при ошибках. Если запрос завершился ошибкой (500, таймаут), нужно ли сохранять ключ? Зависит от природы ошибки. Если операция не начиналась — не сохраняй. Если начиналась и неизвестен результат — сохраняй с пометкой «неизвестный статус» и разбирайся отдельно.

Разные тела запроса с одним ключом. Что делать, если клиент прислал запрос с ключом X и суммой 1000, а потом — тот же ключ X и сумму 2000? Правильный ответ — вернуть 422 Unprocessable Entity с пояснением. Ключ привязан к конкретному запросу, изменять тело нельзя.

Немного про проектирование

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

Когда в REEXY проектируют API для клиентских проектов — будь то интернет-магазин от 10 000 ₽ или корпоративный портал от 15 000 ₽ — идемпотентность для критичных операций закладывается с самого начала. Это дешевле, чем разбирать инциденты с дублями потом.

Правило простое: любая операция, которая меняет деньги, создаёт обязательство или отправляет уведомление — должна быть идемпотентной. Всё остальное — по ситуации.

Краткая выжимка

  • Идемпотентность — это «одинаковый результат при повторных вызовах»
  • GET, PUT, DELETE идемпотентны по спецификации. POST — нет
  • Idempotency Key в заголовке + Redis с TTL — стандартное решение
  • Защищайся от race condition через атомарные операции
  • Ключ генерирует клиент, UUID v4, TTL 24-72 часа
  • Разные тела с одним ключом — ошибка 422
  • Тестируй конкурентные сценарии, а не только последовательные
  • Проектируй с учётом идемпотентности с первого дня, не добавляй потом