Idempotency Keys

Как PayPal и Stripe предотвращают дублирование платежей

Сеть ненадёжна. Запрос может выполниться, но ответ потеряться. Клиент повторит запрос — и пользователь заплатит дважды. Idempotency keys решают эту проблему, гарантируя, что повторный запрос вернёт тот же результат, а не выполнит операцию заново.

Проблема: дублирование платежей

Представьте сценарий:

  1. Пользователь нажимает "Оплатить"
  2. Клиент отправляет запрос на создание платежа
  3. Сервер обрабатывает запрос, списывает деньги
  4. Сеть теряет ответ (таймаут, разрыв соединения)
  5. Клиент не получает подтверждения и повторяет запрос
  6. Сервер обрабатывает запрос снова — двойное списание
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

Концепция

Клиент генерирует уникальный ключ для каждой логической операции и передаёт его в заголовке. Сервер:

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

Пример 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 часа.

Граничный случай: retry после истечения TTL. Если клиент повторяет запрос спустя 25 часов, ключ уже удалён. Сервер обработает запрос как новый — и создаст дубль. Для критических операций (платежи) рекомендуется дополнительная защита: уникальный constraint на бизнес-идентификатор (например, 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 доставка + идемпотентная обработка — наиболее надёжный подход.

Резюме

Ключевые принципы:

Реализация в популярных PSP:
  • Stripe: заголовок Idempotency-Key, TTL 24 часа
  • PayPal: заголовок PayPal-Request-Id
  • Adyen: поле reference в теле запроса