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

Что такое идемпотентность и причём тут математика

Термин пришёл из математики. Операция идемпотентна, если её результат не меняется при повторном применении. Умножение числа на 1 — идемпотентно: 5 × 1 × 1 × 1 = 5. Прибавление единицы — нет: 5 + 1 + 1 + 1 = 8.

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

Звучит просто, но реализуется не всегда очевидно.

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

Спецификация RFC 7231 чётко разграничивает методы:

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

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

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

  • POST — создаёт новый ресурс. Два одинаковых POST /orders — два разных заказа.
  • PATCH — частично обновляет ресурс. Зависит от реализации: PATCH с {"balance": 100} идемпотентен, а PATCH с {"balance": "+50"} — нет.

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

Почему повторные запросы вообще случаются

Не потому что пользователи криворукие. Сеть ненадёжна, и это факт:

  • Мобильная сеть пропала в момент запроса. Клиент не знает, дошёл ли запрос. Отправляет снова.
  • Таймаут на стороне клиента. Сервер получил запрос, начал обрабатывать — но ответ не дошёл до клиента. Клиент решил, что запрос провалился.
  • Retry-логика в SDK или HTTP-клиенте. Axios, Retrofit, fetch с retry-обёрткой — многие библиотеки автоматически повторяют запросы при ошибках сети.
  • Нажали кнопку дважды. Дебаунс не поставили, защиту не сделали — классика.
  • Load balancer переключился на другой инстанс. Первый запрос завис на одном сервере, retry пошёл на другой — и оба выполнились.

Всё это реальные сценарии, не гипотетические. В высоконагруженных системах они происходят постоянно.

Idempotency Key — основной паттерн

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

POST /payments
Idempotency-Key: a3f8c2d1-9b4e-4f7a-8c3d-2e1f5a6b7c8d
Content-Type: application/json

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

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

Именно так работают Stripe, Яндекс.Касса, PayPal и большинство серьёзных платёжных API. Stripe вообще требует idempotency key для всех мутирующих операций.

Как реализовать на сервере

На практике схема такая:

  1. Клиент отправляет запрос с Idempotency-Key.
  2. Сервер проверяет, есть ли этот ключ в хранилище.
  3. Если есть — возвращает сохранённый ответ. Никакой бизнес-логики не выполняется.
  4. Если нет — выполняет запрос, сохраняет ключ + результат, возвращает ответ.

Пример на Python с Redis:

import redis
import json

r = redis.Redis()

def handle_payment(idempotency_key: str, payment_data: dict):
    cache_key = f"idem:{idempotency_key}"
    
    # Проверяем кеш
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Выполняем реальную операцию
    result = create_payment(payment_data)
    
    # Сохраняем результат на 24 часа
    r.setex(cache_key, 86400, json.dumps(result))
    
    return result

Важные детали реализации:

TTL ключей. Хранить ключи вечно нет смысла. 24 часа — стандарт для большинства операций. Для критичных финансовых транзакций — до 7 дней. Stripe хранит 24 часа.

Атомарность. Между проверкой ключа и его сохранением не должно быть race condition. Если два одинаковых запроса пришли одновременно, оба могут пройти проверку и создать два ресурса. Решение — атомарная операция SET NX в Redis или INSERT ... ON CONFLICT DO NOTHING в PostgreSQL.

# Атомарная проверка и установка через SET NX
set_result = r.set(cache_key, "processing", ex=86400, nx=True)
if not set_result:
    # Ключ уже существует — ждём результата
    return wait_for_result(cache_key)

# Выполняем операцию и обновляем значение
result = create_payment(payment_data)
r.set(cache_key, json.dumps(result), ex=86400)
return result

Валидация ключа. Если пришёл тот же ключ, но с другим телом запроса — это ошибка. Возвращайте 422 или 409. Один ключ — одна операция, менять тело нельзя.

Конфликты и граничные случаи

Запрос завис во время обработки. Первый запрос пришёл, сервер начал обработку — и упал. Ключ есть в базе со статусом processing, но результата нет. Второй запрос пришёл, видит ключ, ждёт. Нужно реализовать таймаут ожидания и механизм восстановления.

Разные коды ответа. Если первый запрос вернул 500, нужно ли сохранять этот результат и отдавать при повторе? Зависит от природы ошибки. Временные ошибки (сеть, БД не ответила) — не сохраняем, даём клиенту повторить. Бизнес-ошибки (недостаточно средств, карта заблокирована) — сохраняем, повтор ничего не изменит.

Генерация ключей на клиенте. UUID v4 — стандарт. Никаких хешей от тела запроса: если тело одинаковое, но операция разная (два платежа по 100 рублей от одного пользователя), ключи должны быть разными.

Идемпотентность без ключей — условное обновление

Ещё один подход — версионирование ресурсов через ETag и заголовок If-Match. Клиент получает ресурс с тегом версии, отправляет обновление только если версия не изменилась.

GET /orders/123
→ ETag: "version-5"

PUT /orders/123
If-Match: "version-5"
{ "status": "cancelled" }

Если между двумя запросами кто-то уже изменил заказ — сервер вернёт 412 Precondition Failed. Клиент получит актуальные данные и решит, что делать дальше. Это не совсем идемпотентность в чистом виде, но защищает от конкурентных обновлений.

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

Idem-ключи — не единственный способ. Иногда задачу решает уникальный индекс в базе данных.

Если у заказа есть external_id — уникальный идентификатор со стороны клиента — можно поставить на него unique constraint:

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  external_id UUID UNIQUE NOT NULL,
  amount INTEGER,
  status VARCHAR(20)
);

Теперь два одинаковых POST /orders с одним external_id вызовут конфликт на уровне БД. Второй запрос получит 409 Conflict. Никаких дублей.

Это проще, но менее гибко: не вернёшь сохранённый успешный ответ, только скажешь «уже было».

Что проверить в своём API прямо сейчас

Короткий чек-лист:

  • POST /payments, POST /orders, POST /subscriptions — есть ли защита от дублей?
  • Retry-логика в мобильном или фронтенд-клиенте — отправляет ли она idempotency key?
  • Таймауты в API — что возвращает сервер при 503? Повторит ли клиент запрос?
  • Тесты на идемпотентность — есть ли тест, который отправляет один запрос дважды и проверяет, что ресурс создался один раз?
  • Мониторинг дублей — считаете ли вы случаи, когда idempotency key сработал и предотвратил повтор?

Если хотя бы на один из этих вопросов ответ «нет» — у вас потенциальная проблема с данными.

Как это связано с проектированием API в целом

Идемпотентность — не изолированная фича, а часть философии надёжного API. Она работает в связке с другими вещами: правильными HTTP-статусами, чёткими контрактами, разумными таймаутами и документированным поведением при ошибках.

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

Добавить idempotency key в уже работающий API технически несложно. Но если архитектура изначально не рассчитана на это — придётся переделывать клиент, сервер и договариваться с командой. Это дороже.

Итог одним абзацем

Идемпотентность в API — это контракт: клиент может безопасно повторить любой запрос, и ничего лишнего не произойдёт. Реализуется через idempotency key с хранением в Redis или базе данных, через unique constraints или через версионирование с ETag. Критична для всего, что касается денег, заказов и любых мутирующих операций с побочными эффектами. Не роскошь, а гигиена.