Представьте: пользователь нажал «Оплатить», страница зависла, он нажал ещё раз. Деньги списались дважды. Или заказ создался два раза. Или подписка оформилась дважды. Это не баг платёжной системы — это отсутствие идемпотентности в 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 для всех мутирующих операций.
Как реализовать на сервере
На практике схема такая:
- Клиент отправляет запрос с
Idempotency-Key.
- Сервер проверяет, есть ли этот ключ в хранилище.
- Если есть — возвращает сохранённый ответ. Никакой бизнес-логики не выполняется.
- Если нет — выполняет запрос, сохраняет ключ + результат, возвращает ответ.
Пример на 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. Критична для всего, что касается денег, заказов и любых мутирующих операций с побочными эффектами. Не роскошь, а гигиена.