Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Идемпотентность API и ключи повторов: как не списывать деньги дважды и снизить число инцидентов

Разработка и технологии11 января 2026 г.
Повторы запросов неизбежны: сети рвутся, клиенты жмут кнопку ещё раз, ретраи в библиотеке срабатывают не к месту. Идемпотентность позволяет делать повтор безопасным: один и тот же эффект, один и тот же ответ, ноль дублей платежей. Разбираем, как спроектировать ключи повторов, хранение результатов и защиту от гонок — с готовой схемой таблицы и обработчиком.
Идемпотентность API и ключи повторов: как не списывать деньги дважды и снизить число инцидентов

Оглавление

  • Зачем бизнесу идемпотентность и почему без неё растут инциденты
  • Что такое идемпотентность в API простыми словами
  • Дизайн: ключ повторов, область действия, хэш тела запроса
  • Схема таблицы в PostgreSQL и очистка старых записей
  • Обработчик на Node.js + PostgreSQL: готовый шаблон
  • Асинхронные операции: 202 Accepted, опрос состояния и те же ключи
  • Работа с внешними интеграциями (платежи, почта, SMS)
  • Миграция без боли: как включать по маршрутам и контролировать риски
  • Метрики и алерты: как понять, что система действительно стала надёжнее
  • Чек‑лист внедрения

Зачем бизнесу идемпотентность и почему без неё растут инциденты

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

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

Что такое идемпотентность в API простыми словами

  • Идемпотентный запрос — тот, который можно повторить сколько угодно раз без дополнительных побочных эффектов.
  • GET обычно идемпотентен по смыслу (ничего не меняет), а операции изменения (POST, PUT, PATCH, DELETE) — нет. Но их можно сделать идемпотентными с помощью ключей повторов и хранения результата.
  • В распределённых системах «точно один раз» — миф. Реальность — «как минимум один раз» плюс дедупликация по ключу.

Дизайн: ключ повторов, область действия, хэш тела запроса

Как сделать безопасно:

  1. Ключ повтора (Idempotency-Key)
  • Клиент генерирует уникальный ключ для конкретной операции (например, UUID). Передаёт его в заголовке Idempotency-Key.
  • Один ключ — одна операция. Повтор с тем же ключом должен вернуть тот же ответ.
  1. Область действия (scope)
  • Привяжите ключ к области: например, к аккаунту/пользователю/магазину. Это не даст запросам разных клиентов «перебивать» друг друга одинаковым ключом.
  1. Привязка к содержимому (хэш тела)
  • Чтобы клиент случайно не переиспользовал ключ с другим телом запроса, сохраняйте хэш канонизированного тела. Если ключ совпал, а хэш другой — это конфликт, верните 409.
  1. Храните готовый ответ
  • Первый запрос обрабатывается «по-настоящему», результат и ответ сохраняются. Повторы получают тот же ответ из хранилища.
  1. Состояния записи
  • pending — запрос в работе; success — есть готовый ответ; error — ошибка, которую тоже можно повторно вернуть (или разрешать новый прогон по политике).

Схема таблицы в PostgreSQL и очистка старых записей

-- Таблица идемпотентности
CREATE TABLE IF NOT EXISTS idempotency (
  key TEXT NOT NULL,
  scope TEXT NOT NULL,                 -- например, tenant_id или user_id
  request_hash BYTEA NOT NULL,         -- SHA‑256 канонизированного тела
  status SMALLINT NOT NULL,            -- 0=pending, 1=success, 2=error
  response_status INT,                 -- HTTP-код ответа
  response_headers JSONB,              -- при необходимости (обычно не обяз.)
  response_body JSONB,                 -- храним то, что реально нужно клиенту
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (key, scope)
);

CREATE INDEX IF NOT EXISTS idempotency_created_at_idx
  ON idempotency (created_at);

-- Триггер для updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END; $$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_set_updated_at ON idempotency;
CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON idempotency
FOR EACH ROW EXECUTE PROCEDURE set_updated_at();

Очистка старых записей:

-- Для неплатёжных эндпоинтов обычно достаточно 7–30 дней
DELETE FROM idempotency
WHERE created_at < now() - INTERVAL '30 days';

-- Для платежей храните дольше (например, 180 дней) из-за споров и чарджбеков

Обработчик на Node.js + PostgreSQL: готовый шаблон

Ниже — минимальный, но боевой подход: канонизация тела, вставка pending, обработка, сохранение ответа, возврат кэша при повторе. Язык не важен — паттерн одинаков для Go, Java, Python.

// package.json: "pg", "uuid"
import crypto from 'crypto';
import express from 'express';
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json({ limit: '1mb' }));

function canonicalJson(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(canonicalJson);
  const sorted = {};
  Object.keys(obj).sort().forEach(k => {
    sorted[k] = canonicalJson(obj[k]);
  });
  return sorted;
}

function bodyHash(body) {
  const canonical = JSON.stringify(canonicalJson(body ?? {}));
  return crypto.createHash('sha256').update(canonical).digest();
}

async function withTx(fn) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const res = await fn(client);
    await client.query('COMMIT');
    return res;
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

// Пример: создание заказа с оплатой
app.post('/api/orders', async (req, res) => {
  const scope = String(req.headers['x-tenant-id'] || req.headers['x-user-id'] || 'public');
  const key = String(req.headers['idempotency-key'] || '');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });

  const reqHash = bodyHash(req.body);

  try {
    const result = await withTx(async (client) => {
      // 1) Пытаемся вставить pending-запись
      const insert = await client.query(
        `INSERT INTO idempotency(key, scope, request_hash, status)
         VALUES ($1, $2, $3, 0)
         ON CONFLICT DO NOTHING
         RETURNING key, scope, status`,
        [key, scope, reqHash]
      );

      if (insert.rowCount === 0) {
        // 2) Уже есть запись: проверяем хэш и статус
        const existing = await client.query(
          `SELECT status, request_hash, response_status, response_body
           FROM idempotency WHERE key=$1 AND scope=$2 FOR UPDATE`,
          [key, scope]
        );
        if (existing.rowCount === 0) {
          // Редкий случай гонки: создаём ещё раз
          throw new Error('Idempotency race');
        }
        const row = existing.rows[0];
        const sameBody = Buffer.compare(row.request_hash, reqHash) === 0;
        if (!sameBody) {
          return { replay: true, conflict: true };
        }
        if (row.status === 1 || row.status === 2) {
          return {
            replay: true,
            response_status: row.response_status || 200,
            response_body: row.response_body || {}
          };
        }
        // status=0 (pending) — можно вернуть 202 или подождать коротко
        return { pending: true };
      }

      // 3) Реальная бизнес-логика: создаём заказ, списываем деньги
      // Здесь важно чтобы всё было атомарно и повторяемо.
      // Пример демо-логики:
      const order = await client.query(
        `INSERT INTO orders(id, amount, currency)
         VALUES (gen_random_uuid(), $1, $2)
         RETURNING id, amount, currency`,
        [req.body.amount, req.body.currency || 'RUB']
      );

      // Внешний платёжный провайдер: обязательно пробрасываем наш ключ
      // await charge({ amount: req.body.amount, key });

      const responseBody = { id: order.rows[0].id, status: 'created' };

      // 4) Сохраняем ответ и отмечаем success
      await client.query(
        `UPDATE idempotency
           SET status=1, response_status=$3, response_body=$4
         WHERE key=$1 AND scope=$2`,
        [key, scope, 201, responseBody]
      );

      return { response_status: 201, response_body: responseBody };
    });

    if (result.replay && result.conflict) {
      return res.status(409).json({ error: 'Idempotency-Key already used with different body' });
    }
    if (result.replay) {
      return res.status(result.response_status).json(result.response_body);
    }
    if (result.pending) {
      // Можно вернуть 202, чтобы клиент подождал и повторил чуть позже
      return res.status(202).json({ status: 'pending' });
    }
    return res.status(result.response_status).json(result.response_body);
  } catch (e) {
    // Не забываем сохранить ошибку как результат, чтобы повторы возвращали её же
    try {
      await withTx(async (client) => {
        await client.query(
          `UPDATE idempotency SET status=2, response_status=$3, response_body=$4
           WHERE key=$1 AND scope=$2`,
          [req.headers['idempotency-key'], String(req.headers['x-tenant-id'] || req.headers['x-user-id'] || 'public'), 500, { error: 'internal' }]
        );
      });
    } catch (_) {}
    return res.status(500).json({ error: 'internal' });
  }
});

app.listen(process.env.PORT || 3000, () => console.log('listening'));

curl для проверки:

# Первый запрос — создаст заказ
curl -i -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 4e2e1f30-8a36-4f2a-9a0f-9b5b6a5d9e21' \
  -H 'X-Tenant-Id: shop_42' \
  -d '{"amount": 1000, "currency": "RUB"}'

# Повтор с тем же ключом — вернёт тот же ответ, без дублей
curl -i -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 4e2e1f30-8a36-4f2a-9a0f-9b5b6a5d9e21' \
  -H 'X-Tenant-Id: shop_42' \
  -d '{"amount": 1000, "currency": "RUB"}'

# Конфликт: тот же ключ, другое тело — 409
curl -i -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 4e2e1f30-8a36-4f2a-9a0f-9b5b6a5d9e21' \
  -H 'X-Tenant-Id: shop_42' \
  -d '{"amount": 2000, "currency": "RUB"}'

Практические детали:

  • Канонизация JSON нужна, чтобы порядок полей не влиял на хэш.
  • Не храните чувствительные данные в response_body. Достаточно идентификаторов и статуса.
  • Для больших ответов храните «указатель»: ID сущности, которую клиент сможет получить отдельным GET.

Асинхронные операции: 202 Accepted, опрос состояния и те же ключи

Долгие процессы (обработка файла, интеграция с бухгалтерией) лучше переводить в асинхронный режим:

  • Клиент отправляет POST с Idempotency-Key.
  • Сервер создаёт запись с pending и возвращает 202 + operation_id (часто = ключу).
  • Клиент делает GET /operations/{id} и получает статус done/failed. Повтор POST с тем же ключом возвращает тот же 202 или финальный ответ.

Это убирает таймауты, снижает нагрузку и упрощает повтор.

Работа с внешними интеграциями (платежи, почта, SMS)

  • Платежи. У большинства провайдеров есть собственный Idempotency-Key. Пробрасывайте ваш ключ вниз по цепочке. Если у провайдера нет ключей — держите свой уровень дедупликации и не выполняйте повторный списание.
  • Почта/SMS. Обычно провайдеры не поддерживают идемпотентность. Решение — сохранять отпечаток содержания и получателя, и не слать повторно в течение окна (например, 10 минут).
  • Вебхуки. Доставляются «минимум один раз». Храните у себя таблицу доставленных событий с уникальным event_id. Повтор — просто игнор.

Миграция без боли: как включать по маршрутам и контролировать риски

  • Начните с самых дорогих операций: платежи, создание заказа, начисления бонусов.
  • Включайте «требуется Idempotency-Key» валидацией только на новых версиях клиентов. Старым — разрешите, но добавьте мягкие лимиты (скорость, антидубль по естественному ключу).
  • Привязывайте ключ к области (tenant/user). Так проще масштабировать и шардировать таблицу.
  • В логах пишите correlation_id и idempotency_key — поддержке будет легче разбираться.

Метрики и алерты: как понять, что система действительно стала надёжнее

Собирайте и смотрите на:

  • Доля повторов: count(replay)/count(total) — покажет, сколько трафика вы «погасили» без побочек.
  • Конфликты по ключу и хэшу: если растут — клиенты неверно переиспользуют ключи.
  • Среднее время pending: зашкаливает — увеличьте таймауты или переведите в async.
  • Количество дублей платежей до/после — целевая метрика для бизнеса.

Алерты:

  • Спайк 409 по идемпотентности.
  • Долгое pending > N секунд на 95‑м перцентиле.
  • Ошибки записи в таблицу idempotency (например, из-за недоступности БД).

Чек‑лист внедрения

  • Выбрана область (scope): пользователь, организация, магазин.
  • Схема таблицы создана, индексы и очистка настроены.
  • Канонизация тела и SHA‑256 хэш реализованы.
  • Обработчик: вставка pending → бизнес-логика → сохранение ответа.
  • Повтор с тем же ключом возвращает тот же ответ без побочных эффектов.
  • Конфликт по хэшу — 409.
  • Долгие операции переведены на 202 + GET статуса.
  • Ключ пробрасывается во внешние интеграции (платежи, вебхуки).
  • Метрики и алерты настроены, есть дашборд.
  • Документация для клиентов API: как генерировать ключи и как долго они живут.

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


APIидемпотентностьплатежи