Пошук уроків, статей та іншого контенту
Розберете ідемпотентність HTTP-операцій і реалізуєте безпечне повторення запитів та захист від дублювання операцій.
Операція є ідемпотентною, якщо її повторне виконання приводить систему до того самого стану, що й одне виконання.
Формально для операції f:
f(f(state)) = f(state)Це не означає, що повторні HTTP-відповіді обов’язково будуть однаковими. Наприклад, другий GET може повернути нове представлення ресурсу. Ідемпотентність стосується передусім побічного ефекту операції.
Приклади:
встановити статус замовлення в paid — ідемпотентно;
збільшити баланс на 100 гривень — неілемпотентно;
створити платіж — зазвичай неілемпотентно без додаткового механізму;
додати новий елемент до списку — зазвичай неілемпотентно.
HTTP визначає очікувану семантику методів:
GET — ідемпотентний і безпечний;
HEAD — ідемпотентний і безпечний;
PUT — ідемпотентний;
DELETE — ідемпотентний;
POST — не гарантує ідемпотентність;
PATCH — залежить від реалізації.
PUT як ідемпотентна операціяЗапит:
PUT /users/42/profile
Content-Type: application/json
{
"displayName": "Олена"
}встановлює конкретне представлення ресурсу. Повторення цього запиту не повинно створювати ще один профіль або ще одну зміну.
POST і повторне створенняЗапит:
POST /payments
Content-Type: application/json
{
"amount": 1000,
"currency": "UAH"
}може створити платіж. Якщо клієнт не отримав відповідь через обрив мережі, він не знає, чи платіж:
не був створений;
був створений, але відповідь загубилася;
все ще обробляється.
Повторний POST без захисту може створити другий платіж.
Клієнти та проксі повторюють запити через:
тайм-аут;
тимчасову помилку мережі;
розрив TCP-з'єднання;
помилку балансувальника;
відповідь 502, 503 або 504;
тимчасову недоступність залежного сервісу.
Ключова проблема — невизначений результат. Якщо клієнт отримав тайм-аут, сервер міг уже виконати операцію.
Тому для неілемпотентних операцій не можна просто безумовно робити:
for (let attempt = 0; attempt < 3; attempt += 1) {
await sendRequest();
}Спочатку потрібно забезпечити ідемпотентність самої операції.
Для POST часто використовують заголовок Idempotency-Key:
POST /payments
Idempotency-Key: 6f8c3e1d-21c7-4c1e-8f55-2f18c8c43491
Content-Type: application/json
{
"amount": 1000,
"currency": "UAH"
}Клієнт генерує ключ один раз для однієї логічної операції та використовує його в усіх повторах.
Сервер зберігає:
ключ;
відбиток запиту;
стан операції;
результат;
HTTP-статус і відповідь.
Алгоритм:
Отримати Idempotency-Key.
Обчислити відбиток тіла запиту.
Перевірити, чи існує ключ.
Якщо ключа немає — зареєструвати операцію та виконати її.
Якщо ключ є і тіло таке саме — повернути збережений результат.
Якщо ключ є, але тіло інше — відхилити запит.
Якщо така сама операція ще виконується — дочекатися її результату.
Самого ключа недостатньо. Клієнт може помилково повторно використати ключ для іншої операції:
Idempotency-Key: same-keyСпочатку:
{
"amount": 1000,
"currency": "UAH"
}Пізніше:
{
"amount": 5000,
"currency": "UAH"
}Сервер повинен відхилити другий запит, зазвичай зі статусом 409 Conflict.
Відбиток можна обчислити за нормалізованим тілом або за його канонічним JSON-представленням. У простій реалізації можна хешувати отриманий JSON-текст. У production-системі важливо, щоб однакові за змістом запити мали однакове канонічне представлення.
Нижче наведено приклад HTTP-сервера без зовнішніх залежностей. Він демонструє:
обов'язковий Idempotency-Key;
перевірку повторного використання ключа з іншим тілом;
захист від одночасних дубльованих запитів;
повторне повернення збереженої відповіді.
'use strict';
const http = require('node:http');
const crypto = require('node:crypto');
const PORT = 3000;
// У production ці дані повинні зберігатися в надійному сховищі.
const idempotencyRecords = new Map();
const payments = new Map();
function sendJson(response, statusCode, body, headers = {}) {
const payload = JSON.stringify(body);
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
...headers
});
response.end(payload);
}
function readRequestBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const maxSize = 1024 * 1024;
request.on('data', (chunk) => {
size += chunk.length;
if (size > maxSize) {
reject(new Error('Тіло запиту завелике'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
request.on('error', reject);
});
}
function fingerprint(body) {
return crypto
.createHash('sha256')
.update(body)
.digest('hex');
}
function validatePayment(payment) {
if (
!payment ||
!Number.isInteger(payment.amount) ||
payment.amount <= 0 ||
typeof payment.currency !== 'string' ||
payment.currency.length !== 3
) {
return false;
}
return true;
}
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function executePayment(payment) {
// Імітуємо звернення до платіжного провайдера.
await delay(500);
const paymentId = crypto.randomUUID();
const result = {
id: paymentId,
amount: payment.amount,
currency: payment.currency,
status: 'created'
};
payments.set(paymentId, result);
return {
statusCode: 201,
body: result
};
}
async function handleCreatePayment(request, response) {
const key = request.headers['idempotency-key'];
if (typeof key !== 'string' || key.length < 16 || key.length > 255) {
sendJson(response, 400, {
error: 'Заголовок Idempotency-Key є обов’язковим'
});
return;
}
const rawBody = await readRequestBody(request);
let payment;
try {
payment = JSON.parse(rawBody);
} catch {
sendJson(response, 400, {
error: 'Тіло запиту має бути коректним JSON'
});
return;
}
if (!validatePayment(payment)) {
sendJson(response, 422, {
error: 'Некоректні дані платежу'
});
return;
}
const requestFingerprint = fingerprint(rawBody);
const existingRecord = idempotencyRecords.get(key);
if (existingRecord) {
if (existingRecord.fingerprint !== requestFingerprint) {
sendJson(response, 409, {
error: 'Idempotency-Key уже використано з іншим запитом'
});
return;
}
try {
const savedResult = await existingRecord.resultPromise;
sendJson(
response,
savedResult.statusCode,
savedResult.body,
{
'Idempotency-Replayed': 'true'
}
);
} catch {
sendJson(response, 500, {
error: 'Не вдалося завершити ідемпотентну операцію'
});
}
return;
}
// Promise реєструється до початку асинхронної роботи.
// Тому паралельний запит побачить уже наявну операцію.
const resultPromise = executePayment(payment);
idempotencyRecords.set(key, {
fingerprint: requestFingerprint,
resultPromise
});
try {
const result = await resultPromise;
sendJson(response, result.statusCode, result.body, {
'Idempotency-Replayed': 'false'
});
} catch (error) {
// Видаляємо запис лише для невдалої операції.
// У production-проєкті це рішення залежить від гарантій
// зовнішньої платіжної системи.
idempotencyRecords.delete(key);
sendJson(response, 500, {
error: 'Помилка під час створення платежу'
});
}
}
const server = http.createServer(async (request, response) => {
if (request.method === 'POST' && request.url === '/payments') {
try {
await handleCreatePayment(request, response);
} catch {
sendJson(response, 500, {
error: 'Внутрішня помилка сервера'
});
}
return;
}
sendJson(response, 404, {
error: 'Маршрут не знайдено'
});
});
server.listen(PORT, () => {
console.log(`Сервер слухає http://localhost:${PORT}`);
});Запустити сервер:
node server.jsНадіслати перший запит:
curl -i \
-X POST http://localhost:3000/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 6f8c3e1d-21c7-4c1e-8f55-2f18c8c43491' \
-d '{"amount":1000,"currency":"UAH"}'Повторити той самий запит:
curl -i \
-X POST http://localhost:3000/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 6f8c3e1d-21c7-4c1e-8f55-2f18c8c43491' \
-d '{"amount":1000,"currency":"UAH"}'Другий запит отримає той самий ідентифікатор платежу та заголовок:
Idempotency-Replayed: trueСпроба використати той самий ключ з іншим тілом:
curl -i \
-X POST http://localhost:3000/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 6f8c3e1d-21c7-4c1e-8f55-2f18c8c43491' \
-d '{"amount":5000,"currency":"UAH"}'поверне 409 Conflict.
Два однакові запити можуть прийти майже одночасно. Небезпечна реалізація виглядає так:
if (!records.has(key)) {
const result = await executeOperation();
records.set(key, result);
}Між перевіркою records.has(key) і records.set(key, result) є асинхронна пауза. Другий запит може пройти ту саму перевірку та запустити операцію повторно.
Безпечніший підхід:
атомарно зареєструвати ключ;
зберегти ознаку виконання або Promise;
усі наступні запити прив'язати до тієї самої операції.
У прикладі запис додається в Map до await executePayment(). Тому паралельні запити очікують той самий resultPromise.
У розподіленій системі Map не забезпечує атомарність між процесами. Для цього потрібні механізми сховища, наприклад:
унікальний індекс на ідемпотентному ключі в базі даних;
транзакція з блокуванням або операція INSERT ... ON CONFLICT;
атомарна команда в Redis;
окреме сховище станів операцій.
Перевірка та створення запису мають бути однією атомарною операцією. Окремі запити SELECT, а потім INSERT створюють race condition.
Мінімальний запис ідемпотентності може містити:
scope
idempotency_key
request_fingerprint
status
response_status
response_headers
response_body
created_at
expires_atscope потрібен, якщо один ключ може бути унікальним лише в межах:
користувача;
API-клієнта;
типу операції;
облікового запису.
Наприклад, ключ abc для користувача user-1 не повинен конфліктувати з ключем abc для user-2, якщо правила системи дозволяють таке використання.
Запис може мати такі стани:
processing — операція виконується;
completed — результат збережено;
failed — операція завершилася помилкою;
expired — запис більше не використовується.
Для стану processing сервер може:
дочекатися завершення першої операції;
повернути 409 Conflict;
повернути 202 Accepted, якщо операція асинхронна.
Важливо мати політику для завислих записів. Якщо процес завершився під час processing, запис не повинен блокувати ключ назавжди. Водночас автоматично запускати операцію повторно небезпечно: зовнішня система могла виконати її до аварії.
Клієнт повинен генерувати ключ до першої спроби та не змінювати його під час повторів.
Псевдокод:
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const response = await sendPayment({
idempotencyKey,
amount: 1000,
currency: 'UAH'
});
if (response.ok) {
return response;
}
if (response.status === 409) {
throw new Error('Конфлікт ідемпотентного ключа');
}
if (response.status >= 400 && response.status < 500) {
throw new Error('Помилка в даних запиту');
}
} catch (error) {
if (attempt === 2) {
throw error;
}
await waitWithBackoff(attempt);
}
}Практичні правила:
не створювати новий ключ для кожної спроби;
обмежувати кількість повторів;
використовувати backoff;
додавати випадкову затримку, щоб багато клієнтів не повторювали запити одночасно;
не повторювати безумовно всі помилки 4xx;
пам'ятати, що тайм-аут не означає, що операція не виконалася.
Ідемпотентний сервер зазвичай зберігає не лише успішні відповіді. Якщо перша операція завершилася визначеною помилкою, повторний запит з тим самим ключем може отримати ту саму помилку.
Наприклад, якщо платіж відхилено через недостатній баланс, повторення того самого логічного запиту не повинно щоразу створювати нову спробу списання.
Однак для тимчасових помилок політика може бути іншою. Потрібно розрізняти:
операція точно не розпочалася;
операція не виконалася;
результат операції невідомий;
операція виконалася, але відповідь не була доставлена.
Найнебезпечніший випадок — останній. Саме для нього потрібне збереження результату за ідемпотентним ключем.
Ідемпотентний ключ захищає конкретну логічну операцію, але не вирішує всі проблеми:
він не гарантує доставку запиту;
він не замінює транзакцію;
він не робить зовнішній платіжний провайдер ідемпотентним;
він не захищає після видалення запису;
він не працює між процесами без спільного сховища;
він не гарантує результат після аварії, якщо дані зберігалися лише в пам'яті.
Якщо операція змінює кілька систем, потрібно узгодити ідемпотентність на кожній межі. Наприклад, сервер може мати захист від дублювання, але платіжний провайдер також повинен отримувати стабільний ідемпотентний ідентифікатор транзакції.
У прикладі використано Map, тому всі записи зникають після перезапуску процесу. Це прийнятно лише для демонстрації.
У production потрібно визначити:
де зберігаються записи;
як довго вони живуть;
коли їх можна видаляти;
чи можна повторно використати ключ після завершення терміну дії.
Занадто короткий TTL небезпечний: клієнт може повторити запит після видалення запису та створити дубль.
Занадто довгий TTL збільшує обсяг сховища. Термін дії має відповідати бізнес-операції. Для фінансових операцій зазвичай потрібна довша історія або постійний унікальний ідентифікатор операції.
Ідемпотентний ключ не повинен бути передбачуваним. Краще використовувати криптографічно випадкові значення, наприклад UUID.
Також слід:
обмежити довжину ключа;
пов'язувати ключ з автентифікованим клієнтом або користувачем;
не дозволяти одному користувачу читати результат чужої операції;
не зберігати в ключі конфіденційні дані;
контролювати обсяг запитів і кількість унікальних ключів.
Ключ є ідентифікатором операції, але не повинен бути засобом автентифікації.
Неправильно:
спроба 1 → key-a
спроба 2 → key-b
спроба 3 → key-cТак сервер бачить три різні операції.
Правильно:
спроба 1 → key-a
спроба 2 → key-a
спроба 3 → key-aЯкщо ключ збігається, але параметри відрізняються, не можна повертати результат першої операції мовчки. Це може приховати помилку клієнта.
Якщо запис створюється лише після await, конкурентні запити можуть виконати операцію кілька разів.
Недостатньо зберегти тільки paymentId. Для коректного повтору можуть знадобитися:
HTTP-статус;
тіло відповіді;
важливі заголовки;
інформація про помилку.
Клієнт може повторити запит через кілька секунд або хвилин. Якщо запис уже видалено, сервер створить дубль.
500 доказом невиконанняСервер міг створити платіж, а помилка виникла під час формування відповіді. Безпечна стратегія — повторити запит із тим самим ключем і отримати збережений результат або перевірити стан операції.
У кількох екземплярах Node.js кожен процес має власну Map. Запити з однаковим ключем можуть потрапити до різних процесів і виконатися двічі.
Ідемпотентність означає, що повторення операції не створює додаткового небажаного ефекту.
GET, HEAD, PUT і DELETE мають ідемпотентну семантику, але реалізація все одно може порушити цю властивість.
POST для створення ресурсів потрібно захищати ідемпотентним ключем.
Один логічний запит має використовувати той самий Idempotency-Key у всіх повторах.
Сервер повинен порівнювати ключ і відбиток тіла запиту.
Для конкурентних запитів реєстрація операції має бути атомарною.
У production-розгортанні стан ідемпотентності потрібно зберігати у спільному надійному сховищі.
Ідемпотентність не є гарантією «виконати рівно один раз» у всіх системах. Вона забезпечує повторення без дублювання за умови правильної координації стану операції.