
Повторные запросы — нормальная часть жизни любого онлайнового сервиса. Пользователь нажал «Оплатить» дважды, мобильная сеть флапнула, клиентская библиотека сделала ретрай, прокси повторил из‑за таймаута. Если сервер не готов, мы получаем:
Идемпотентность делает повтор безопасным: один и тот же запрос с тем же Idempotency‑Key приводит к одному и тому же результату. Это уменьшает инциденты, экономит деньги и время саппорта.
Типичные сценарии:
В каждой ситуации нужно одно: определить «повтор это или нет» и вернуть прежний результат, не выполняя действие второй раз.
Простая, но сильная идея:
Важно: идемпотентность не про «сделать операцию безопасной сама по себе», а про «сделать повтор безопасным». Внутри операция может быть сложной, но снаружи результат для данного ключа фиксирован.
Рекомендации по интерфейсу:
Idempotency-Key: <uuid> на операции, которые создают/меняют состояние (платеж, заказ, возврат, назначение курьера).409 Conflict с пояснением.409 Conflict + Retry-After.Заголовки, которые помогают:
Idempotency-Key (в запросе и можно отражать в ответе для трассировки),Idempotency-Result: reused|created (удобно для отладки),Retry-After при статусе 409 в случае «ещё в обработке».Для финансовых и критичных операций — храните ключи с результатами в БД.
Ниже — рабочая схема для хранения состояния идемпотентности и ответа. Мы также храним «хэш запроса», чтобы запретить переиспользование одного и того же ключа для разных тел.
-- Таблица ключей идемпотентности
create table if not exists idempotency_keys (
key text primary key,
endpoint text not null,
request_hash bytea not null,
status text not null check (status in ('in_progress','completed','failed')),
response_status int,
response_headers jsonb,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
expires_at timestamptz
);
create index if not exists idempotency_keys_expires_at_idx
on idempotency_keys (expires_at) where expires_at is not null;
-- Обновляем 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 set_updated_at_trg on idempotency_keys;
create trigger set_updated_at_trg
before update on idempotency_keys
for each row execute procedure set_updated_at();
Пояснения:
key — сам Idempotency‑Key (уникальный).endpoint — нормализованный путь и метод (POST /payments). Это часть хеша.request_hash — криптографический хеш тела/параметров запроса и эндпоинта.status — в обработке/завершён/ошибка (ошибки стоит кэшировать коротко).response_* — сохранённый ответ, который вернём при повторе.expires_at — когда запись можно удалить задачей очистки.Хеш удобно считать, например, через SHA‑256. В Postgres можно хранить как bytea.
Ключевая идея: создание записи Idempotency‑Key и бизнес‑операцию выполняем в одной транзакции.
in_progress.response_*, выставляем completed.Так мы исключаем «ответ записан, но операция не выполнена» и наоборот.
Ниже — минимальный, но рабочий пример на TypeScript с pg. Он показывает обработку POST /payments, где для каждого запроса требуется Idempotency‑Key. При повторах мы возвращаем сохранённый ответ.
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { Pool } from 'pg';
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/postgres',
max: 10,
});
function hashRequest(endpoint: string, body: any): Buffer {
const h = crypto.createHash('sha256');
h.update(endpoint);
h.update('\n');
h.update(JSON.stringify(body, Object.keys(body).sort()));
return h.digest();
}
async function withClient<T>(fn: (client: any) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
app.post('/payments', async (req: Request, res: Response) => {
const idemKey = req.header('Idempotency-Key');
if (!idemKey || idemKey.length > 200) {
return res.status(400).json({ error: 'Idempotency-Key is required and must be <= 200 chars' });
}
const endpoint = 'POST /payments';
const reqHash = hashRequest(endpoint, req.body);
try {
await withClient(async (client) => {
await client.query('BEGIN');
// Попытка вставить новую запись. Если ключ уже существует — конфликт.
const insertResult = await client.query(
`insert into idempotency_keys(key, endpoint, request_hash, status, expires_at)
values ($1, $2, $3, 'in_progress', now() + interval '3 days')
on conflict (key) do nothing
returning key`,
[idemKey, endpoint, reqHash]
);
if (insertResult.rowCount === 0) {
// Ключ существует: проверим тело и статус
const rowRes = await client.query(
'select key, request_hash, status, response_status, response_headers, response_body from idempotency_keys where key = $1 for update',
[idemKey]
);
const row = rowRes.rows[0];
if (!row) {
// Теоретически не должно случиться, но на всякий случай
throw new Error('Idempotency record disappeared');
}
const sameHash = Buffer.compare(row.request_hash, reqHash) === 0;
if (!sameHash) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'Idempotency-Key reused with different payload' });
}
if (row.status === 'completed') {
await client.query('COMMIT');
// Возвращаем сохранённый ответ
res.set('Idempotency-Result', 'reused');
if (row.response_headers) {
const hdrs = row.response_headers as Record<string,string>;
Object.entries(hdrs).forEach(([k,v]) => { if (k.toLowerCase() !== 'content-length') res.set(k, String(v)); });
}
return res.status(row.response_status || 200).json(row.response_body || {});
}
// Если in_progress — подождём до 5 секунд, вдруг первая попытка сейчас завершится
const started = Date.now();
while (Date.now() - started < 5000) {
await new Promise(r => setTimeout(r, 200));
const check = await client.query(
'select status, response_status, response_headers, response_body from idempotency_keys where key = $1',
[idemKey]
);
const st = check.rows[0];
if (st && st.status === 'completed') {
await client.query('COMMIT');
res.set('Idempotency-Result', 'reused');
return res.status(st.response_status || 200).json(st.response_body || {});
}
}
await client.query('ROLLBACK');
res.set('Retry-After', '2');
return res.status(409).json({ error: 'Request with this Idempotency-Key is still in progress. Retry later.' });
}
// Новая попытка: выполняем бизнес-логику.
// Здесь — имитация платёжной операции и создание ресурса.
// В реальном коде вызывайте платёжный провайдер, БД и т.д., но всё в этой транзакции либо с надёжной компенсацией.
const paymentId = crypto.randomUUID();
// Пример: создаём запись о платеже
await client.query(
'create table if not exists payments(id uuid primary key, amount int not null, currency text not null, created_at timestamptz default now())'
);
const { amount, currency } = req.body || {};
if (!Number.isInteger(amount) || amount <= 0 || typeof currency !== 'string') {
// Ошибка валидации — можно пометить failed и короткий TTL
await client.query(
`update idempotency_keys set status = 'completed', response_status = $2, response_headers = $3, response_body = $4, expires_at = now() + interval '6 hours' where key = $1`,
[idemKey, 422, { 'Content-Type': 'application/json' }, { error: 'Invalid amount or currency' }]
);
await client.query('COMMIT');
res.set('Idempotency-Result', 'created');
return res.status(422).json({ error: 'Invalid amount or currency' });
}
await client.query(
'insert into payments(id, amount, currency) values ($1, $2, $3)',
[paymentId, amount, currency]
);
const responseBody = { id: paymentId, status: 'succeeded', amount, currency };
// Сохраняем ответ
await client.query(
`update idempotency_keys
set status = 'completed', response_status = $2, response_headers = $3, response_body = $4
where key = $1`,
[idemKey, 201, { 'Content-Type': 'application/json' }, responseBody]
);
await client.query('COMMIT');
res.set('Idempotency-Result', 'created');
return res.status(201).json(responseBody);
});
} catch (e: any) {
console.error(e);
return res.status(500).json({ error: 'Internal error' });
}
});
const port = Number(process.env.PORT || 3000);
app.listen(port, () => console.log(`Server on :${port}`));
Что здесь важно:
«Ровно один раз» в распределённых системах — миф. Реалистичный контракт — «как минимум один раз», значит нужно:
insert ... on conflict do nothing с уникальным бизнес‑ключом.processed_messages с уникальным message_id. Перед началом обработки — попытка вставки; если конфликт, пропускаем обработку.Пример таблицы для де‑дупликации сообщений:
create table if not exists processed_messages (
message_id text primary key,
processed_at timestamptz not null default now(),
expires_at timestamptz
);
create index if not exists processed_messages_expires_at_idx
on processed_messages (expires_at) where expires_at is not null;
Логика потребителя:
insert into processed_messages(message_id, expires_at) values (...) on conflict do nothing.expires_at или оставить для TTL‑очистки.Связка с outbox‑паттерном: события публикуем из таблицы outbox, а на стороне консьюмера защищаемся от дублей через processed_messages.
Метрики и логи, которые стоит собирать:
Idempotency-Result: created|reused).Защита:
Idempotency-Key для этих эндпоинтов.in_progress: короткое ожидание + 409/Retry‑After.Идемпотентность — это не надстройка «для галочки». Это фундаментальная защита денег, заказов и репутации. Реализовать её можно просто и надёжно, без экзотики и лишних сервисов. Начните с ключей в API и де‑дупликации в воркерах, а дальше совершенствуйте UX и наблюдаемость.