Клиент нажал «Оплатить», деньги списались, но ответ потерялся. Браузер повторил запрос — и списалось еще раз. Ситуация до боли знакома всем, кто сталкивался с платежными API. И дело не в плохом провайдере, а в том, что сервер не отличает повторное сообщение от нового. Разбираем, как это исправить с помощью идемпотентности.
Почему повторный запрос становится вторым платежом
Представьте: пользователь жмет кнопку оплаты, сервер отправляет команду провайдеру, деньги списываются. Но соединение обрывается раньше, чем ответ доходит до браузера. Браузер видит таймаут и считает, что запрос не удался. Он шлет тот же POST еще раз. Сервер принимает его как новую операцию и инициирует второе списание. В результате у клиента две транзакции.
Каждый компонент по отдельности работает правильно. Браузер ретраит, потому что не получил ответ. API обрабатывает валидный POST. Провайдер выполняет команду. Проблема — в отсутствии контекста, который позволил бы серверу понять: второй запрос — это ретрай первого.
Что такое идемпотентность
Идемпотентность — это свойство операции: сколько раз ее ни повторяй, результат будет одинаковым. Для API платежей это означает, что даже при нескольких HTTP-запросах клиент платит только один раз.
Чтобы это реализовать, нужен идемпотентный ключ. Это уникальная строка, которую клиент генерирует для одной логической операции. Например, для одного чека. Ключ передается в заголовке, обычно Idempotency-Key, и остается одинаковым для всех попыток доставки этого платежа. Новая операция — новый ключ.
Как устроена идемпотентная ручка
Просто сохранить список использованных ключей недостаточно. Нужно хранить состояние операции и ответ, который отдавался при первом обращении. Иначе повторный запрос получит «рыбу», а не реальный результат.
| Поле в БД | Зачем |
|---|---|
| idempotency_key | Сам ключ, первичный ключ таблицы |
| request_hash | Хеш значимых полей запроса — чтобы ключ нельзя было переиспользовать с другим телом |
| status | processing, completed, rejected или recovery_required |
| response_status | HTTP-статус, который вернется при повторе |
| 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;
Только один запрос получит строку в результате. Он и становится владельцем операции. Все остальные должны прочитать уже существующую запись и решить: вернуть сохраненный ответ, если операция завершена, или сообщить о конфликте, если она еще выполняется.
Как обрабатывать повторные запросы
- Клиент генерирует UUID и отправляет его в заголовке Idempotency-Key.
- Сервер вычисляет хеш тела запроса по фиксированной структуре (например, JSON.stringify с определенным порядком полей).
- Вставляет запись со статусом processing.
- Если получилось — эта попытка первая, можно вызывать провайдера.
- Если не получилось — читаем существующую запись. Сравниваем хеш: не совпал — возвращаем 422, совпал и статус completed — отдаем сохраненный ответ, статус processing — возвращаем 409.
- После успешного ответа от провайдера обновляем запись: status='completed', сохраняем код и тело ответа.
- Если провайдер вернул ошибку, помечаем запись как recovery_required — нужно свериться с провайдером перед тем, как разрешить повтор.
Почему локальной базы недостаточно
Самый сложный случай — когда провайдер списал деньги, а приложение упало до обновления записи в БД. Локальная транзакция не поможет: провайдер и ваша база не делят одну границу транзакции. Нельзя гарантировать, что успех в одном месте означает успех в другом.
Поэтому идемпотентный ключ нужно передавать и провайдеру, если его API это поддерживает. Тогда повторная попытка с тем же ключом не создаст вторую операцию. Если провайдер такого не умеет, придется как можно раньше сохранять его идентификатор операции и через фоновый воркер сверять реальное состояние перед повтором.
Итог
Двойное списание — это не баг клиента и не шалость провайдера. Это закономерный результат того, что API не различает транспортный запрос и бизнес-операцию. Ретраи неизбежны, так что без идемпотентности любая потеря пакета может обернуться финансовым инцидентом.
Реализация не сводится к одному middleware. Нужны атомарное резервирование ключа, хранение ответа, проверка хеша и координация с провайдером. Зато после этого клиент может повторять запрос сколько угодно — и заплатит ровно один раз.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.