Пошук уроків, статей та іншого контенту
Навчимося проєктувати ідемпотентні API та обробники повідомлень для безпечних повторів і доставки щонайменше один раз.
Операція є ідемпотентною, якщо її повторне виконання дає той самий ефект, що й одноразове виконання:
[ f(f(x)) = f(x) ]
У розподілених системах повтори є нормальним сценарієм:
клієнт не отримав відповідь через мережевий збій і повторив запит;
проксі або балансувальник повторив запит;
брокер повідомлень доставив повідомлення ще раз;
обробник завершив бізнес-операцію, але не встиг підтвердити повідомлення;
процес упав після запису в базу даних, але до відправлення відповіді.
Без ідемпотентності повтор може призвести до:
подвійного списання коштів;
створення двох замовлень;
повторного надсилання листа;
подвійного нарахування бонусів;
дублювання записів.
Ідемпотентність не означає, що запит можна виконувати нескінченно без обмежень. Вона означає, що повтор тієї самої логічної операції не створює нового ефекту.
Деякі HTTP-методи за задумом ідемпотентні:
PUT встановлює ресурс у визначений стан;
DELETE видаляє ресурс, а повторне видалення не повинно створювати новий ефект.
Наприклад:
PUT /users/42/profile
Content-Type: application/json
{
"name": "Олена"
}Повторення цього запиту встановлює те саме ім’я.
Однак ідемпотентність методу не гарантує ідемпотентність конкретної реалізації. Наприклад, якщо обробник PUT щоразу створює новий запис в історії або надсилає лист без перевірки, побічні ефекти можуть дублюватися.
Метод POST зазвичай не є ідемпотентним:
POST /paymentsКожен такий запит може створювати новий платіж. Для безпечного повтору POST API використовують ідемпотентний ключ.
Клієнт генерує унікальний ключ для однієї логічної операції та передає його в заголовку:
POST /payments
Idempotency-Key: 8c4f1f7a-7d7e-4b37-ae4a-12b6d6e0b3a1
Content-Type: application/json
{
"orderId": "order-123",
"amount": 2500,
"currency": "UAH"
}Сервер повинен:
отримати ключ;
обчислити відбиток запиту;
перевірити, чи оброблявся цей ключ раніше;
якщо результат уже є — повернути збережену відповідь;
якщо ключ обробляється зараз — не запустити операцію вдруге;
якщо ключ новий — виконати операцію та зберегти результат.
Важливо: ключ описує не HTTP-запит взагалі, а одну логічну операцію. Якщо користувач хоче створити два різні платежі, для них потрібні два різні ключі.
Для кожного ідемпотентного ключа зазвичай зберігають:
ідентифікатор клієнта або користувача;
сам ключ;
відбиток параметрів запиту;
статус обробки;
HTTP-статус відповіді;
тіло відповіді;
час створення та час завершення;
за потреби — час завершення терміну дії.
Приклад логічної структури:
(client_id, idempotency_key) -> {
request_fingerprint,
status: pending | completed | failed,
response_status,
response_body,
created_at,
expires_at
}Ключ потрібно прив’язувати до клієнта. Інакше один клієнт потенційно зможе повторно використати ключ іншого клієнта.
Один ключ не можна використовувати для різних параметрів:
Idempotency-Key: key-1Перший запит:
{
"orderId": "order-123",
"amount": 2500
}Повторний запит із тим самим ключем:
{
"orderId": "order-456",
"amount": 9000
}Такий запит потрібно відхилити, зазвичай зі статусом 409 Conflict. Інакше результат залежатиме від того, який запит сервер обробив першим.
Відбиток можна обчислити з канонічного представлення параметрів. Потрібно, щоб однакові логічні дані давали однаковий відбиток незалежно від порядку полів JSON.
Окремо потрібно обробляти випадок, коли два однакові запити надходять майже одночасно.
Наївний алгоритм небезпечний:
if key не знайдено:
виконати платіж
зберегти keyДва паралельні запити можуть одночасно не знайти ключ і обидва виконати платіж.
Потрібна атомарна операція резервування ключа:
INSERT idempotency_key ...
ON CONFLICT DO NOTHINGЛише запит, який успішно вставив запис, стає власником обробки. Інші запити повинні:
дочекатися завершення;
повернути вже збережений результат;
або отримати відповідь про тимчасове очікування.
Нижче наведено runnable-приклад на Node.js без зовнішніх бібліотек. Він демонструє:
заголовок Idempotency-Key;
перевірку однаковості параметрів;
повторне повернення тієї самої відповіді;
захист від одночасних повторів.
У прикладі сховище зберігається в пам’яті процесу. Для production-системи потрібне спільне довговічне сховище, наприклад база даних або Redis із правильною атомарною логікою.
const http = require("node:http");
const crypto = require("node:crypto");
const idempotency = new Map();
const payments = new Map();
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(JSON.stringify(body));
}
function readBody(request) {
return new Promise((resolve, reject) => {
let data = "";
request.setEncoding("utf8");
request.on("data", chunk => {
data += chunk;
if (data.length > 1_000_000) {
reject(new Error("Занадто великий запит"));
request.destroy();
}
});
request.on("end", () => {
try {
resolve(JSON.parse(data || "{}"));
} catch {
reject(new Error("Некоректний JSON"));
}
});
request.on("error", reject);
});
}
function fingerprint(payload) {
// У прикладі поля сортуються, щоб порядок ключів JSON не мав значення.
const normalized = JSON.stringify({
amount: payload.amount,
currency: payload.currency,
orderId: payload.orderId
});
return crypto
.createHash("sha256")
.update(normalized)
.digest("hex");
}
function createPayment(payload) {
const payment = {
id: crypto.randomUUID(),
orderId: payload.orderId,
amount: payload.amount,
currency: payload.currency,
status: "succeeded"
};
payments.set(payment.id, payment);
return payment;
}
async function handlePayment(request, response) {
const key = request.headers["idempotency-key"];
if (typeof key !== "string" || key.length === 0 || key.length > 255) {
return sendJson(response, 400, {
error: "Заголовок Idempotency-Key є обов'язковим"
});
}
let payload;
try {
payload = await readBody(request);
} catch (error) {
return sendJson(response, 400, { error: error.message });
}
if (
typeof payload.orderId !== "string" ||
!Number.isInteger(payload.amount) ||
payload.amount <= 0 ||
typeof payload.currency !== "string"
) {
return sendJson(response, 400, {
error: "Некоректні параметри платежу"
});
}
const requestFingerprint = fingerprint(payload);
const existing = idempotency.get(key);
if (existing) {
if (existing.fingerprint !== requestFingerprint) {
return sendJson(response, 409, {
error: "Цей ключ уже використано з іншими параметрами"
});
}
if (existing.status === "completed") {
return sendJson(
response,
existing.responseStatus,
existing.responseBody
);
}
// Інший запит із тим самим ключем уже виконує операцію.
const result = await existing.promise;
return sendJson(response, result.status, result.body);
}
const record = {
fingerprint: requestFingerprint,
status: "pending"
};
// Promise створюється до початку асинхронної операції.
// Наступний повтор зможе дочекатися саме цього результату.
record.promise = new Promise(resolve => {
setTimeout(() => {
const payment = createPayment(payload);
const result = {
status: 201,
body: { payment }
};
record.status = "completed";
record.responseStatus = result.status;
record.responseBody = result.body;
resolve(result);
}, 100);
});
idempotency.set(key, record);
const result = await record.promise;
return sendJson(response, result.status, result.body);
}
const server = http.createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/payments") {
return handlePayment(request, response);
}
sendJson(response, 404, { error: "Маршрут не знайдено" });
});
server.listen(3000, () => {
console.log("Сервер запущено на http://localhost:3000");
});При повторенні запиту з тим самим ключем сервер поверне той самий ідентифікатор платежу:
curl -X POST http://localhost:3000/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: payment-operation-1' \
-d '{"orderId":"order-123","amount":2500,"currency":"UAH"}'Повторний виклик із тим самим Idempotency-Key не створить новий платіж.
Ідемпотентні записи не обов’язково зберігати назавжди. Для них можна встановити TTL, наприклад кілька годин або днів.
Тривалість залежить від домену:
для швидкого API-запиту може бути достатньо кількох годин;
для платежу або замовлення термін має покривати можливі повтори після мережевих і сервісних збоїв;
для бізнес-операцій із юридичними або фінансовими наслідками запис часто зберігають довше.
Після завершення TTL повторний запит із тим самим ключем може бути сприйнятий як нова операція. Тому клієнт повинен знати правила життя ключа.
Очищення старих записів не повинно видаляти ключ, поки операція ще може повторюватися або поки її результат не можна безпечно відновити з основної бази даних.
Потрібно визначити, що саме означає помилка для ідемпотентного запису.
Після успішної бізнес-операції зберігають результат:
status = completed
response_status = 201
response_body = ...Усі наступні повтори повертають цей результат.
Якщо параметри некоректні, операцію зазвичай не потрібно резервувати надовго. Клієнт може виправити дані та повторити запит із новим ключем.
Наприклад, база даних тимчасово недоступна. Тут важливо не створити ситуацію, коли сервер повідомив про помилку, хоча бізнес-операція вже відбулася.
Можливі стани:
операція точно не виконана — ключ можна безпечно повторити;
операція точно виконана — потрібно повернути збережений результат;
результат невідомий — не можна автоматично створювати нову операцію без перевірки.
Стан «невідомо» особливо важливий для платежів. Якщо клієнт не отримав відповідь, це не доводить, що платіж не відбувся.
Брокери повідомлень часто гарантують доставку щонайменше один раз. Це означає:
повідомлення не повинно загубитися після підтвердження;
те саме повідомлення може бути доставлено повторно;
споживач повинен бути готовим до дублювання.
Приклад повідомлення:
{
"messageId": "message-789",
"type": "OrderPaid",
"orderId": "order-123",
"amount": 2500
}Обробник не повинен просто виконувати дію при кожній доставці:
отримати повідомлення
нарахувати бонуси
підтвердити повідомленняЯкщо процес упаде після нарахування бонусів, але до підтвердження, брокер доставить повідомлення повторно. Бонуси будуть нараховані двічі.
Поширений підхід — зберігати ідентифікатори вже оброблених повідомлень у таблиці inbox.
Логіка обробника:
почати транзакцію
спробувати вставити message_id в inbox
якщо message_id уже існує:
зафіксувати транзакцію
підтвердити повідомлення
завершити
виконати бізнес-зміну
зберегти результат
зафіксувати транзакцію
підтвердити повідомленняКритично важливо, щоб вставка в inbox і бізнес-зміна виконувалися в одній транзакції.
Якщо спочатку позначити повідомлення обробленим, а потім упасти до зміни бізнес-даних, повтор буде проігнорований і дані залишаться неправильними.
Якщо спочатку змінити бізнес-дані, а потім вставити запис в inbox, повтор може виконати бізнес-зміну вдруге.
Концептуальна схема:
CREATE TABLE inbox_messages (
message_id TEXT PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);Обробник може використовувати унікальність message_id як атомарний захист від повтору:
BEGIN;
INSERT INTO inbox_messages (message_id, processed_at)
VALUES (?, current_time);
-- Якщо вставка порушила PRIMARY KEY,
-- повідомлення вже було оброблено.
UPDATE customer_rewards
SET points = points + ?
WHERE customer_id = ?;
COMMIT;
ACK повідомлення;Підтверджувати повідомлення потрібно лише після успішного COMMIT. Якщо транзакція завершилася помилкою, повідомлення не підтверджують, щоб брокер міг доставити його повторно.
Технічна перевірка messageId або Idempotency-Key не завжди достатня. Іноді потрібно захищати бізнес-правило.
Наприклад, для події оплати можна додатково гарантувати:
замовлення не переходить у стан paid двічі;
один платіжний ідентифікатор не застосовується до двох замовлень;
бонус за конкретну оплату нараховується лише один раз.
Для цього застосовують:
унікальні обмеження в базі даних;
перевірки станів у транзакції;
окремі бізнес-ідентифікатори;
атомарні операції оновлення.
Ідентифікатор повідомлення відповідає на запитання:
Чи обробляли ми саме це повідомлення?
А бізнес-ідентифікатор відповідає на запитання:
Чи виконували ми цю бізнес-операцію раніше?
Це можуть бути різні значення. Наприклад, два різні повідомлення про одну й ту саму оплату не повинні призвести до подвійного нарахування.
Транзакція бази даних не охоплює зовнішні системи автоматично.
Наприклад, обробник може:
змінити дані в базі;
надіслати HTTP-запит до сервісу листів;
підтвердити повідомлення.
Якщо процес упаде між кроками 2 і 3, повтор може знову надіслати лист. Тому зовнішня операція також повинна мати власний ідемпотентний ключ.
ключ листа = orderId + ":payment-confirmation"Сервіс листів повинен розпізнавати повторне використання цього ключа й не створювати дубль.
Якщо зовнішній сервіс не підтримує ідемпотентність, неможливо надійно гарантувати відсутність дублювання лише на стороні викликача. У такому випадку потрібно явно прийняти модель «можлива повторна дія» або змінити інтеграційний контракт.
Перед реалізацією варто відповісти на такі запитання:
Що є однією логічною операцією?
Хто генерує її ідентифікатор?
Де зберігається ідемпотентний запис?
Як система перевіряє однаковість параметрів?
Що відбувається при двох одночасних запитах?
Який результат повертається при повторі?
Скільки часу зберігається ключ?
Як обробляється стан «результат невідомий»?
Чи виконуються бізнес-зміна та дедуплікація атомарно?
Чи мають зовнішні виклики власні ідемпотентні ключі?
Якщо сервер на повторному запиті знову генерує ідентифікатор і створює запис, ідемпотентність не реалізована.
Потрібно зберігати результат першого виконання та повертати його повторно.
Запису key -> processed недостатньо. Після повтору клієнту потрібно повернути результат або хоча б однозначну інформацію про створений ресурс.
Окремі операції «перевірити» та «вставити» створюють race condition. Потрібна унікальність у сховищі або інша атомарна операція.
Той самий ключ із різними параметрами потрібно відхиляти, а не повертати випадковий результат.
Якщо повідомлення підтверджено до завершення бізнес-транзакції, падіння процесу може призвести до втрати повідомлення.
Якщо inbox і основна зміна зберігаються незалежно, між ними може виникнути неузгодженість. Для надійної дедуплікації їх потрібно виконувати атомарно або мати механізм відновлення.
Помилка мережі може означати як невиконану, так і вже виконану операцію. Потрібно розрізняти відомий результат і невідомий результат.
Ідемпотентність дозволяє безпечно повторювати одну логічну операцію.
Для POST часто використовують заголовок Idempotency-Key.
Ключ потрібно пов’язувати з клієнтом і відбитком параметрів запиту.
Результат першого виконання зберігають і повертають для наступних повторів.
Паралельні запити повинні проходити через атомарне резервування ключа.
Обробники повідомлень із доставкою щонайменше один раз мають дедуплікувати повідомлення.
Вставку messageId і бізнес-зміну потрібно виконувати в одній транзакції.
Підтвердження повідомлення надсилають лише після успішного завершення транзакції.
Ідемпотентність зовнішніх побічних ефектів також має бути частиною контракту інтеграції.