Головна / Статті / Розуміння ключів ідемпотентності у кінцевих точках POST Node.js

Розуміння ключів ідемпотентності у кінцевих точках POST Node.js

Пояснює, чому запити типу POST непередбачувано зазнають невдач під час повторних спроб, та як ключі ідемпотентності, створені клієнтом, дозволяють API Node.js безпечно обробляти дублікати запитів.

1357 слів

Ідемпотентність — це термін, який можна знайти в документації до API оплати, зазвичай разом із словниковим визначенням, яке всі лише швидко проглядають, не усвідомлюючи його суті. Нижче наведена спроба пояснити його через запитання, які розробники насправді ставлять, коли стикаються з ним у продакшн-коді, а не через абстрактну версію, яку можна знайти в підручнику.

Що насправді означає „ідемпотентний“, коли ви пишете код, а не читаєте глосарій?

Операція вважається ідемпотентною, коли її виконання один раз призводить до того самого кінцевого стану, що й її виконання п’ять разів поспіль з абсолютно такими самими вхідними даними. Візьмемо PUT /users/8/name із тілом { "name": "Jane" }: незалежно від того, чи викликаємо ми її один раз, чи п’ять, ім’я користувача завжди залишатиметься „Jane“, і нічого не накопичуватиметься. Порівняйте це з POST /orders, який призначений для створення нового замовлення — якщо викликати його п’ять разів, ймовірно, у результаті буде п’ять окремих замовлень, а не одне, оскільки ніщо у самій операції не заважає їх накопичуватися.

Чому саме у випадку запитів POST це стає такою важливою проблемою?

POST зазвичай є операцією, відповідальною за створення даних, і мережі мають особливий спосіб збоїв, який робить це небезпечним: запит може успішно виконатися на серверній стороні, тоді як клієнт ніколи про це не дізнається, оскільки сама відповідь губиться по дорозі назад. З точки зору клієнта він бачить лише тайм-аут. У нього немає способу дізнатися, чи справді замовлення було оброблене, тому він робить єдине розумне дію — намагається знову.

// the client's perspective, roughly
async function submitOrder(payload) {
  try {
    return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
  } catch {
    return submitOrder(payload); // did the first one actually fail, or just the response?
  }
}

Якщо кінцева точка /orders не розроблена так, щоб витримувати подібні спроби повторення, клієнт оплачує двічі за одну покупку — причому жодна зі сторін не є винною. За словами клієнта, запит справді зазнав невдачі. З точки зору сервера він справді був успішним.

То що потрібно, щоб кінцева точка POST у Node була справді ідемпотентною?

Традиційне рішення полягає у тому, що клієнт генерує один унікальний ключ для кожної логічної операції, додає його як заголовок та дозволяє серверу використовувати цей ключ для ідентифікації повторно надісланого запиту як того самого, який він вже обробив, а не як чогось нового.

app.post("/orders", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];
  if (!idempotencyKey) {
    return res.status(400).json({ error: "Idempotency-Key header required" });
  }
  const existing = await db.query(
    "SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
    [idempotencyKey]
  );
  if (existing) {
    return res.status(existing.status_code).json(JSON.parse(existing.response_body));
  }

  const order = await createOrder(req.body);
  await db.query(
    "INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
    [idempotencyKey, JSON.stringify(order), 201]
  );
  res.status(201).json(order);
});

Завданням клієнта є повторне використання цього самого ключа кожного разу, коли він надсилає той самий логічний запит — зазвичай це UUID, створений один раз безпосередньо перед першою спробою. Завдання сервера простіше: впізнати ключ, який він вже бачив, та повернути збережений результат замість того, щоб повторювати роботу.

Хто має генерувати ключ ідемпотентності — клієнт чи сервер?

Це має бути клієнт, і це дивує багатьох людей, тому що їхній інстинкт підказує інше. Якби ключ створював сервер, кожна спроба повторного надсилання приносила б новий ключ, що зробило б увесь механізм марним — у сервера не було б підстав відрізнити спробу повторного надсилання від нового запиту. Ключ має існувати ще до того, як буде надіслана перша спроба, саме для того, щоб можна було знову використати ту саму цінність, якщо потрібно буде повторити цю спробу.

А що, якщо два ідентичні запити надійдуть буквально в один і той самий момент, а не по черзі?

Це та частина, яку майже завжди помилково виконують під час першої спроби застосувати цю схему. Простий підхід „перевірити, потім вставити“, описаний раніше, містить у собі проблему конкурентного доступу: два запити з однаковим ключем можуть обидва виконати свою команду SELECT, обидва отримати порожні результати та обидва продовжити створення замовлення — що повністю суперечить початковій меті використання ключа.

// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
  const idempotencyKey = req.headers["idempotency-key"];  try {
    await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
  } catch (err) {
    if (err.code === "23505") { // unique constraint violation
      const existing = await db.query(
        "SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
        [idempotencyKey]
      );
      return res.status(existing.status_code).json(JSON.parse(existing.response_body));
    }
    throw err;
  }

  const order = await createOrder(req.body);
  await db.query(
    "UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
    [JSON.stringify(order), 201, idempotencyKey]
  );
  res.status(201).json(order);
});

Встановлення унікального обмеження для стовпця key переносить прийняття рішень з логіки вашого додатку на саму базу даних: коли зустрічаються два одночасні запити, база даних вирішує, який з них має перевагу, а інший отримує чітку, виявлювану помилку замість того, щоб непомітно пройти. Одного лише оператора if у обробнику маршруту недостатньо, щоб усунути цю проблему — такі питання конкурентності потрібно вирішувати на тому рівні, який фактично серіалізує доступ, а цим рівнем є база даних, а не умовна перевірка в вашому коді.

Чи має це значення також для запитів типу GET?

Не так, і це постійно створює плутанину у користувачів. GET за своєю суттю має бути ідемпотентним — він не повинен нічого змінювати, тому його можна вільно повторювати без жодних ризиків, не потребуючи спеціального оброблення. Паттерн idempotency-key існує саме для операцій, які створюють або змінюють стан, де необережне повторення може подвоїти ефект. Якщо endpoint GET не є безпечним для багаторазового виклику, справжня проблема полягає у тому, що він виконує побічні ефекти, які зовсім не повинен мати в рамках семантики GET.

Як довго має залишатися дійсним ключ ідемпотентності?

Ідеально — достатньо довго, щоб покрити реалістичні сценарії повторних спроб, але не настільки довго, щоб збережені ключі накопичувалися нескінченно. Багато платіжних платформ вибирають термін від 24 годин до кількох днів. Планова задача на очищення може потім видалити прострочені записи:

await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");

Якщо встановити занадто вузький проміжок часу, то повторна спроба, відкладена з законних причин — наприклад, телефон клієнта втрачає зв’язок на десять хвилин під час оформлення замовлення — може випасти за межі цього проміжку та спричинити справжнє дублювання. Якщо ж залишити проміжок відкритим назавжди, таблиця буде продовжувати зростати без жодної реальної користі.

Чи варто перейматися цією схемою лише у системах оплати?

Саме під час оплат люди зазвичай вперше дізнаються про цей урок, переважно тому, що подвійна стягнення — це саме та проблема, яка призводить до гнівного електронного листа від клієнта протягом години. Однак основна проблема — коли клієнт не може розрізнити ситуації «моя заявка провалилася» та «моя заявка була успішною, але я так і не отримав відповіді» — проявляється будь-де, де є побічні ефекти: надсилання електронного листа, виклик webhook-функції, створення нового облікового запису, запуск фонової задачі. Будь-яка операція, де можливий повторний запуск, і де її виконання двічі буде гіршим, ніж її повне відсутність, є хорошим кандидатом на застосування цього підходу.

Пов’язана література

  • Поширені помилки у контракті API, які підривають надійність фронтенду — Дізнайтеся про десять поширених недоліків у проектуванні бекенд-API — від непослідовних форматів відповідей до крихких механізмів сторінкування — які зменшують довіру до фронтенду, та як їх виправити.
  • REST API для початківців: ресурси, методи, коди стану та відсутність стану — Посібник простою мовою про те, що таке REST API, п’ять принципів, які забезпечують його функціонування, де він використовується у реальних командах, та як створити та протестувати свій перший REST API.