Короткий ответ
Нужен обязательный клиентский ключ идемпотентности, атомарно привязанный к результату операции на уровне БД (уникальный индекс + транзакция), плюс идемпотентная обработка на стороне интеграции с платёжным провайдером (который сам может повторно доставить уведомление); "exactly-once" в строгом смысле недостижимо в распределённой системе, поэтому цель — эффективно-однократное выполнение (effectively-once) через идемпотентность на каждом шаге цепочки.
Как это работает подробнее
клиент генерирует Idempotency-Key перед первой попыткой; сервер должен атомарно "зарезервировать" ключ (например, INSERT ... ON CONFLICT DO NOTHING с уникальным индексом по ключу) до начала фактического списания, чтобы параллельные повторные запросы с тем же ключом не прошли через бизнес-логику параллельно. Отдельно — вызов самого платёжного провайдера также должен быть идемпотентным (большинство провайдеров поддерживают свой idempotency key), потому что retry может произойти и на этом уровне, независимо от вашего API.
Пример кода
def charge_payment(idempotency_key: str, amount: float, user_id: int):
with db.begin():
inserted = db.execute(
"""INSERT INTO payment_attempts (idempotency_key, status)
VALUES (:key, 'processing')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id""",
{"key": idempotency_key},
).fetchone()
if inserted is None:
# ключ уже существует — возвращаем предыдущий результат, не выполняя списание повторно
return get_existing_result(idempotency_key)
provider_result = payment_provider.charge(
amount=amount,
user_id=user_id,
idempotency_key=idempotency_key, # идемпотентность и на уровне провайдера
)
save_result(idempotency_key, provider_result)
return provider_resultЧто использовать на практике
- Рекомендация. нужно явно продумать все точки, где может произойти дублирование: клиент → ваш API, ваш API → платёжный провайдер, провайдер → ваш webhook-приёмник уведомлений о результате. Каждая из этих точек нуждается в своей защите идемпотентности; пропуск хотя бы одной (например, обработка webhook без проверки, что событие уже обработано) оставляет лазейку для дублирования. Решение требует согласования с выбранным платёжным провайдером, так как конкретный механизм (их idempotency key, формат уведомлений) зависит от его API. Компромисс: строгая идемпотентность на каждом уровне (клиент-API, API-провайдер, webhook) даёт максимальную защиту от дублирования, но увеличивает сложность реализации, требует дополнительного хранилища состояния операций и aккуратной обработки гонок (что в свою очередь добавляет латентность из-за блокировок/транзакций). Более простая реализация только на уровне клиент-API экономит время разработки, но оставляет риск дублирования на уровне интеграции с провайдером и обработки webhook — критерий выбора глубины защиты зависит от финансовой критичности операции, объёма трафика и того, насколько платёжный провайдер сам гарантирует идемпотентность на своей стороне.
Подводные камни
- Риски. защищать идемпотентность только на входе вашего API, но не на вызове платёжного провайдера, из-за чего retry именно этого внутреннего вызова (например, после таймаута у провайдера) создаёт два реальных платежа при одном входящем запросе клиента; обрабатывать webhook от провайдера без дедупликации по его
event_id, из-за чего повторная доставка уведомления (которую провайдеры прямо документируют как возможную) приводит к повторному зачислению/обработке.