Идемпотентность в API — почему ваш POST был запущен дважды и как это исправить
Оплата, которая была взята дважды, дублированный заказ или пользователь, созданный три раза — все это симптомы одного и того же отсутствующего API-договора. Вот что означает идемпотентность, почему POST нарушает её и как идемпотентные ключи решают эту проблему.
Пользователь нажал «Оплатить сейчас». Появился спиннер. Сбой сети. У вас запустился механизм повторной попытки. Оплата прошла. Затем первоначальный запрос также завершился на сервере — и оплата прошла ещё раз. Снято $200 с аккаунта клиента, и поддержка ждёт вас утром.
Это не редкий крайний случай. Это то, что происходит, когда вы не думаете о идемпотентности до того, как это нужно. Давайте это исправим.
Что означает идемпотентность на самом деле
Это слово происходит из математики. Операция называется идемпотентной, если применение её несколько раз даёт тот же результат, что и её применение один раз. f(f(x)) = f(x).
В терминах API: при каждом вызове того же конечного интерфейса с тем же намерением N раз система должна оставаться в том же состоянии, что после одного вызова. Ответ может быть кэшированным, но побочные эффекты — записи в базу данных, оплаты, письма — должны происходить только один раз.
Это гарантия о том, что ваш сервер Эта кука сопровождает запросы только к, а не только о том, что он возвращает.
Методы HTTP: кто безопасен, а кто нет
В спецификации HTTP определённые методы считаются идемпотентными по определению. Вот практическое объяснение:
| Метод | Идемпотентен? | Почему |
|---|---|---|
GET | ✅ Да | Только чтение. Побочные эффекты отсутствуют по определению. |
HEAD | ✅ Да | То же самое, что и GET, без тела ответа. |
PUT | ✅ Да | «Установить этот ресурс в точном состоянии». Вызов дважды даёт тот же результат. |
DELETE | ✅ Да | Ресурс исчезает после первого вызова. Дальнейшие вызовы находят ничего, чтобы удалить. (Код состояния может отличаться — 204 против 404 — но состояние сервера не меняется.) |
POST | ❌ Нет | «Обработать этот заголовок». То, что это означает, зависит от сервера. Два POST обычно создают два ресурса или вызывают два побочных эффекта. |
PATCH | ⚠️ Зависит | Относительное обновление ("increment count by 1") не является идемпотентным. Абсолютное обновление ("set count to 5") является. Ваша реализация определяет, какое из них. |
Проблема в том, что большинство реальных бизнес-операций — оплата платежа, создание заказа, отправка уведомления — естественно отображаются на POST. И POST даёт вам нулевые гарантии идемпотентности по умолчанию.
Последовательность, которая приводит к
Вот классическая модель сбоев, пошагово:
Client Network Server
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓
|<-- (connection drops) ---| response queued
|
| [retry logic kicks in]
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓ (again)
|<------- 200 OK ----------|<------------------------|
|
[client sees: one charge. server did: two charges]
Клиент никогда не видел первый ответ. С его точки зрения запрос не удался. С точки зрения сервера он успешно завершился дважды. Это классическая ситуация в распределённых системах — клиент и сервер расходятся по тому, что произошло.
Это не только платежные процессоры. Это создание заказов, регистрация пользователей, отправка электронных писем, резервирование запасов — любое действие, где «выполнить дважды» имеет реальные последствия.
Ключи идемпотентности: паттерн Stripe
Stripe популяризировал подход, который сейчас используется большинством платежных API. Клиент генерирует уникальный ключ до отправки запроса и прикрепляет его в заголовке. Сервер использует этот ключ для исключения дублирования.
Сторона клиента:
// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();
async function chargeWithRetry(amount, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // same key on every retry
},
body: JSON.stringify({ amount, currency: 'usd' }),
});
if (response.ok) return await response.json();
// Don't retry on client errors (4xx)
if (response.status < 500) throw new Error(`Client error: ${response.status}`);
} catch (err) {
if (attempt === retries - 1) throw err;
await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
}
}
}
Ключ должен генерироваться перед в любом цикле повторной попытки — это и есть суть. Если вы генерируете новый UUID на каждом попытке, вы полностью уничтожаете механизм.
Хорошим вариантом здесь является UUID v4. Если вам нужно быстро генерировать его при тестировании, IO Tools' генератор UUID позволяет создавать пакет из них без необходимости подключения библиотеки.
Сторона сервера: хранить ответ, возвращать его при повторном вызове
Работа сервера в концептуальном плане проста: проверить, был ли уже виден этот ключ; если да, вернуть сохранённый ответ; если нет, обработать и сохранить.
// Express + Redis
app.post('/api/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyKey) {
const cached = await redis.get(`idem:${idempotencyKey}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
}
const result = await paymentProcessor.charge(req.body);
const responseBody = { id: result.id, status: result.status };
if (idempotencyKey) {
// 24-hour TTL — same window Stripe uses
await redis.setex(
`idem:${idempotencyKey}`,
86400,
JSON.stringify({ status: 200, body: responseBody })
);
}
res.json(responseBody);
});
Несколько важных деталей:
- Ошибки кэширования, а не только успехи. Если оплата не удалась (отклонено карта), сохраните ответ об ошибке. Иначе при повторной попытке оплата будет повторно отправлена, хотя уже возвращалась ошибка — что именно вы пытаетесь избежать.
- Проверка согласованности тела запроса. Stripe возвращает 422, если ключ повторно используется с разными параметрами запроса. Это выявляет ошибки, когда вы случайно повторно используете ключ в разных операциях.
- Обработка дублирования в процессе. Если два запроса с тем же ключом приходят одновременно, требуется координация — обработать один и вернуть 409 на другой, или использовать распределённый блокировку. Обычный паттерн — Redis SETNX.
- Установить разумный срок действия. После истечения срока действия ключи могут быть перезапущены, и новые запросы с тем же ключом рассматриваются как новые. Не храните их вечно — ваш кэш будет расти.
Ошибки на стороне клиента, которые запускают POST дважды без повторной попытки
Ключи идемпотентности защищают от повторных попыток на уровне сети. Они не помогают, когда клиент запускает два отдельных, независимых запроса. Обычные причины:
- Двойной клик на кнопку отправки. Пользователь нажимает, ничего не происходит, затем нажимает снова. Отключите кнопку сразу после первого клика — не после получения ответа. Промежуток между кликом и ответом — именно в этот момент приходит второй клик.
- Двойной вызов в React StrictMode. React 18 Strict Mode в режиме разработки запускает эффекты дважды, чтобы выявить ошибки. Если вы запускаете POST в
useEffectбез очистки, вы увидите дублирование запросов в режиме разработки. Это не происходит в продакшене, но может скрывать реальную проблему. - Пересылка формы в браузере. Отправка → перенаправление → назад → вперёд. Некоторые браузеры предлагают «пересылать?», другие просто делают это. Паттерн POST-Redirect-GET (возврат перенаправления после POST) полностью устраняет это.
- Ситуации конкуренции в обработчиках событий. Клик и быстрый ввод клавиши Enter запускают обработчик отправки до того, как приходит первый ответ, и отключают форму.
- Повторная попытка в фоновом режиме мобильного приложения. Фоновая загрузка iOS и Android или логика повторной попытки на уровне сети может повторно отправить запрос, который уже был инициирован приложением. Генерируйте ключ идемпотентности в момент намерения пользователя, сохраняйте их локально при необходимости и удаляйте только после подтверждённого успеха.
Альтернатива, заслуживающая рассмотрения: PUT к URL с UUID
Если вы контролируете обе стороны API, есть более чистый вариант для создания ресурсов: пусть клиент назначает идентификатор ресурса и использует PUT вместо POST.
# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}
# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}
PUT является идемпотентным по спецификации HTTP — «установить этот ресурс в это состояние» означает, что повторный вызов PUT не оказывает дополнительного эффекта, если ресурс уже существует. Сервер обрабатывает дублирование с помощью INSERT ... ON CONFLICT DO NOTHING или аналогичного.
Этот паттерн работает хорошо для создания заказов, управления черновиками и любых ресурсов, где важна согласованность идентификатора при повторных попытках. Он не работает, когда третья сторона (платежный процессор) назначает идентификатор, или когда побочный эффект должен произойти на сервере до того, как вы узнаете идентификатор.
Как на самом деле реализуется Stripe
Stripe документация по ключу идемпотентности заслуживает внимательного прочтения. Несколько важных деталей, которые имеют практическое значение:
- Формат ключа: Любая строка до 255 символов, ограниченная вашим аккаунтом Stripe. Два разных аккаунта, использующих одинаковую строку, не мешают друг другу.
- Окно в 24 часа: Ключи истекают через 24 часа. Повторная попытка после истечения срока рассматривается как новая попытка — это важно знать, если вы разрабатываете долгосрочные рабочие процессы.
- Несоответствие тела = 422: То же ключ, разные параметры → Stripe возвращает 422 Unprocessable Entity. Это правильное поведение; оно выявляет ошибку, когда вы случайно повторно используете ключ.
- Конкурентное исключение дубликатов: Если два запроса с тем же ключом приходят одновременно, Stripe обрабатывает один и возвращает 409 Conflict на другой. Повторите попытку после короткого отсрочки.
- Кэширование ошибок: Если оплата не удалась (отклонено карта), Stripe кэширует эту ошибку. Повторная попытка с тем же ключом возвращает тот же отказ. Вам нужно новый ключ, чтобы попробовать другую карту — что правильно, потому что предыдущая попытка была полной, намеренной операцией.
Проверка идемпотентности без полного набора интеграционных тестов
Самый быстрый способ проверить поведение — дважды отправить запрос на ваш интерфейс с тем же ключом и проверить, что второй вызов возвращает кэшированный ответ без запуска побочного эффекта снова. IO Tools' инструмент для построения команды cURL делает простым создание запроса с пользовательскими заголовками — включая Idempotency-Key — без необходимости запоминания синтаксиса флагов cURL.
Сценарии, которые нужно проверить:
- То же ключ + то же тело, в пределах срока действия → возвращается кэшированный ответ, побочный эффект запускается один раз
- То же ключ + разные тела → 422 (или ваш выбранный статус конфликта)
- То же ключ после истечения срока действия → рассматривается как новый запрос
- Нет ключа → обрабатывается как обычный запрос (сделайте решение заранее: ключи обязательны или не обязательны)
- Два одновременных запроса с тем же ключом → один обрабатывается, другой получает 409
Краткий вариант
POST не является идемпотентным, и разрыв между «запрос отправлен» и «ответ получен» — это то, где живут дублированные побочные эффекты. Исправление не сложное: генерируйте UUID до любого цикла повторной попытки, отправляйте его в заголовке, кэшируйте ответ на сервере, используя этот заголовок в качестве ключа. Сложные части — кэширование ошибок, обработка одновременных дубликатов, выбор правильного срока действия — но основной паттерн достаточно прост, чтобы добавить его в любой важный интерфейс.
Добавьте поддержку ключей идемпотентности до первого потока в продакшене. Поздно учиться уроку после двойной оплаты — это значительно худшая ситуация.
Установите наши расширения
Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска
恵 Табло результатов прибыло!
Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!
Подписаться на новости
все Новые поступления
всеОбновлять: Наш последний инструмент Добавлено 20 июня 2026 года
