Коротка відповідь. Якщо клієнт відправив POST /payments і отримав timeout – операція могла як не відбутись, так і відбутись без доставленої відповіді. Повторний запит без захисту означає подвійне списання. Рішення – патерн Idempotency-Key: клієнт генерує унікальний ключ логічної операції, сервер атомарно фіксує ключ у базі (unique constraint, не «спочатку перевірив – потім вставив»), виконує операцію один раз і на повтори віддає збережену відповідь. Це індустріальний патерн, популяризований платіжними API, а не стандарт: IETF-драфт заголовка так і лишився драфтом. Нижче – дизайн і робочий код.
Чому повтори неминучі
За семантикою HTTP (RFC 9110) методи GET, PUT, DELETE – ідемпотентні: повтор дає той самий ефект, що й один виклик. POST – ні, і саме POST-ом ми створюємо замовлення, платежі й перекази.
Тепер подивись, скільки суб'єктів у сучасній системі ретраїть без твоєї участі: HTTP-клієнт із retry-політикою, service mesh, черга з at-least-once доставкою, вебхук провайдера («ми повторюємо доставку до 3 діб»), і нарешті людина, що тисне кнопку вдруге. Доставка «рівно один раз» у розподіленій системі недосяжна; досяжна – «принаймні один раз» плюс ідемпотентна обробка. Тому питання не «як заборонити ретраї», а «як зробити повтор безпечним».
Критичний нюанс, який перевіряють на співбесідах: timeout – це не негативна відповідь, це відсутність інформації. Сервер міг устигнути закомітити транзакцію до того, як обірвався сокет.
Дизайн Idempotency-Key
Контракт простий:
- Клієнт генерує ключ (UUID v4) на логічну операцію, не на HTTP-спробу: усі ретраї одного «Оплатити» несуть той самий ключ у заголовку
Idempotency-Key. - Сервер, уперше бачачи ключ, виконує операцію і зберігає результат.
- На повтор із тим самим ключем сервер не виконує операцію знову, а віддає збережену відповідь (той самий статус і тіло).
- Той самий ключ з іншим payload – це помилка клієнта: відповідаємо
409 Conflict, бо «повтор» із іншою сумою – це не повтор.
Scope ключа – пара «ендпоінт + ключ» (а в мультитенантній системі ще й тенант): однаковий UUID для POST /orders і POST /refunds не повинен конфліктувати.
Схема зберігання
CREATE TABLE idempotency_keys (
key text NOT NULL,
endpoint text NOT NULL,
request_hash text NOT NULL, -- sha256 нормалізованого payload
status text NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed')),
response_code integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (endpoint, key) -- атомарність гонки – ТУТ
);
-- прибирання старих ключів (TTL – див. нижче)
CREATE INDEX idx_idempotency_created ON idempotency_keys (created_at);Головне рішення тут – PRIMARY KEY (endpoint, key). Наївна реалізація «SELECT чи є ключ → якщо нема, працюємо» містить класичну гонку: два ретраї приходять одночасно, обидва бачать «ключа нема», обидва списують гроші. Перевірку й фіксацію має робити база атомарно – через унікальний індекс і INSERT ... ON CONFLICT.
Два режими: store result і lock in-progress
Патерн має два стани, і обидва потрібні:
- completed + збережена відповідь – пізній ретрай отримує той самий результат, операція не повторюється.
- in_progress (лок) – ретрай прилетів, поки перша спроба ще виконується. Виконувати паралельно не можна; віддаємо
409зRetry-After(або чекаємо – залежно від SLA клієнта).
Робочий ендпоінт на Node/TypeScript
import { createHash } from 'node:crypto';
import type { Pool } from 'pg';
type PaymentRequest = { orderId: string; amountUah: number };
function hashPayload(payload: unknown): string {
return createHash('sha256')
.update(JSON.stringify(payload))
.digest('hex');
}
export async function createPayment(
db: Pool,
idempotencyKey: string,
payload: PaymentRequest
): Promise<{ code: number; body: unknown }> {
const requestHash = hashPayload(payload);
// 1. Атомарна спроба «застовпити» ключ. ON CONFLICT – серце патерну:
// гонку двох одночасних ретраїв вирішує unique constraint, не код.
const claimed = await db.query(
`INSERT INTO idempotency_keys (key, endpoint, request_hash)
VALUES ($1, 'POST /payments', $2)
ON CONFLICT (endpoint, key) DO NOTHING
RETURNING key`,
[idempotencyKey, requestHash]
);
if (claimed.rowCount === 0) {
// Ключ уже існує: це ретрай або конфлікт
const existing = await db.query(
`SELECT request_hash, status, response_code, response_body
FROM idempotency_keys
WHERE endpoint = 'POST /payments' AND key = $1`,
[idempotencyKey]
);
const row = existing.rows[0];
if (row.request_hash !== requestHash) {
return {
code: 409,
body: { error: 'Idempotency-Key уже використаний з іншим payload' },
};
}
if (row.status === 'in_progress') {
return {
code: 409,
body: { error: 'Операція ще виконується, повтори пізніше' },
};
}
// Чесний повтор завершеної операції – віддаємо збережену відповідь
return { code: row.response_code, body: row.response_body };
}
// 2. Ми перші – виконуємо бізнес-операцію
try {
const payment = await chargeCustomer(db, payload); // списання + INSERT платежу
const response = { code: 201, body: { paymentId: payment.id } };
// 3. Фіксуємо результат для майбутніх ретраїв
await db.query(
`UPDATE idempotency_keys
SET status = 'completed', response_code = $3, response_body = $4
WHERE endpoint = 'POST /payments' AND key = $1 AND request_hash = $2`,
[idempotencyKey, requestHash, response.code, response.body]
);
return response;
} catch (error) {
// Операція впала – звільняємо ключ, щоб ретрай міг спробувати знову
await db.query(
`DELETE FROM idempotency_keys
WHERE endpoint = 'POST /payments' AND key = $1`,
[idempotencyKey]
);
throw error;
}
}
declare function chargeCustomer(
db: Pool,
payload: PaymentRequest
): Promise<{ id: string }>;Межа транзакції: найтонше місце
Уважний читач спитає: а що, як процес помре між chargeCustomer і UPDATE ... completed? Ключ залишиться in_progress навічно, а гроші – списані. Це реальна дірка naive-реалізації, і закривається вона так: якщо бізнес-операція живе у тій самій базі, виконуй списання і перехід ключа в completed в одній транзакції – тоді стан або повний, або жодний. Якщо ж операція йде в зовнішній платіжний шлюз, транзакцією її не обгорнеш – тоді потрібен reconciliation: фоновий процес, який знаходить давно «завислі» in_progress-ключі й опитує шлюз, чим насправді закінчилась операція. Це ще один приклад того, чому розуміння транзакцій і їхніх меж – не теорія для співбесід, а щоденна інженерія.
TTL і прибирання
Ключі не живуть вічно: типова практика – 24–72 години (платіжні API зазвичай документують добу). Менше – і чесний відкладений ретрай (вебхук через 6 годин) створить дубль; більше – таблиця розповзеться. Прибирання – банальний cron DELETE WHERE created_at < now() - interval '72 hours'. Після закінчення TTL повтор того самого ключа знову виконає операцію – тому TTL має бути довшим за найповільніший легітимний ретрай у твоїй системі.
Типові помилки
- Перевірка ключа через SELECT перед INSERT. Гонка. Атомарність дає лише unique constraint /
ON CONFLICT. - Ключ на HTTP-спробу замість логічної операції. Якщо фронт генерує новий UUID на кожен клік – патерн не працює. Ключ створюється разом із наміром («оплатити це замовлення») і живе крізь усі ретраї.
- Ігнорування payload-конфлікту. Той самий ключ з іншою сумою має бути помилкою, а не «ну, віддамо стару відповідь».
- Збереження відповіді без статусу операції. Без стану
in_progressпаралельні ретраї виконуються двічі. - Ідемпотентність «через перевірку бізнес-стану» («чи нема вже замовлення з таким cart_id») як єдиний механізм: інколи працює, але ламається на легітимних повторних покупках того самого кошика.
- Застосування патерну до GET. GET уже ідемпотентний за семантикою HTTP – ключ там лише шум.
Практичне завдання
Візьми свій pet-project із будь-яким POST /create-ендпоінтом і зроби його ідемпотентним за схемою вище. Потім напиши тест, який відправляє два паралельні запити з однаковим ключем (Promise.all) і перевіряє: у базі рівно один запис, одна з відповідей – результат, друга – той самий результат або 409. Цей тест на одну гонку навчить більше, ніж десять прочитаних статей – і це готова історія для backend-співбесіди, де idempotency люблять питати одразу після auth-токенів.