Клиент ретраит POST /api/payments при таймауте — и деньги списываются дважды. Чиним через Idempotency-Key.
Легенда урока. Мобильное приложение шлёт POST /api/payments. Интернет в метро: сервер успел списать деньги, но ответ до клиента не дошёл. Клиент видит таймаут и честно ретраит запрос. Итог: двойное списание — и разъярённый пользователь.
Операция идемпотентна, если её повтор не меняет результат: один раз выполнить или пять — состояние сервера одинаковое.
Что говорит стандарт HTTP:
| Метод | Идемпотентен? | Почему |
|---|---|---|
| GET | ✅ | чтение ничего не меняет |
| PUT | ✅ | «поставь значение X» — хоть 10 раз, результат тот же |
| DELETE | ✅ | удалить удалённое — ничего не происходит |
| POST | ❌ | «создай ещё один» — каждый повтор создаёт новый! |
| PATCH | ⚠️ | зависит: {status:'paid'} — да, {balance:+100} — нет |
GET/PUT/DELETE клиент может ретраить смело. А вот POST — нельзя… если сервер не поможет. Стандартное решение — заголовок Idempotency-Key: клиент генерирует уникальный ключ операции, сервер запоминает ответ по ключу и на повтор отдаёт его же, ничего не создавая. Так работают Stripe, ЮKassa и почти все платёжные API.
Сначала воспроизведём баг, потом починим.
// Клиент (упрощённо): почему возникает повтор
// const key = crypto.randomUUID(); // один ключ на ОПЕРАЦИЮ
// await fetch('/api/payments', {
// method: 'POST',
// headers: { 'Idempotency-Key': key }, // при ретрае — ТОТ ЖЕ ключ
// body: JSON.stringify({ amount: 500 }),
// });
// // таймаут? повторяем запрос с тем же key — сервер разберётся