# Idempotency в API: як не списати гроші двічі, коли клієнт повторив запит

> Timeout не означає, що операція не відбулась. Розбираємо retry-семантику, дизайн Idempotency-Key, гонки та межі транзакцій – з робочим Node/TypeScript-ендпоінтом і схемою Postgres.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: Backend і System Design
- Canonical: https://cookiesoftware.io/blog/api-idempotency-retries

---

**Коротка відповідь.** Якщо клієнт відправив `POST /payments` і отримав timeout – операція могла як не відбутись, так і відбутись без доставленої відповіді. Повторний запит без захисту означає подвійне списання. Рішення – патерн **Idempotency-Key**: клієнт генерує унікальний ключ логічної операції, сервер атомарно фіксує ключ у базі (unique constraint, не «спочатку перевірив – потім вставив»), виконує операцію один раз і на повтори віддає збережену відповідь. Це індустріальний патерн, популяризований платіжними API, а не стандарт: [IETF-драфт заголовка](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) так і лишився драфтом. Нижче – дизайн і робочий код.

## Чому повтори неминучі

За семантикою 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` не повинен конфліктувати.

## Схема зберігання

```sql
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

```ts
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-ключі й опитує шлюз, чим насправді закінчилась операція. Це ще один приклад того, чому [розуміння транзакцій і їхніх меж](/blog/sql-vs-nosql) – не теорія для співбесід, а щоденна інженерія.

## 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-співбесіди](/blog/nodejs-backend-interview), де idempotency люблять питати одразу після [auth-токенів](/blog/access-refresh-tokens-nodejs).
