Скрытый сбой идемпотентности в финтех-системе: разбор инцидента

История одного инцидента в финтехе, который вскрыл проблему архитектуры — и почему HTTP 500 не всегда означает «операция не выполнена»

Обложка: Скрытый сбой идемпотентности в финтех-системе: разбор инцидента

Как все началось

Всё началось с безобидного, почти рутинного тикета в саппорт:

«У меня тут несколько одинаковых карт создалось. Я вроде один раз нажимал, а их штук пять висит в приложении».

Для финтеха подобная фраза — не мелкая UI-аномалия, а сигнал тревоги высшего уровня. Когда речь идёт о платёжных инструментах, дубль сущности мгновенно переходит из разряда «странностей» в категорию «полноценный инцидент».

Поначалу казалось, что это просто один случай. Но на самом деле, проблема уже какое-то время была, хоть и скрытно: люди получали дубликаты своих банковских карт, но массово на это никто не жаловался. На уровне первой линии поддержки это ошибочно классифицировали как пользовательские ошибки

Настоящий шум поднялся не внутри компании, а уже в соцсетях. Один из клиентов выложил пост. Там была фотография посылки, а в ней — примерно сотня одинаковых карт. Пост очень быстро стал популярным, и вот тогда-то и возникли проблемы с репутацией, а команда разработчиков только тогда и узнала о случившемся. И особенно тревожно это звучало на фоне того, что мы считали что таких проблем быть не может - ведь считалось, что у нас глобальная идемпотентность на все запросы.

Мы начали разбираться. Стандартный мониторинг показывал норму: дашборды зелёные, в логах тихо, метрики CPU и памяти без отклонений. При этом в базе обнаруживались дубликаты, которых быть не должно. И только тогда, когда мы внимательно посмотрели на коммунальный API Gateway, то поняли что одно изменение от другой команды поменяло идемпотентность работы endpoint-а.

Проблема была структурно невидима для тех, кто находился ближе всего к ней. Отсутствие видимой проблемы не равно отсутствию риска — система просто ждала подходящего момента.

Что произошло: хронология инцидента

Архитектура была классической: Клиент → API Gateway → микросервисы,.

Сценарий развивался почти незаметно для стандартных средств наблюдения:

1. Фронтенд: Пользователь нажимает кнопку «Выпустить карту».

2. Gateway: Запрос начинает обрабатываться в API Gateway.

3. Микросервис по созданию карт: честно выполняет работу: создаёт запись в БД и возвращает `200 OK` в Gateway.

4. Новый микросервис: API Gateway вызывает новый микросервис и получает HTTP 500 в ответ от него. Исключение возникает уже после успешного вызова нашего микросервиса, и это ключевая точка разрыва: Gateway считает весь запрос неудавшимся, а ответ нашего микросервиса теряется.

5. Клиент: получает HTTP 500

6. Реакция: Пользователь видит красную плашку ошибки и логично решает: «Не сработало, пробую снова». Более того, с точки зрения протокола HTTP - запросы, в ответ на которые пришла ошибка HTTP 500, можно пытаться отправлять опять.

7. Петля: Микросервис, ничего не зная о судьбе предыдущих ответов, послушно создавал карту за картой.

Круг замкнулся. Эпидемия началась.

В компании такого уровня это не было предусмотрено. И это такая обидная ошибка.

Как так получилось?

Вскрылось ошибочное предположение:

«В API Gateway вызов микросервиса создания карт всегда идет последним».

Таким образом идемпотентность достигалась «формально» - ведь если создание карты завершалось с ошибкой - это точно означало что и вызов API Gateway тоже завершится с ошибкой. Сработало ложное чувство безопасности.

Ответ на вопрос “почему так было сделано?” очень простой - это было осознанное упрощение на старте. Все знали об этом, но задача на фикс потерялась в недрах бэклога на очень долгое время. Это классический пример того, как архитектурное допущение и отложенный рефакторинг годами живут в проде, пока их не вскрывает редкая последовательность отказов.-

Что потребовалось изменить

Пришлось в срочном порядке внедрять полноценный механизм идемпотентности по request-id, который не зависел бы от порядка вызова downstream-сервисов. И это было достаточно тяжело, потому что окно для легкого внедрения закрывается в первый день продакшена: до этого момента нет ни живых пользователей, ни накопленных данных, ни клиентов старых версий, с которыми нужно сохранять обратную совместимость. Ниже — ответы на вопросы, которые мы получили от коллег, когда разбирали этот инцидент.

FAQ: Часто задаваемые вопросы

1. Почему нельзя просто заблокировать кнопку на фронте?

Блокировка кнопки решает проблему только при стабильной сети. Если запрос ушёл, сервер создал сущность, но ответ потерялся — кнопка разблокируется по таймауту, и пользователь нажмёт снова. Это не устраняет корневую причину, а лишь слегка снижает вероятность дубля.

2. Чем идемпотентность отличается от дедупликации в БД?

Дедупликация через уникальные индексы защищает от дублей в хранилище, но не решает проблему сайд-эффектов: повторный запрос всё равно вызовет отправку SMS, печать банковской карты, генерацию событий в шине или списание средств. Идемпотентность гарантирует, что вся цепочка выполнится ровно один раз — включая все внешние вызовы и побочные действия.

3. Как долго хранить ключи идемпотентности?

На практике мы хранили ключи в основной базе данных без ограничения срока — затраты на хранение UUID по всем сущностям оказались небольшими. В общем случае минимальный срок зависит от конкретных сценариев использования — кому-то хватит и часа, а кому-то нужна неделя. В любом случае, окна должно быть достаточно, чтобы покрыть сценарии, когда пользователь возвращается к повтору запроса, например, на следующий день или когда клиентское приложение автоматически перезапускает отложенные запросы после восстановления сети. Бессрочное хранение не обязательно, но слишком короткий TTL создаёт риск дублей при длительных сетевых проблемах.

4. Что делать со старыми клиентами, которые не шлют request_id?

Мы сделали несколько версий API для создания карт — под разные версии приложения. Для новых клиентов работала полноценная идемпотентность с клиентским ключом. Для старых версий приходилось принимать риски и генерировать request_id на стороне сервера. Альтернативой может быть хэширование payload запроса, но это менее надежно и сложнее: таймстемпы и случайные поля могут отличаться от вызова к вызову. Поэтому мы выбрали подход с генерацией ключа на сервере для устаревших клиентов: риски дублей на переходный период оказались меньше, чем сложность поддержки двух схем валидации одновременно.

5. Обязательно ли делать идемпотентность для всех методов API?

Идемпотентность требуется только для методов, которые изменяют состояние системы — POST, PUT, PATCH и иногда DELETE. Методы чтения (GET, HEAD, OPTIONS) не изменяют данные, поэтому считаются идемпотентными по умолчанию.

6. Как понять, что в вашей системе уже есть скрытая проблема с дублями?

Лучший способ — ввести метрики превентивно, не дожидаясь жалоб пользователей. Отслеживайте количество повторных вызовов с одинаковым ключом идемпотентности и сравнивайте его с общим числом запросов. Если метрики уже показывают ненулевое значение — проблема есть, даже если внешне всё работает незаметно.

Если метрик ещё нет, вот три косвенных признака, которые помогут заподозрить неладное:

  • В базе данных периодически появляются записи с одинаковым содержимым, созданные с разницей в несколько секунд.
  • Пользователи жалуются на дубликаты карт, заказов или платежей, но вы не можете воспроизвести проблему локально, списываете на то, что пользователи что-то делают не так
  • В логах API Gateway периодически всплывают HTTP 500 ошибки, но downstream-сервисы при этом отрабатывают успешно.

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

Итог

Главный урок, который мы вынесли: идемпотентность — это общая ответственность всех команд разработки, и поломать её может быть проще, чем кажется. А добавить в уже работающую систему быстро и дёшево — почти невозможно.

Метрик на всплески повторных запросов у нас не было. А зря — это самый дешёвый способ увидеть проблему до того, как она обрушит продакшен. Системы, спроектированные на 10% нагрузки, ломаются на 60% — и обычно это становится неожиданностью для команды.

Практический чек-лист: что проверить в своей системе уже сегодня

  • Убедитесь, что все критические мутирующие эндпоинты поддерживают ключ идемпотентности.
  • Настройте алерты на аномальное количество запросов на создание сущностей от одного пользователя за короткий промежуток времени.
  • Проверьте, как ваш API Gateway обрабатывает ошибки — не теряет ли он ответы downstream-сервисов.
  • Убедитесь, что фронтенд корректно обрабатывает не только 200, но и 500, 502, 504, не провоцируя пользователя на повторные клики без необходимости.