Пошук уроків, статей та іншого контенту
Чому подвійний клік на кнопку «Оплатити» небезпечний без idempotency — і як ключі ідемпотентності це вирішують.
Подвійний клік на кнопку «Оплатити» або повторний HTTP-запит після тайм-ауту може створити дві однакові операції: два платежі, два замовлення чи два списання коштів.
Причина часто не в помилці користувача. Клієнт може не отримати відповідь через нестабільне з’єднання і повторити запит, хоча сервер уже встиг його обробити. Саме для таких ситуацій використовують ідемпотентність.
Операція є ідемпотентною, якщо її повторне виконання має той самий ефект, що й одноразове виконання.
У формальному вигляді:
f(f(x)) = f(x)Для API це означає: якщо клієнт кілька разів надсилає той самий запит із тим самим ідентифікатором операції, сервер не повинен створювати додатковий побічний ефект.
Наприклад, клієнт надсилає запит на оплату:
POST /payments
Idempotency-Key: 6f2c4d8e-9c77-4d92-a9b3-2d0f9a1c8a11
Content-Type: application/json
{
"orderId": "order-123",
"amount": 49900,
"currency": "UAH"
}Якщо цей запит буде повторено з тим самим значенням Idempotency-Key, сервер має повернути результат уже створеної операції, а не виконати оплату ще раз.
HTTP-клієнт не завжди знає, чи сервер виконав запит.
Розглянемо послідовність:
Користувач натискає «Оплатити».
Клієнт надсилає POST /payments.
Сервер списує кошти.
Сервер формує відповідь.
Мережеве з’єднання розривається до того, як відповідь потрапляє до клієнта.
Клієнт вважає запит невдалим і повторює його.
Без додаткового захисту сервер побачить два звичайні POST-запити й може двічі списати кошти.
Проблема може виникнути і в інших випадках:
користувач двічі натиснув кнопку;
браузер повторив запит;
мобільний клієнт автоматично виконав retry;
проксі або балансувальник повторив запит;
клієнт отримав 502, 504 або тайм-аут;
фоновий воркер повторно взяв повідомлення з черги;
сервер завершив операцію, але не встиг зберегти або повернути відповідь.
Для читання даних повторний запит зазвичай безпечний. Для операцій, які змінюють стан, це може бути критично.
Деякі HTTP-методи за своєю семантикою є ідемпотентними:
GET — отримання ресурсу;
HEAD — отримання заголовків;
PUT — повна заміна ресурсу;
DELETE — видалення ресурсу;
OPTIONS;
TRACE.
Наприклад, повторний DELETE /users/42 не повинен видаляти щось додатково після першого видалення.
Проте ідемпотентність методу не означає, що кожна реалізація автоматично безпечна. Наприклад, DELETE може викликати додаткову неідемпотентну логіку, якщо сервер щоразу створює окремий побічний запис.
Метод POST зазвичай не є ідемпотентним:
POST /ordersКожен запит може створити нове замовлення. Однак сервер може додати до POST підтримку ключів ідемпотентності, зробивши конкретну операцію повторюваною без дублювання результату.
Idempotency-Key — це унікальний ідентифікатор логічної операції, який клієнт надсилає разом із запитом.
Приклад:
POST /orders
Idempotency-Key: 8b6e0d3b-0d4e-4e0d-8c2e-2c9f8a2a4b19
Content-Type: application/jsonКлюч має бути:
унікальним для кожної нової операції;
стабільним під час повторних спроб тієї самої операції;
достатньо випадковим, наприклад UUID;
створеним до першої спроби запиту;
збереженим клієнтом до завершення операції.
Важливо розрізняти дві ситуації:
Клієнт генерує новий ключ:
Idempotency-Key: key-AСервер обробляє запит як нову операцію.
Клієнт повторює запит із тим самим ключем:
Idempotency-Key: key-AСервер повертає попередній результат.
Клієнт використовує інший ключ:
Idempotency-Key: key-BСервер обробляє це як нову операцію, навіть якщо тіло запиту збігається з попереднім.
Отже, idempotency key — це не ідентифікатор ресурсу. Це ідентифікатор спроби виконати певну логічну операцію.
Типовий алгоритм має такий вигляд:
Отримати Idempotency-Key із заголовка.
Перевірити, чи існує запис із таким ключем.
Якщо запис уже завершений — повернути збережені статус і відповідь.
Якщо запис обробляється — не запускати операцію вдруге.
Якщо запису немає — атомарно зареєструвати ключ.
Виконати побічну операцію.
Зберегти результат.
Повернути відповідь клієнту.
Спрощений приклад middleware для Node.js:
async function idempotencyMiddleware(req, res, next) {
const key = req.header("Idempotency-Key");
if (!key) {
return res.status(400).json({
error: "Idempotency-Key є обов'язковим"
});
}
const requestHash = hashRequest(req);
const existing = await idempotencyStore.find(key);
if (existing) {
if (existing.requestHash !== requestHash) {
return res.status(409).json({
error: "Цей ключ уже використано з іншими параметрами"
});
}
if (existing.status === "completed") {
res.status(existing.responseStatus);
return res.json(existing.responseBody);
}
if (existing.status === "processing") {
return res.status(409).json({
error: "Операція ще виконується"
});
}
}
const created = await idempotencyStore.createIfAbsent({
key,
requestHash,
status: "processing"
});
if (!created) {
return res.status(409).json({
error: "Запит уже обробляється"
});
}
try {
const result = await createPayment(req.body);
await idempotencyStore.complete(key, {
responseStatus: 201,
responseBody: result
});
return res.status(201).json(result);
} catch (error) {
await idempotencyStore.markFailed(key, {
responseStatus: 500,
responseBody: {
error: "Не вдалося виконати операцію"
}
});
return res.status(500).json({
error: "Не вдалося виконати операцію"
});
}
}Це спрощена модель. У production-системі важливо також вирішити питання транзакцій, тайм-аутів, завислих записів і конкурентних запитів.
Наївна реалізація може виглядати так:
якщо ключ існує:
повернути старий результат
інакше:
створити запис
виконати операціюВона має race condition.
Два однакові запити можуть прийти майже одночасно:
Запит A: перевіряє ключ — ключа немає
Запит B: перевіряє ключ — ключа немає
Запит A: починає оплату
Запит B: починає оплатуУ результаті обидва запити виконають побічну операцію.
Перевірка і створення ключа мають бути атомарними. Для цього зазвичай використовують:
унікальний індекс у базі даних;
операцію INSERT ... ON CONFLICT;
атомарну команду в Redis;
розподілений lock;
транзакцію з відповідним рівнем ізоляції.
Наприклад, у таблиці може бути унікальне обмеження:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL,
response_status INTEGER,
response_body JSONB,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);Поле PRIMARY KEY не дозволить двом процесам успішно створити один і той самий ключ.
Найнадійніший варіант — зберігати:
ключ ідемпотентності;
хеш параметрів запиту;
статус обробки;
HTTP-статус відповіді;
тіло відповіді;
час створення;
час завершення;
за потреби — ідентифікатор створеного ресурсу або платежу.
Тоді повторний запит може отримати точно ту саму відповідь, яку сервер повернув під час першої обробки.
Іноді зберігають лише ідентифікатор ресурсу:
Idempotency-Key -> paymentIdА під час повтору заново формують відповідь через paymentId. Це теж може працювати, але відповідь не обов’язково буде побітово ідентичною попередній. Крім того, ресурс може бути видалений або змінений.
Для платіжних і фінансових операцій зазвичай корисно зберігати результат операції та її фінальний статус.
Один ключ не можна безконтрольно використовувати для різних операцій.
Наприклад, спочатку клієнт надіслав:
{
"orderId": "order-123",
"amount": 49900
}А потім повторив запит із тим самим ключем:
{
"orderId": "order-123",
"amount": 99900
}Сервер має виявити конфлікт і відхилити запит, наприклад із кодом 409 Conflict або іншим узгодженим кодом помилки.
Для цього сервер зберігає хеш значущих параметрів запиту:
hash(method + path + normalized_body)Під час повтору:
хеш збігається — це повтор тієї самої операції;
хеш відрізняється — ключ використано повторно неправильно.
Не варто хешувати поля, які природно змінюються між запитами, наприклад службові часові мітки або випадкові значення, якщо вони не є частиною бізнес-операції.
Зручно явно моделювати життєвий цикл операції.
processingЗапит прийнято, але операція ще виконується.
Якщо в цей момент приходить повторний запит, сервер може:
повернути 409 Conflict;
повернути 202 Accepted;
зачекати обмежений час і повернути результат;
повернути поточний статус операції.
Вибір залежить від контракту API.
completedОперація успішно завершилася. Повторний запит має отримати збережений результат.
failedОперація завершилася помилкою. Тут важливо визначити політику:
завжди повертати ту саму помилку;
дозволити повтор після тимчасової помилки;
позначити ключ як остаточно невдалий;
створити нову спробу з новим ключем.
Для помилки валідації зазвичай немає сенсу дозволяти повторювати той самий ключ із тими самими неправильними параметрами.
Для тимчасової помилки зовнішнього сервісу ситуація складніша: сервер може ще не знати, чи зовнішня операція фактично відбулася.
Збереження ключа у власній базі даних саме по собі не гарантує захист від дублювання в зовнішній системі.
Наприклад:
Ваш сервер створив запис processing.
Надіслав запит платіжному провайдеру.
Провайдер списав кошти.
Відповідь загубилася.
Ваш сервер вирішив, що сталася помилка.
Повторив запит до провайдера.
Якщо зовнішня система підтримує idempotency keys, потрібно передавати їй той самий стабільний ключ:
Ваш API: client-key-123
Платіжний сервіс: client-key-123Або використовувати окремий, але постійний ключ, пов’язаний із тією самою операцією.
Якщо зовнішній сервіс не підтримує ідемпотентність, можуть знадобитися:
унікальний ідентифікатор платежу;
перевірка статусу операції перед повторною спробою;
журнал інтеграційних викликів;
reconciliation-процес;
ручна або автоматична звірка платежів.
У розподіленій системі неможливо зробити довільну багатокрокову операцію абсолютно атомарною лише за допомогою локальної транзакції.
Це важливе уточнення.
Мережеві системи зазвичай працюють за моделлю at-least-once delivery: повідомлення або запит можуть бути доставлені повторно.
Idempotency потрібна для того, щоб повторна доставка не створювала повторний бізнес-ефект.
Це не завжди означає, що код усередині сервера фізично виконається лише один раз. Наприклад, процес може впасти після виклику зовнішнього сервісу, але до збереження локального результату.
Тому коректніше говорити:
одна логічна операція повинна мати один фінальний бізнес-результат, навіть якщо запит або окремі технічні кроки повторюються.
Ключі не обов’язково зберігати назавжди. Для них встановлюють TTL або політику очищення.
Тривалість залежить від операції:
для швидкої команди — хвилини або години;
для платежу — довше, з урахуванням можливих повторів і звірок;
для асинхронного замовлення — до завершення всього бізнес-процесу.
Надто короткий TTL небезпечний:
клієнт повторює запит після завершення TTL;
старий ключ уже видалений;
сервер сприймає запит як нову операцію;
виникає дублювання.
Надто довгий TTL збільшує обсяг сховища. Тому строк зберігання має відповідати реальному періоду, протягом якого можливі retries.
Переваги:
унікальні обмеження;
транзакції;
надійне довготривале зберігання;
зручне збереження відповіді та статусу.
Це часто найкращий варіант для платежів, замовлень і фінансових операцій.
Переваги:
швидка перевірка;
TTL із коробки;
зручні атомарні операції.
Недоліки:
потрібно правильно налаштувати довговічність;
втрата даних може зруйнувати захист від дублювання;
Redis не завжди має бути єдиним джерелом істини для критичних платежів.
Підходить лише для демонстрацій або дуже простих сценаріїв.
У production це ненадійно, тому що:
дані зникають після перезапуску;
різні інстанси сервісу мають різні набори ключів;
масштабування створює різні результати для однакових запитів.
Idempotency key — не єдиний захисний механізм.
У базі даних також мають бути бізнесові обмеження. Наприклад, якщо замовлення можна оплатити лише один раз, це правило повинно бути виражене в моделі даних і коді:
orderId -> не більше одного успішного платежуКорисними можуть бути:
унікальний індекс на order_id для успішних платежів;
перевірка поточного статусу замовлення;
атомарне оновлення стану;
журнал переходів статусів;
обмеження на повторну обробку події.
Найнадійніший захист зазвичай багаторівневий:
ключ ідемпотентності на API;
унікальне обмеження в базі;
ідемпотентний виклик зовнішнього сервісу;
контроль бізнес-стану;
аудит і звірка результатів.
Клієнтська частина також має дотримуватися правил:
генерувати ключ до першого запиту;
використовувати той самий ключ під час retry;
створювати новий ключ для нової операції;
не змінювати бізнес-параметри під час повтору;
зберігати ключ достатньо довго;
не вважати тайм-аут доказом, що операція не відбулася.
Наприклад, після тайм-ауту клієнт не повинен одразу створювати новий ключ. Спочатку варто повторити запит із попереднім ключем або перевірити статус операції.
Для довгих операцій API часто розділяє створення операції та перевірку її стану:
POST /payments
Idempotency-Key: payment-attempt-123Відповідь:
202 Accepted
Content-Type: application/json
{
"paymentId": "payment-456",
"status": "processing"
}Після цього клієнт перевіряє стан:
GET /payments/payment-456Такий підхід зручний для операцій, які виконуються асинхронно, але він не скасовує необхідності ідемпотентності під час створення платежу.
Це перетворює повторну спробу на нову операцію. Сервер не може зрозуміти, що запити пов’язані.
Ключ має ідентифікувати одну операцію, а не користувача, сесію чи весь кошик.
Перевірка «знайти, потім вставити» без атомарності не захищає від конкурентних запитів. Потрібні унікальні обмеження або атомарні операції.
Той самий ключ із іншою сумою або іншим orderId має бути відхилений.
Клієнт може повторити запит через кілька секунд або хвилин. Якщо ключ уже видалено, операція може виконатися повторно.
Після масштабування запити з одним ключем можуть потрапити на різні інстанси.
500 доказом невиконання операціїСервер міг виконати побічну дію перед виникненням помилки. У разі невизначеного результату потрібно перевіряти стан, а не сліпо створювати нову операцію.
Такий підхід корисний для будь-яких дорогих або незворотних дій:
створення замовлення;
резервування товару;
видача бонусів;
відправлення листа;
створення підписки;
запуск фонової задачі;
списання коштів;
відправлення вебхука.
Ідемпотентність гарантує, що повторення тієї самої логічної операції не створить додатковий побічний ефект.
Вона особливо важлива для POST-запитів, які створюють замовлення, платежі або інші ресурси.
Idempotency-Key має залишатися незмінним під час retry однієї операції.
Нову бізнес-операцію потрібно надсилати з новим ключем.
Сервер має атомарно реєструвати ключ і зберігати результат операції.
Повторний ключ з іншими параметрами потрібно відхиляти.
Для захисту від race condition потрібні унікальні обмеження або інші атомарні механізми.
Idempotency не робить розподілену систему абсолютно «одноразовою», але перетворює повторні доставки на безпечні повтори однієї операції.
Для платежів важливо підтримувати ідемпотентність не лише у власному API, а й у викликах зовнішнього платіжного провайдера.