Сеть ненадёжна. Запрос может выполниться, но ответ потеряться. Клиент повторит запрос — и пользователь заплатит дважды. Idempotency keys решают эту проблему, гарантируя, что повторный запрос вернёт тот же результат, а не выполнит операцию заново.
Проблема: дублирование платежей
Представьте сценарий:
- Пользователь нажимает "Оплатить"
- Клиент отправляет запрос на создание платежа
- Сервер обрабатывает запрос, списывает деньги
- Сеть теряет ответ (таймаут, разрыв соединения)
- Клиент не получает подтверждения и повторяет запрос
- Сервер обрабатывает запрос снова — двойное списание
Client Server Payment Provider
| | |
|--- POST /payments ------>| |
| |--- Charge card ------------>|
| |<-- Success -----------------|
| (timeout) | |
| | |
|--- POST /payments ------>| (retry) |
| |--- Charge card ------------>| (duplicate!)
| |<-- Success -----------------|
|<-- 200 OK ---------------| |
Что такое идемпотентность
Операция идемпотентна, если её многократное выполнение даёт тот же результат, что и однократное.
| HTTP метод | Идемпотентен? | Пример |
|---|---|---|
| GET | Да | Получение данных не меняет состояние |
| PUT | Да | Замена ресурса — результат одинаков |
| DELETE | Да | Удаление уже удалённого = 404, но без побочных эффектов |
| POST | Нет | Создание ресурса — каждый вызов создаёт новый |
POST не идемпотентен по дизайну. Но платежи требуют идемпотентности. Решение — idempotency keys.
Как работают idempotency keys
Концепция
Клиент генерирует уникальный ключ для каждой логической операции и передаёт его в заголовке. Сервер:
- Проверяет, есть ли уже результат для этого ключа
- Если есть — возвращает сохранённый результат
- Если нет — выполняет операцию и сохраняет результат
Пример Stripe API
curl https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-H "Idempotency-Key: AGJ6FJMkGQIpHUTX" \
-d amount=2000 \
-d currency=usd \
-d description="Charge for order #1234" \
-d customer=cus_A8Z5MHwQS7jUmZ
Если запрос с тем же Idempotency-Key повторится — Stripe вернёт результат первого запроса, не создавая новый charge.
Диаграмма с idempotency key
Client Server Payment Provider
| | |
|--- POST /payments ------>| |
| Key: abc-123 | |
| |--- Check key abc-123 |
| | (not found) |
| |--- Charge card ------------>|
| |<-- Success -----------------|
| |--- Save result for abc-123 |
| (timeout) | |
| | |
|--- POST /payments ------>| (retry) |
| Key: abc-123 | |
| |--- Check key abc-123 |
| | (found!) |
|<-- 200 OK (cached) ------| (no duplicate charge) |
Реализация на практике
Серверная сторона
class PaymentService {
async createPayment(request, idempotencyKey) {
// 1. Проверяем, есть ли уже результат
const cached = await this.cache.get(idempotencyKey);
if (cached) {
return cached; // Возвращаем сохранённый результат
}
// 2. Проверяем, не выполняется ли операция прямо сейчас
const lock = await this.acquireLock(idempotencyKey);
if (!lock) {
throw new ConflictError('Request in progress');
}
try {
// 3. Выполняем операцию
const result = await this.processPayment(request);
// 4. Сохраняем результат
await this.cache.set(idempotencyKey, result, TTL_24_HOURS);
return result;
} finally {
await this.releaseLock(idempotencyKey);
}
}
}
Клиентская сторона
class PaymentClient {
async createPayment(order) {
// Генерируем ключ один раз для логической операции
const idempotencyKey = `payment-${order.id}-${order.version}`;
return this.retryWithBackoff(async () => {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(order),
});
if (response.status === 409) {
// Конфликт — запрос ещё выполняется, подождём
throw new RetryableError('Request in progress');
}
return response.json();
});
}
}
Схема базы данных
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL, -- Для валидации параметров
response_code INT,
response_body JSONB,
created_at TIMESTAMP DEFAULT NOW(),
locked_at TIMESTAMP, -- Для конкурентных запросов
-- TTL: автоматическое удаление старых записей
expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);
Best practices
1. Генерация ключей на клиенте
Ключ должен генерироваться клиентом и быть привязан к намерению пользователя, а не к запросу.
// Хорошо: привязка к бизнес-сущности
const key = `order-${orderId}-payment`;
// Плохо: случайный ключ при каждом retry
const key = crypto.randomUUID(); // Не защитит от дублей!
2. Валидация параметров запроса
Сохраняйте хэш параметров вместе с ключом. Если повторный запрос приходит с другими параметрами — это ошибка клиента.
const requestHash = hash(JSON.stringify(request.body));
const stored = await getIdempotencyRecord(key);
if (stored && stored.requestHash !== requestHash) {
throw new BadRequestError(
'Idempotency key reused with different parameters'
);
}
3. TTL для ключей
Не храните ключи вечно. 24 часа — разумный TTL для большинства случаев. Stripe использует 24 часа.
order_id + payment_intent), который предотвратит дубликат даже после истечения TTL.
4. Обработка конкурентных запросов
Два запроса с одинаковым ключом могут прийти одновременно. Используйте distributed lock:
// Redis lock с TTL
const lockKey = `lock:${idempotencyKey}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 30);
if (!acquired) {
// Другой запрос уже выполняется
return res.status(409).json({ error: 'Request in progress' });
}
5. Retry стратегия
Используйте exponential backoff с jitter:
async function retryWithBackoff(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (err) {
if (!isRetryable(err) || i === maxRetries - 1) throw err;
const delay = Math.min(1000 * Math.pow(2, i), 10000);
const jitter = Math.random() * 1000;
await sleep(delay + jitter);
}
}
}
6. Какие операции защищать
| Операция | Нужен idempotency key? |
|---|---|
| Создание платежа | Да |
| Возврат средств | Да |
| Отправка email | Да |
| Создание заказа | Да |
| Обновление профиля | Опционально (PUT идемпотентен) |
| Получение данных | Нет (GET идемпотентен) |
7. Идемпотентность в событийных архитектурах
В event-driven системах проблема дублирования ещё острее: очереди сообщений (Kafka, RabbitMQ, SQS) гарантируют at-least-once доставку, то есть одно и то же сообщение может быть обработано несколько раз. Idempotency keys применимы и здесь:
async function handlePaymentEvent(event) {
// Используем event ID как idempotency key
const processed = await db.query(
'SELECT 1 FROM processed_events WHERE event_id = $1',
[event.id]
);
if (processed.rows.length > 0) return; // Уже обработано
await db.transaction(async (tx) => {
await processPayment(tx, event.data);
await tx.query(
'INSERT INTO processed_events (event_id) VALUES ($1)',
[event.id]
);
});
}
Истинная exactly-once семантика в распределённых системах крайне сложна. На практике at-least-once доставка + идемпотентная обработка — наиболее надёжный подход.
Резюме
Ключевые принципы:
- Сеть ненадёжна — запросы будут повторяться
- Idempotency key = "выполни один раз, верни результат при повторах"
- Ключ генерируется клиентом и привязан к намерению пользователя
- Сервер сохраняет результат и возвращает его при повторном запросе
- Используйте lock для конкурентных запросов
- TTL 24 часа — разумный default
- Stripe: заголовок
Idempotency-Key, TTL 24 часа - PayPal: заголовок
PayPal-Request-Id - Adyen: поле
referenceв теле запроса