Слогер Создать блог
Разработка

Как избежать двойного списания в REST API: идемпотентность на практике

Когда ответ на платежный запрос теряется, повторная попытка приводит к второму списанию. Объясняем, как идемпотентный ключ решает эту проблему и что учесть при реализации.

Клиент нажал «Оплатить», деньги списались, но ответ потерялся. Браузер повторил запрос — и списалось еще раз. Ситуация до боли знакома всем, кто сталкивался с платежными API. И дело не в плохом провайдере, а в том, что сервер не отличает повторное сообщение от нового. Разбираем, как это исправить с помощью идемпотентности.

Почему повторный запрос становится вторым платежом

Представьте: пользователь жмет кнопку оплаты, сервер отправляет команду провайдеру, деньги списываются. Но соединение обрывается раньше, чем ответ доходит до браузера. Браузер видит таймаут и считает, что запрос не удался. Он шлет тот же POST еще раз. Сервер принимает его как новую операцию и инициирует второе списание. В результате у клиента две транзакции.

Каждый компонент по отдельности работает правильно. Браузер ретраит, потому что не получил ответ. API обрабатывает валидный POST. Провайдер выполняет команду. Проблема — в отсутствии контекста, который позволил бы серверу понять: второй запрос — это ретрай первого.

Что такое идемпотентность

Идемпотентность — это свойство операции: сколько раз ее ни повторяй, результат будет одинаковым. Для API платежей это означает, что даже при нескольких HTTP-запросах клиент платит только один раз.

Чтобы это реализовать, нужен идемпотентный ключ. Это уникальная строка, которую клиент генерирует для одной логической операции. Например, для одного чека. Ключ передается в заголовке, обычно Idempotency-Key, и остается одинаковым для всех попыток доставки этого платежа. Новая операция — новый ключ.

Как устроена идемпотентная ручка

Просто сохранить список использованных ключей недостаточно. Нужно хранить состояние операции и ответ, который отдавался при первом обращении. Иначе повторный запрос получит «рыбу», а не реальный результат.

Поле в БДЗачем
idempotency_keyСам ключ, первичный ключ таблицы
request_hashХеш значимых полей запроса — чтобы ключ нельзя было переиспользовать с другим телом
statusprocessing, completed, rejected или recovery_required
response_statusHTTP-статус, который вернется при повторе
response_bodyТело ответа первого запроса

Главная ошибка: проверка вместо атомарной вставки

Типичный небезопасный паттерн выглядит так: сначала проверить, есть ли ключ в базе. Если нет — создать платеж, потом сохранить ключ. Звучит логично, но при параллельных запросах оба «видят» отсутствие ключа и оба создают платеж. Уже после двух списаний выясняется, что уникальный индекс не дал вставить вторую запись, но отменить платеж провайдеру он не может.

Правильный подход — резервировать ключ до начала любых побочных эффектов, используя атомарную операцию. Например, в PostgreSQL это делается вставкой с ON CONFLICT DO NOTHING и возвратом строки:

INSERT INTO idempotency_records (idempotency_key, request_hash, status, expires_at) VALUES ($1, $2, 'processing', NOW() + INTERVAL '24 hours') ON CONFLICT (idempotency_key) DO NOTHING RETURNING idempotency_key;

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

Как обрабатывать повторные запросы

  1. Клиент генерирует UUID и отправляет его в заголовке Idempotency-Key.
  2. Сервер вычисляет хеш тела запроса по фиксированной структуре (например, JSON.stringify с определенным порядком полей).
  3. Вставляет запись со статусом processing.
  4. Если получилось — эта попытка первая, можно вызывать провайдера.
  5. Если не получилось — читаем существующую запись. Сравниваем хеш: не совпал — возвращаем 422, совпал и статус completed — отдаем сохраненный ответ, статус processing — возвращаем 409.
  6. После успешного ответа от провайдера обновляем запись: status='completed', сохраняем код и тело ответа.
  7. Если провайдер вернул ошибку, помечаем запись как recovery_required — нужно свериться с провайдером перед тем, как разрешить повтор.

Почему локальной базы недостаточно

Самый сложный случай — когда провайдер списал деньги, а приложение упало до обновления записи в БД. Локальная транзакция не поможет: провайдер и ваша база не делят одну границу транзакции. Нельзя гарантировать, что успех в одном месте означает успех в другом.

Поэтому идемпотентный ключ нужно передавать и провайдеру, если его API это поддерживает. Тогда повторная попытка с тем же ключом не создаст вторую операцию. Если провайдер такого не умеет, придется как можно раньше сохранять его идентификатор операции и через фоновый воркер сверять реальное состояние перед повтором.

Итог

Двойное списание — это не баг клиента и не шалость провайдера. Это закономерный результат того, что API не различает транспортный запрос и бизнес-операцию. Ретраи неизбежны, так что без идемпотентности любая потеря пакета может обернуться финансовым инцидентом.

Реализация не сводится к одному middleware. Нужны атомарное резервирование ключа, хранение ответа, проверка хеша и координация с провайдером. Зато после этого клиент может повторять запрос сколько угодно — и заплатит ровно один раз.

По материалам: dev. Текст переработан редакцией Слогера.

← На главную

Рекламное место — Конец поста
Реклама · Слогер

Комментарии (0)

Войдите, чтобы комментировать.

Пока нет комментариев. Будьте первым.