Прямой ответ
Повторный запрос создания платежа выполняется при таймауте, повторной отправке формы и сбое сети. Без защиты каждый повтор создаёт новый платёж, поэтому провайдеры поддерживают ключ идемпотентности: одинаковый ключ возвращает результат первого выполнения вместо нового действия.
import httpx
r = httpx.post(
"",
auth=(SHOP_ID, SECRET_KEY),
headers={"Idempotency-Key": f"order-{order_id}-pay"},
json={"amount": {"value": "1500.00", "currency": "RUB"}, "reference": order_id},
timeout=10.0,
)
Повтор с тем же ключом возвращает сохранённый ответ первого запроса, включая код состояния и тело. Повтор с тем же ключом, но другим телом отклоняется как конфликт, поскольку ключ соответствует другому набору параметров.
Ключ формируется из идентификатора бизнес-операции, а не из случайного значения:
| Источник ключа | Поведение при повторе |
|---|---|
| Идентификатор заказа | Повтор возвращает существующий платёж |
| Случайный UUID на запрос | Повтор создаёт новый платёж |
| Идентификатор заказа + тип операции | Разные операции одного заказа различаются |
| Хэш тела запроса | Изменение поля меняет ключ |
Случайный идентификатор на каждый запрос не даёт защиты, поскольку повтор формирует новый ключ. Практическое правило: ключ соответствует намерению пользователя, а не конкретной попытке выполнения.
Собственная защита выполняется уникальным ограничением:
CREATE TABLE payment_requests (
idempotency_key text PRIMARY KEY,
order_id bigint NOT NULL,
provider_id text,
status text NOT NULL,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
Вставка с существующим ключом отклоняется, и обработчик возвращает сохранённый результат. О механизме на уровне API — в статье про идемпотентность API.
Срок хранения результата и неопределённое состояние
Ключ идемпотентности действует ограниченное время. Провайдер хранит результат первого выполнения в течение срока, после которого тот же ключ трактуется как новый запрос.
Практические следствия ограничения срока:
Повтор через длительный интервал создаёт новый платёж. Если ключ сформирован из идентификатора заказа, а срок хранения истёк, повторное выполнение приводит к списанию. Защита требует проверки состояния заказа перед созданием платежа.
Результат хранится вместе с ключом. Ответ первого выполнения возвращается повторно, поэтому клиент получает согласованный результат независимо от числа повторов.
Конфликт тела отклоняется. Изменение параметров при том же ключе означает ошибку формирования ключа, а не новую операцию.
Отдельный случай — таймаут запроса. Операция могла выполниться на стороне провайдера после разрыва соединения, поэтому состояние неопределённо. Три действия допустимы:
- Повторить запрос с тем же ключом — провайдер вернёт результат первого выполнения.
- Запросить состояние по идентификатору операции, если он известен.
- Перевести платёж в неопределённое состояние и уточнить позже.
Недопустимое действие — повторное создание платежа с новым ключом, поскольку оно приводит к двойному списанию. О схеме интеграции — в статье про интеграцию платёжного шлюза.
Параллельные повторы требуют изоляции: два одновременных запроса с одним ключом не должны оба создать платёж. Уникальное ограничение отклоняет вторую вставку, а уровень изоляции транзакции определяет, увидит ли вторая транзакция результат первой; об уровнях изоляции — в статье про транзакции PostgreSQL.
Практика: применение идемпотентности к платёжным операциям
1. Формируйте ключ из бизнес-идентификатора. Идентификатор заказа и тип операции определяют намерение, а не попытку выполнения.
2. Различайте операции одного заказа. Создание платежа и возврат требуют разных ключей, иначе возврат отклоняется как повтор.
3. Храните результат вместе с ключом. Повторный запрос возвращает сохранённый ответ, поэтому клиент получает согласованный результат.
4. Проверяйте состояние перед повтором. Истечение срока хранения ключа делает повтор новой операцией, поэтому состояние заказа проверяется явно.
5. Используйте постоянное время на сравнение. Проверка ключа выполняется в базе уникальным ограничением, а не сравнением строк в коде.
6. Не меняйте тело запроса при том же ключе. Изменение параметров требует нового ключа, поскольку конфликт отклоняется провайдером.
7. Логируйте повторы. Число повторов по одному ключу показывает наличие проблемы с таймаутами либо повторной отправкой формы.
Рекомендации:
- Проверяйте идемпотентность в тестах. Повторный запрос с тем же ключом должен возвращать тот же результат без нового действия.
- Учитывайте срок хранения результата. Защита действует в пределах срока, поэтому длительные сценарии требуют проверки состояния.
- Разделяйте идемпотентность создания и применения. Создание платежа и действие после оплаты защищаются отдельно.
- Не используйте идемпотентность как замену проверке состояния. Ключ предотвращает дубли, но не определяет актуальное состояние операции.
- Документируйте правило формирования ключа. Правило определяет поведение при повторах, поэтому фиксируется вместе с интеграцией.
Практический сценарий: повторное списание после истечения срока ключа
Контекст: пользователь вернулся к оплате заказа через несколько дней и получил второе списание.
- Диагностика. В журнале провайдера два платежа по одному заказу с интервалом в несколько дней. Ключ идемпотентности формировался из идентификатора заказа, но срок хранения результата у провайдера истёк.
- Причина. Защита действовала в пределах срока хранения, а повтор за пределами срока трактовался как новая операция. Проверка состояния заказа перед созданием платежа отсутствовала.
- Действия. (а) Добавили проверку состояния заказа: создание платежа выполняется только при отсутствии успешного платежа; (б) сохранили идентификатор провайдера в базе сразу после создания, чтобы последующие запросы находили существующую операцию; (в) при неопределённом состоянии добавили уточнение запросом вместо повторного создания; (г) выполнили возврат по второму платежу; (д) добавили проверку дублей платежей по заказу в периодическую сверку.
- Результат. Повторная оплата заказа возвращает существующий платёж, а дубли обнаруживаются сверкой.
Вывод: ключ идемпотентности защищает от повторов в пределах срока хранения результата, но не заменяет проверку состояния операции. Проверка перед созданием платежа и сохранение идентификатора провайдера исключают списание за пределами срока действия ключа.
FAQ
Можно ли использовать случайный ключ идемпотентности? Технически да, но защита теряется, поскольку каждый повтор формирует новый ключ. Ключ формируется из бизнес-идентификатора операции.
Что произойдёт при повторе с другим телом запроса? Провайдер отклонит запрос как конфликт, поскольку ключ соответствует другому набору параметров.
Как долго хранится результат первого выполнения? Срок определяется провайдером. Повтор за пределами срока трактуется как новая операция, поэтому состояние проверяется отдельно.
Защищает ли ключ от двойного действия после оплаты? Нет. Действие после оплаты защищается условным переходом состояния, а не ключом создания платежа.
Источники: Stripe idempotent requests, RFC 9110, PostgreSQL constraints — ссылки в шапке.