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

Как избежать двойного списания в 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. Текст переработан редакцией Слогера.

← На главную

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

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

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

Md.Alomgir Hossain 29.08.2026 13:30 ▲ 0
Отличная и очень познавательная
Hasan1122 29.08.2026 13:34 ▲ 0
Вот отличный вариант комментария на русском языке для публикации на Sloger: ​Спасибо за отличную и действительно полезную статью! 👏 ​Проблема двойных списаний при интеграции платежных шлюзов — действительно очень частая, но критическая ошибка. Многие разработчики на первых порах пытаются обработать ее обычной проверкой через БД и попадают в ловушку race condition. ​Особенно точно вы подсветили темы с атомарной вставкой (ON CONFLICT DO NOTHING) и проверкой хэша тела запроса (Request Body Hash). Ключ может совпадать, но содержимое тела измениться, поэтому проверка хэша крайне важна. ​Отдельная благодарность автору за то, что объяснил тему идемпотентности на бэкенде простым языком и с понятными примерами!
Hassan Mohamed Hussein 29.08.2026 13:43 ▲ 0
Очень полезная и понятная статья! Особенно понравилось практическое объяснение идемпотентности в REST API и способов избежать двойного списания. Материал изложен доступно, с акцентом на реальные сценарии и важные детали реализации.
Hassan Mohamed Hussein 29.08.2026 13:44 ▲ 0
Очень полезная и понятная статья! Особенно понравилось практическое объяснение идемпотентности в REST API и способов избежать двойного списания. Материал изложен доступно, с акцентом на реальные сценарии и важные детали реализации.
Hassan Mohamed Hussein 29.08.2026 13:44 ▲ 0
Очень полезная и понятная статья! Особенно понравилось практическое объяснение идемпотентности в REST API и способов избежать двойного списания. Материал изложен доступно, с акцентом на реальные сценарии и важные детали реализации.
Fluxs comment
Реклама · fluxs.ru
Mohammid Haider 29.08.2026 16:22 ▲ 0
Very nice post.
Mohammid Haider 29.08.2026 16:22 ▲ 0
Very nice post.
Mohammid Haider 29.08.2026 16:23 ▲ 0
Very nice post
Mohammid Haider 29.08.2026 16:25 ▲ 0
Nice Artical
Mohammid Haider 29.08.2026 16:26 ▲ 0
Nice article
Маркетинг

Мессенджер для компании: каналы, клиенты и акции в одном месте

У компании в мессенджере может быть своё место: профиль с логотипом, каналы, группы для команды и актуальные предложения. Клиентов приглашают в канал по ссылке, а акцию публикуют с ценой и кнопкой, которая ведёт на нужную страницу.

Слогер 13.10.2026 ▲ 1
Разработка

Как одному собрать серьёзный продукт: разбор опыта разработчика, который 5 лет писал платформу по вечерам

Один человек, пять лет вечеров после основной работы — и живая платформа с дешёвым обслуживанием. Что из этого опыта применимо к вашему проекту, а что лучше не повторять.

Слогер 13.10.2026 ▲ 0
AI

Как построить AI-скрининг резюме на Node.js: разбор пайплайна и его ограничений

Почти все демо AI-рекрутинга — это Python-монолит. В рабочем найме сложнее другое: слой оркестрации, который принимает резюме, считает соответствие роли и решает, когда в дело вступает человек.

Слогер 13.10.2026 ▲ 0
Карьера

Как проходят собеседования по кодингу с AI-ассистентом: разбор восьми компаний

AI-ассистированный раунд есть уже у Amazon, Google, Meta, Stripe, Shopify, DoorDash, Instacart и Coinbase — и у каждой он устроен по-своему. Разбираем, чем форматы отличаются и что это значит для подготовки.

Слогер 13.10.2026 ▲ 0
Fluxs lenta
Реклама · fluxs.ru
AI

Сколько часов уходит на поиск клиентов вручную и можно ли отдать это ИИ

Как искать новых клиентов для B2B и не получить в итоге мёртвую базу? Мы сделали ИИ-помощника: он находит компании под ваш профиль, читает их сайты, проверяет по реестру и к каждому выводу прикладывает ссылку на страницу, которую реально прочитал.

Слогер 12.10.2026 ▲ 0
Карьера

Как написать сопроводительное письмо для удалённой работы: разбор рынка 2026 года

Из 5 237 удалённых вакансий больше половины открыты для кандидатов по всему миру. Письмо здесь работает громче резюме — если в нём есть результат, а не пересказ должностей.

Слогер 12.10.2026 ▲ 0
Fluxs lenta
Реклама · fluxs.ru
Fluxs lenta
Реклама · fluxs.ru