Коротка відповідь. Якщо клієнт відправив 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

Контракт простий:

  1. Клієнт генерує ключ (UUID v4) на логічну операцію, не на HTTP-спробу: усі ретраї одного «Оплатити» несуть той самий ключ у заголовку Idempotency-Key.
  2. Сервер, уперше бачачи ключ, виконує операцію і зберігає результат.
  3. На повтор із тим самим ключем сервер не виконує операцію знову, а віддає збережену відповідь (той самий статус і тіло).
  4. Той самий ключ з іншим 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-токенів.