Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Идемпотентные API и лимиты запросов: без дублей в оплатах и защиты от перегрузки

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

Оглавление

  • Зачем это бизнесу
  • Что такое идемпотентность и лимитирование
  • Дизайн идемпотентных операций
    • Ключ и область действия
    • Хранилище и схема таблицы
    • Потоки: первый запрос, повтор, гонки
    • TTL и очистка
  • Практика: Node.js + PostgreSQL
  • Платежи и внешние провайдеры
  • Лимиты запросов: защита от перегрузки
    • Алгоритм «ведро с токенами»
    • Реализация на Redis + Lua
    • Многоуровневые лимиты
  • Наблюдаемость и алерты
  • Подводные камни и безопасность
  • План тестов
  • Чек‑лист внедрения

Зачем это бизнесу

Повторы запросов — норма: мобильный интернет рвётся, прокси ретраят, клиенты жмут кнопку дважды. Без идемпотентности одно действие превращается в два: двойное списание, два заказа, две SMS, два подписания договора. Это прямые убытки, споры с клиентами и штрафы от платёжных систем.

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

Идемпотентность убирает «дубли», лимиты — «штормы». Вместе они дают спокойные релизы, меньше инцидентов и довольных пользователей.

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

  • Идемпотентность операции: повтор одного и того же запроса не меняет результат и состояние системы. Для GET это обычно правда по умолчанию, а для POST — вопрос реализации.
  • Лимитирование: контролируем, сколько запросов в единицу времени принимает система от субъекта (пользователь, токен, IP), и сколько допускаем мгновенных «всплесков».

Важно: идемпотентность — про корректность и деньги; лимиты — про устойчивость и бюджеты. Они дополняют друг друга.

Дизайн идемпотентных операций

Ключ и область действия

Идемпотентный ключ должен однозначно описывать «намерение» запроса:

  • Включаем: метод, путь, идентификатор клиента/арендатора, версию API, значимые поля тела запроса (хэш).
  • Исключаем: поля, не влияющие на смысл (timestamp, трекинг, порядок полей).
  • Источник ключа:
    • Клиентский: заголовок Idempotency-Key (UUID/ULID). Удобно для платежей и UI.
    • Серверный: детерминированный хэш полезной нагрузки + контекста. Полезно для внутренних API и вебхуков.

Ключ должен быть уникальным в пределах «области» (например, на пользователя в конкретном эндпоинте) и иметь разумный срок жизни (TTL).

Хранилище и схема таблицы

Нужна таблица, где мы фиксируем попытки по ключу, статус выполнения и «слепок» ответа для повторной выдачи.

-- PostgreSQL
create table if not exists idempotency_keys (
  key            text        primary key,
  tenant_id      text        not null,
  method         text        not null,
  path           text        not null,
  body_sha256    bytea       not null,
  status         text        not null check (status in ('in_progress','succeeded','failed_final')),
  response_code  int,
  response_body  jsonb,
  created_at     timestamptz not null default now(),
  updated_at     timestamptz not null default now(),
  -- для «захвата» работы по ключу без гонок
  worker_token   uuid,
  locked_until   timestamptz
);

create index if not exists idx_idk_tenant_created on idempotency_keys (tenant_id, created_at);

Зачем поля:

  • status — понимаем, можно ли «переиспользовать» результат.
  • response_code/response_body — возвращаем клиенту тот же ответ.
  • worker_token/locked_until — не даём двум процессам одновременно «победить» по одному ключу.

Потоки: первый запрос, повтор, гонки

Базовый сценарий:

  1. Приходит запрос с ключом.
  2. Пытаемся «забронировать» ключ: вставляем запись со статусом in_progress. Если конфликт — читаем существующую запись.
  3. Если статус succeeded — сразу отдаём сохранённый ответ (тот же код и тело). Если failed_final — отдаём ту же ошибку.
  4. Если мы «победили» бронь — выполняем бизнес‑операцию, на выходе атомарно фиксируем succeeded + ответ. Если упали — последующие повторы разберутся со статусом.

Главная цель — либо выполнить операцию ровно один раз, либо вернуть тот же результат без повторного эффекта.

TTL и очистка

Идемпотентные ключи храним ограниченно, обычно 24–72 часа, иногда до недели (платежи). Старые записи удаляем фоновым джобом. TTL должен учитываться в контракте API: после истечения ключ больше не гарантирует повтор того же ответа.

Практика: Node.js + PostgreSQL

Ниже — минимальная реализация middleware для Express: фиксируем ключ, защищаемся от гонок, выполняем операцию один раз и переиспользуем ответ при повторах.

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

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

function hashBody(body: unknown) {
  const normalized = JSON.stringify(body ?? {});
  return Buffer.from(crypto.createHash('sha256').update(normalized).digest());
}

// Обёртка: делает обработчик идемпотентным
function withIdempotency(handler: (req: express.Request, res: express.Response) => Promise<{code:number, body:any}>) {
  return async (req: express.Request, res: express.Response) => {
    const client = await pool.connect();
    try {
      const tenantId = String(req.headers['x-tenant-id'] ?? 'public');
      const keyHeader = String(req.headers['idempotency-key'] ?? '');
      const key = keyHeader || `${tenantId}:${ulid()}`; // можно требовать обязательный ключ от клиента
      const bodyHash = hashBody(req.body);

      // 1) Попытка забронировать ключ (без гонок)
      const now = new Date();
      const lockedUntil = new Date(now.getTime() + 60_000); // минута на выполнение
      const workerToken = crypto.randomUUID();

      await client.query('BEGIN');

      // Вставляем новую запись, если нет конфликтов
      const insert = await client.query(
        `insert into idempotency_keys (key, tenant_id, method, path, body_sha256, status, worker_token, locked_until)
         values ($1,$2,$3,$4,$5,'in_progress',$6,$7)
         on conflict (key) do nothing
         returning key`,
        [key, tenantId, req.method, req.path, bodyHash, workerToken, lockedUntil]
      );

      if (insert.rowCount === 0) {
        // Ключ уже существует: проверяем состояние
        const exist = await client.query(
          `select status, response_code, response_body, locked_until from idempotency_keys where key=$1 for update`,
          [key]
        );
        const row = exist.rows[0];
        if (!row) {
          // крайне маловероятно
          throw new Error('Idempotency read lost');
        }
        if (row.status === 'succeeded') {
          await client.query('COMMIT');
          return res.status(row.response_code).json(row.response_body);
        }
        if (row.status === 'failed_final') {
          await client.query('COMMIT');
          return res.status(row.response_code).json(row.response_body);
        }
        // in_progress: если блокировка протухла — перехватываем; иначе просим клиента повторить позже
        if (new Date(row.locked_until) > new Date()) {
          await client.query('COMMIT');
          return res.status(409).json({ error: 'Request in progress, retry later' });
        }
        // перехватываем задачу
        await client.query(
          `update idempotency_keys set worker_token=$2, locked_until=$3, updated_at=now()
           where key=$1`,
          [key, workerToken, lockedUntil]
        );
      }

      await client.query('COMMIT');

      // 2) Выполняем бизнес‑логику
      const result = await handler(req, res);

      // 3) Фиксируем успех и ответ
      await client.query(
        `update idempotency_keys set status='succeeded', response_code=$2, response_body=$3, updated_at=now()
         where key=$1`,
        [key, result.code, result.body]
      );

      return res.status(result.code).json(result.body);
    } catch (e) {
      // Фиксируем финальный провал (если ошибка повторяемая — можно вернуть 409/429 без маркера failed_final)
      try {
        const tenantId = String(req.headers['x-tenant-id'] ?? 'public');
        const keyHeader = String(req.headers['idempotency-key'] ?? '');
        const key = keyHeader || `${tenantId}:unknown`;
        await pool.query(
          `update idempotency_keys set status='failed_final', response_code=$2, response_body=$3, updated_at=now()
           where key=$1 and status!='succeeded'`,
          [key, 500, { error: 'Internal error' }]
        );
      } catch {}
      return res.status(500).json({ error: 'Internal error' });
    } finally {
      // чистить блокировки по таймауту можно фоново; здесь просто возвращаем соединение
      client.release();
    }
  };
}

// Пример бизнес‑обработчика: создание заказа
const createOrder = withIdempotency(async (req, _res) => {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { amount, currency, user_id } = req.body;
    const ins = await client.query(
      `insert into orders (user_id, amount, currency) values ($1,$2,$3) returning id` ,
      [user_id, amount, currency]
    );
    await client.query('COMMIT');
    return { code: 201, body: { order_id: ins.rows[0].id } };
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
});

const app = express();
app.use(express.json());
app.post('/v1/orders', createOrder);

app.listen(3000, () => console.log('API on :3000'));

Принципы из примера:

  • Первый, кто вставил ключ, «владеет» выполнением. Остальные — получают готовый ответ или 409.
  • Ответ кешируется и возвращается дословно — клиент видит стабильное поведение.
  • Логику повторной «перехватки» по протухшей блокировке можно доработать под ваш SLA.

Платежи и внешние провайдеры

В платежах идемпотентность нужно тянуть сквозь интеграцию:

  • Передавайте ваш Idempotency-Key провайдеру (у Stripe и др. это штатно). Тогда даже при сетевом обрыве провайдер не спишет повторно, а вернёт сохранённый результат.
  • Если провайдер не поддерживает ключи — храните у себя «внешний идентификатор операции» и при повторах сначала проверяйте статус по API провайдера, не создавая новую попытку.
  • Всегда записывайте связь: ключ → внешняя операция. При успешном списании, но падении до фиксации ответа, повторы смогут достать статус и вернуть корректный результат.

Лимиты запросов: защита от перегрузки

Алгоритм «ведро с токенами»

Идея: у каждого субъекта есть «ведро» ёмкостью N токенов. Запрос «съедает» 1 токен. Токены пополняются со скоростью R в секунду. Если токенов нет — 429 Too Many Requests. Это даёт контролируемый средний поток и ограниченный «всплеск».

Плюсы: простота, предсказуемость. Минусы: возможна «ступенька» при одновременных стартах. Для большей точности — скользящее окно, но токен‑бакет обычно достаточно хорош.

Реализация на Redis + Lua

Lua‑скрипт выполняется атомарно на Redis: обновляет счётчик, учитывает пополнение и решает, можно ли пропускать запрос.

-- token_bucket.lua
-- KEYS[1] = ключ субъекта (например, rate:user:123)
-- ARGV[1] = capacity (integer)
-- ARGV[2] = refill_rate_per_sec (float)
-- ARGV[3] = now_ms (integer)
-- Возвращает: 1 если пропускаем, 0 если лимит; остаток токенов (float); время до полного восстановления (мс)

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])

if tokens == nil then
  tokens = capacity
  ts = now
else
  local delta = math.max(0, now - ts) / 1000.0
  tokens = math.min(capacity, tokens + delta * refill)
  ts = now
end

local allowed = 0
if tokens >= 1.0 then
  tokens = tokens - 1.0
  allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'ts', ts)
-- разумный TTL: 5 минут без активности
redis.call('PEXPIRE', key, 5 * 60 * 1000)

local to_full_ms = math.floor(((capacity - tokens) / refill) * 1000)
return { allowed, tostring(tokens), to_full_ms }

Middleware на Node.js:

import fs from 'fs';
import { createClient } from 'redis';
import { promisify } from 'util';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const script = fs.readFileSync('./token_bucket.lua', 'utf8');
const sha = await redis.scriptLoad(script);

type Subject = { key: string; capacity: number; refillPerSec: number };

async function checkLimit(subj: Subject) {
  const now = Date.now();
  const resp = await redis.evalSha(sha, {
    keys: [subj.key],
    arguments: [String(subj.capacity), String(subj.refillPerSec), String(now)],
  }) as [number, string, number];
  return { allowed: resp[0] === 1, tokensLeft: parseFloat(resp[1]), toFullMs: resp[2] };
}

function rateLimit(resolveSubject: (req: express.Request) => Subject) {
  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
    try {
      const subj = resolveSubject(req);
      const { allowed, toFullMs } = await checkLimit(subj);
      if (!allowed) {
        res.setHeader('Retry-After', Math.ceil(toFullMs / 1000));
        return res.status(429).json({ error: 'Rate limit exceeded' });
      }
      next();
    } catch (e) {
      // fail-open или fail-closed — зависит от политики
      return res.status(503).json({ error: 'Rate limiter unavailable' });
    }
  };
}

// Пример: 10 rps, ведро 20, отдельно для API‑ключа
app.use(rateLimit((req) => {
  const apiKey = String(req.headers['x-api-key'] || 'anon');
  return { key: `rate:api:${apiKey}`, capacity: 20, refillPerSec: 10 };
}));

Многоуровневые лимиты

Комбинируйте несколько ограничителей:

  • Глобальный (всему сервису), чтобы защитить базу/очереди.
  • Переключаемые «клапаны» на дорогие маршруты (например, /export, /search).
  • На клиента/токен и на IP — вместе. Сначала клиентский лимит, затем IP, чтобы локально душить ботов.
  • Горячие профили лимитов: при деградации снижайте RPS и выключайте неключевые маршруты.

Наблюдаемость и алерты

  • Метрики лимитера: доля 429, p95 задержки выполнения Lua, количество субъектов в лимите. Алерт: >1% 429 в течение 5 минут — расследовать.
  • Метрики идемпотентности: число in_progress старше X минут, конфликты по ключу, доля ответов из кеша. Алерт: рост «старых» in_progress — признак подвисающих воркеров.
  • Логи по ключу: traceId + idempotencyKey в каждую строку. Проще расследовать спорные операции.

Подводные камни и безопасность

  • «Ровно один раз» — миф вне одной транзакции. Реалистичная цель: «ровно один успешный эффект», остальное — повтор того же ответа.
  • Не кладите в ключ персональные данные. Если ключ строите детерминированно из тела — берите крипто‑хэш.
  • Старайтесь, чтобы успешный ответ был детерминирован при повторах (например, возвращайте существующий ресурс по уникальному external_id).
  • TTL: не делайте слишком коротким для медленных клиентов. Платежи — до 7 дней; внутренние API — 24–72 часа.
  • Ключи из UI: договоритесь с фронтом — при повторной отправке формы не меняйте Idempotency-Key.
  • Вебхуки: провайдеры ретраят. Делайте приём вебхуков тоже идемпотентным с теми же принципами и отдельно лимитируйте источник.

План тестов

  • Конкурентные повторы: 10 одновременных POST с одинаковым ключом — в базе один эффект, ответ одинаковый.
  • Сетевые таймауты: эмулируйте падение после выполнения и до фиксации ответа — повтор возвращает тот же результат.
  • Истечение блокировки: запрос «завис», затем повтор через 1–2 минуты — происходит перехват и корректное завершение.
  • Дедуп по телу: одинаковые тела с разными ключами (если строите ключ из тела) — не создают дублей.
  • Лимиты: замерьте, что при R=10, C=20 вы получаете ~10 rps в среднем и до 20 мгновенных запросов.
  • Нагрузочное: при 10× среднем трафике лимиты срабатывают, SLA базы и очередей не падают.

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

  • Приняли формат ключа и область действия (метод+путь+арендатор+хэш тела).
  • Реализовали хранение ключей с статусами и «слепком» ответа.
  • Сделали эндпоинты с критичными побочными эффектами идемпотентными.
  • Прописали TTL, фоновую очистку и дашборды.
  • В платежах передаёте ключ провайдеру и храните связь ключ ↔ внешняя операция.
  • Включили лимиты: глобальные, на маршрут, на клиента, на IP.
  • Оформили алерты по 429 и по подвисшим in_progress.
  • Покрыли тестами гонки, таймауты и деградацию Redis/БД.

Хорошо реализованные идемпотентность и лимиты — это не «костыль», а часть контракта вашего API и страховка для выручки. Им однажды уделяешь время — и потом спишь лучше сам и даёшь спать платежному провайдеру.


APIидемпотентностьrate limit