
Повторы запросов происходят постоянно: пользователь нажал «Оплатить» дважды, мобильный клиент сделал ретрай после потери сети, балансировщик оборвал соединение на границе таймаута, а бэкенд успел провести операцию. Итог — дубли платежей, два заказа вместо одного, двойные письма и лишние расходы на поддержку.
Идемпотентность решает это: повтор одного и того же запроса приводит к одному и тому же результату и тому же ответу. Пользователь не пострадает от ретраев, а команда поддержки получает меньше обращений. Практический эффект — снижение числа инцидентов и возвратов, меньше откатов и разбирательств, выше доверие к продукту.
Как сделать безопасно:
Idempotency-Key.-- Таблица идемпотентности
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 дней) из-за споров и чарджбеков
Ниже — минимальный, но боевой подход: канонизация тела, вставка 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"}'
Практические детали:
Долгие процессы (обработка файла, интеграция с бухгалтерией) лучше переводить в асинхронный режим:
Это убирает таймауты, снижает нагрузку и упрощает повтор.
Собирайте и смотрите на:
Алерты:
Идемпотентность — это не про «красивую архитектуру», а про прямую защиту денег и репутации. Один день на внедрение в критические ручки окупается снижением дублей и обращений в поддержку. А дальше — дело техники: расширяйте покрытие, учите клиентов правильно генерировать ключи и наблюдайте за цифрами.