
Повторы запросов — норма: мобильный интернет рвётся, прокси ретраят, клиенты жмут кнопку дважды. Без идемпотентности одно действие превращается в два: двойное списание, два заказа, две SMS, два подписания договора. Это прямые убытки, споры с клиентами и штрафы от платёжных систем.
Лимиты запросов защищают SLA и деньги: без них бот или неудачная интеграция способна уложить ваш сервис и чужие. Пиковая нагрузка становится прогнозируемой, а бюджеты — управляемыми.
Идемпотентность убирает «дубли», лимиты — «штормы». Вместе они дают спокойные релизы, меньше инцидентов и довольных пользователей.
Важно: идемпотентность — про корректность и деньги; лимиты — про устойчивость и бюджеты. Они дополняют друг друга.
Идемпотентный ключ должен однозначно описывать «намерение» запроса:
Idempotency-Key (UUID/ULID). Удобно для платежей и UI.Ключ должен быть уникальным в пределах «области» (например, на пользователя в конкретном эндпоинте) и иметь разумный срок жизни (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);
Зачем поля:
Базовый сценарий:
in_progress. Если конфликт — читаем существующую запись.succeeded — сразу отдаём сохранённый ответ (тот же код и тело). Если failed_final — отдаём ту же ошибку.succeeded + ответ. Если упали — последующие повторы разберутся со статусом.Главная цель — либо выполнить операцию ровно один раз, либо вернуть тот же результат без повторного эффекта.
Идемпотентные ключи храним ограниченно, обычно 24–72 часа, иногда до недели (платежи). Старые записи удаляем фоновым джобом. TTL должен учитываться в контракте API: после истечения ключ больше не гарантирует повтор того же ответа.
Ниже — минимальная реализация 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'));
Принципы из примера:
В платежах идемпотентность нужно тянуть сквозь интеграцию:
Idempotency-Key провайдеру (у Stripe и др. это штатно). Тогда даже при сетевом обрыве провайдер не спишет повторно, а вернёт сохранённый результат.Идея: у каждого субъекта есть «ведро» ёмкостью N токенов. Запрос «съедает» 1 токен. Токены пополняются со скоростью R в секунду. Если токенов нет — 429 Too Many Requests. Это даёт контролируемый средний поток и ограниченный «всплеск».
Плюсы: простота, предсказуемость. Минусы: возможна «ступенька» при одновременных стартах. Для большей точности — скользящее окно, но токен‑бакет обычно достаточно хорош.
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 };
}));
Комбинируйте несколько ограничителей:
in_progress старше X минут, конфликты по ключу, доля ответов из кеша. Алерт: рост «старых» in_progress — признак подвисающих воркеров.Idempotency-Key.in_progress.Хорошо реализованные идемпотентность и лимиты — это не «костыль», а часть контракта вашего API и страховка для выручки. Им однажды уделяешь время — и потом спишь лучше сам и даёшь спать платежному провайдеру.