Осенью 2022-го мы добавляли в наш платёжный агрегатор новый способ оплаты: плательщик уходит по ссылке в приложение своего банка, подтверждает платёж там, а банк присылает нам нотификацию о результате. Песочница у банка была – формально. Отвечала статусами из позапрошлой версии протокола, а нотификации не присылала вообще; обещанные доработки ехали к нам дольше, чем наш дедлайн. Мы перестали ждать и написали двойника провайдера сами – по PDF со спекой, с тестом на каждый сценарий из документации. CI зелёный. Запускались осторожно, тремя волнами мерчантов: первые две прошли тихо. После третьей в саппорт пошли тикеты: у плательщика деньги списаны, у мерчанта платёж висит.
Сердиться на банк бессмысленно – у крупного провайдера тестовый стенд вечно живёт по остаточному принципу: прод приносит деньги, стенд – нет, и очередь доработок выстраивается соответственно. Для нас как интегратора это условие задачи. Документация на протокол в таких интеграциях – отдельный жанр: не публичная страница с актуальным API, а PDF-файл, который менеджер по интеграции присылает письмом. В нём попадаются поля и поведение, которых нет в открытом описании на сайте – фичи из-под полы, доступные тем, кому прислали нужную версию файла. Интереснее, что произошло с нашими тестами. Стаб исполняет ровно ту семантику, которую ты ему написал. Гоняя тесты против него, мы проверяли собственную фантазию о банке: внутренне непротиворечивую, прилежно покрытую и не обязанную совпадать с продом. Зелёный CI в такой схеме означает “наша фантазия согласована сама с собой” – и ничего больше.
Дальше – пять граблей, которые мы вытащили из этой интеграции. Все они про расхождение фантазии с реальностью, но в четырёх разных измерениях: время (грабли 1 и 2), завершённость (грабля 3), повторность и порядок (грабля 4), словарь (грабля 5).
Грабля 1. Нотификация обгоняет наш коммит
Первым в логах всплыло сообщение, которому мы сначала не поверили: обработчик нотификаций отвечал “платёж не найден” – по платежам, которые мы сами только что успешно создали.
Запись платежа у нас была двухшаговой. Свою транзакцию мы создавали сразу, по внутреннему OrderId, ещё до похода в банк – контракт соблюдён, намерение сохранено. Затем шёл запрос на создание платежа у провайдера, и его идентификатор – ProviderPaymentId – привязывался к нашей записи вторым шагом, уже после ответа банка. А обработчик нотификаций искал платёж по ProviderPaymentId. Логичный выбор: в протоколе это главный идентификатор платежа.
// обработчик нотификаций, версия 1 public async Task<IResult> Handle(PaymentNotification n, CancellationToken ct) { var payment = await payments.FindByProviderId(n.ProviderPaymentId, ct); if (payment is null) return Results.NotFound(); // здесь ломается: платёж есть, привязки ещё нет payment.ApplyStatus(MapStatus(n.Status)); await payments.Save(payment, ct); return Results.Text("OK"); // почему именно "OK" – дойдём в грабле 5 }
Теперь сложим тайминги. Плательщик с привязанным счётом подтверждает оплату в один тап – нотификация прилетает через секунды после создания платежа. Привязка ProviderPaymentId к нашей записи живёт в отдельном коммите, и на малом трафике он успевает за миллисекунды. На полном трафике пул коннекшенов занят, база под нагрузкой, и окно между ответом банка и коммитом привязки растягивается до секунд. Остальное – дело техники: нотификация приходит, поиск по ProviderPaymentId не находит ничего, банк получает 404. Запись в базе при этом существует с самого начала – просто под другим ключом.

Стаб этого показать не мог физически. Он отвечает синхронно, нотификацию шлёт тогда, когда её отправит тестовый сценарий, и порядок событий в нём всегда срежиссирован. Тест “колбэк пришёл раньше, чем мы дописали то, к чему он относится” не приходит в голову, пока не увидишь его в проде.
Чинится это обороной в три рубежа. Первый: матчить нотификацию по своему ключу. OrderId существует с первой миллисекунды жизни платежа, и нотификация его несёт – банку мы сами его передали при создании. Второй: неузнанная нотификация – повод отложить, а не отказать; короткая очередь “сирот” с повторной обработкой через пару секунд снимает остаточные гонки. Отвечать банку ошибкой в расчёте на его ретраи тоже можно, но тогда ваша консистентность зависит от чужой retry-политики, пределы которой вы узнаете опытным путём. Наша первая версия, как видно из кода, жила именно так – 404 и надежда на повтор. Чаще всего банк действительно повторял, и со второй попытки всё сходилось; случаи, где повтор не доехал, копились в очереди зависших. Третий рубеж – полинг, и он заслуживает отдельной грабли.
Грабля 2. Нотификация имеет право не прийти
Гонки с недоехавшими повторами объясняли часть зависших платежей, но не все. У некоторых нотификация опаздывала на минуты, на часы – или не приходила совсем, ни первой попыткой, ни последующими. Сейчас я это пишу и сам слышу, как звучит: вебхуки доставляются по принципу “постараемся”, об этом сказано в любой статье про них. Мы тоже это знали. Отдельная ирония в том, что единственное, чего песочница банка не делала совсем – нотификации: как раз тот канал, который нас уронил. Но в стабе нотификация приходила всегда – мы сами так его написали, ведь в PDF сценария “нотификации не будет” нет. А чего нет в стабе, того нет и в тестах: наш CI честно проверял мир, в котором почта не теряется.
Майкл Найгард в “Release It!” называет точки интеграции главным источником нестабильности: каждая внешняя система однажды поведёт себя не так, как обещала документация. Зависшие после третьей волны платежи – наш взнос в эту статистику. Разгребали часа 3-4: поднимали статусы руками через запрос к банку, успокаивали мерчантов, а параллельно писали то, что должно было существовать с первого дня – страховочный полинг.
// страховка асинхронного канала: сами спрашиваем статусы незавершённых public sealed class PendingPaymentsPoller( IPaymentStore payments, IProviderClient bank) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { // дешёвая выборка по индексу: только платежи, у которых настал срок проверки var due = await payments.TakePendingDueForCheck(ct); foreach (var payment in due) { var status = await bank.GetPaymentStatus(payment.ProviderPaymentId, ct); payment.ApplyStatus(status); payment.ScheduleNextCheck(); // минута → пять → час → ... пока живёт платёж await payments.Save(payment, ct); } await Task.Delay(WakeInterval, ct); } } }
Сам цикл просыпается часто, но это дешёвый запрос по индексу. А вот как часто дёргать банк, решает персональное расписание платежа: почти все развязки случаются в первые минуты, поэтому свежий платёж проверяем через минуту-другую, затем интервалы растут, а хвост у нас дотягивался до двух суток уже редкими контрольными запросами. За всю жизнь висящей транзакции набегает пара десятков обращений к банку, а не тысячи – и нагрузка на чужой API почти не зависит от размера нашей очереди незавершённых. Было и внеочередное правило: плательщик вернулся на страницу успеха, а нотификации ещё нет – статус спрашиваем немедленно, не дожидаясь расписания. С тех пор я считаю это инвариантом любой интеграции с нотификациями: асинхронный канал – оптимизация, гарантию даёт только синхронный опрос. Нотификация делает систему быстрой, полинг делает её правдивой.
Второй урок этой грабли – уже не про код. Дозированный запуск волнами нас не спас, хотя обязан был: зависший платёж снаружи неотличим от платежа, который просто ещё не оплатили, и без метрики “возраст самого старого незавершённого платежа” волны прошли вслепую. Канареечный запуск ловит только то, что вы измеряете.
Поллер закрыл дыру с потерянными нотификациями – и тут же подсветил платежи, с которыми всё было в порядке, кроме одного: завершаться они не собирались.
Грабля 3. Платёж, у которого нет финала
Разбирая очередь незавершённых, мы обнаружили, что заметная её часть вовсе не зависла. Плательщик создал платёж, получил ссылку – и не пошёл по ней. Передумал, отвлёкся, закрыл вкладку. Транзакция в промежуточном статусе, и никакого сбоя в этом нет: она будет жить так, пока ссылка не протухнет. В тестах подобного платежа не существовало. Стаб всегда доигрывал сценарий до конца, до успеха или до отказа – потому что тест без финала невозможно даже сформулировать. На чём ставить assert?
Реальность спокойно живёт без финала. Часть созданных платежей не завершается никогда и ничем – для оплаты по ссылке это штатный исход, и доля его тем выше, чем легче плательщику уйти. Различать “ждём нотификацию” и “плательщик не платит” пришлось по возрасту: для свежего платежа молчание банка – норма, для старого – повод спросить статус самим, а после протухания ссылки – закрыть транзакцию как неоплаченную.
На этом мы успокоились. Зря: спустя время в закрытый по протуханию платёж пришли деньги. Единичные случаи – но оплата после смерти ссылки оказалась возможной, а наша статусная модель встречала её молча: статус терминальный, событий не ждём. После того случая у похороненных платежей появилась обработка посмертных событий – поздняя оплата создаёт инцидент разбора, и дальше человек решает, вернуть деньги или провести платёж.
Стаб врал нам дважды. Сначала – что у каждой истории есть конец. Потом – что конец окончательный.
Грабля 4. Дубли приходят, порядок не гарантирован
Четвёртая грабля – классика жанра. Одна и та же нотификация приходила по два раза – иногда с интервалом в секунды, иногда в часы. А изредка нотификации приезжали не в том порядке, в котором банк менял статусы: сначала финальная, следом запоздавшая промежуточная. Стаб, разумеется, слал одну нотификацию на переход и строго по порядку – такая вежливость встроена в саму идею тестового сценария.
Обработчик из первой грабли к этому моменту уже умел находить платёж. Оставалось научить его не реагировать на повторы и не позволять опоздавшим статусам перезатирать свежие:
public void ApplyStatus(PaymentStatus incoming) { if (incoming == Status) return; // дубль: повторная доставка того же статуса - норма, не повод падать if (!StatusFlow.CanTransition(from: Status, to: incoming)) return; // опоздавший промежуточный статус не перезатирает финальный Status = incoming; raisedEvents.Add(new PaymentStatusChanged(Id, incoming)); }
Идемпотентность плюс монотонность: повтор не делает ничего, переход возможен только вперёд по статусной машине. У монотонности одна оговорка, знакомая по грабле 3: недопустимый переход, который приносит деньги в уже закрытый платёж, нельзя глотать молча – он поднимает тот самый инцидент разбора. Игнорировать можно дубли; деньги – нет. Заводить всё это надо до того, как увидишь первый дубль, потому что первый дубль приезжает вместе с первым настоящим трафиком. Про идемпотентность с другой стороны баррикады – когда запросы шлёшь ты сам и дубли порождает твой собственный ретрай – я писал в посте про idempotency keys; там те же грабли, вид сбоку. Оставалась последняя – та, про которую обещал рассказать комментарий в самом первом примере кода.
Грабля 5. Контракт – это то, что приходит по сети
Последняя грабля выглядит мелочью, и в этом её коварство. На нотификацию нужно отвечать строкой “OK” в теле ответа. Буквальной строкой: не просто кодом 200, не пустым телом, не JSON’ом {"status":"ok"} – двумя байтами OK. Всё прочее банк считает недоставкой и присылает нотификацию снова. Наш стаб принимал любой успешный ответ – мы написали его по своему прочтению спеки, и наше прочтение оказалось щедрее банковского.
Форма ответа – полбеды, словарь подвёл сильнее. Однажды в нотификации пришёл статус, которого в нашем PDF не было. Потом – поле с токеном привязки счёта, о котором документация молчала: фича существовала, просто её описание до нас не доехало. Винить некого: PDF, который вам прислали, отстаёт от прода на неизвестную вам величину. Стаб наследует слепые пятна своего автора – он знает о протоколе столько же, сколько вы, и ни байтом больше. Тест против него не поймает расхождение между вашим прочтением и чужой реализацией: обе стороны такого теста писали вы.
Защита здесь скучная и надёжная. Парсинг словаря – толерантный:
private static PaymentStatus MapStatus(string wire) => wire switch { "PENDING" => PaymentStatus.Pending, "COMPLETED" => PaymentStatus.Completed, "REJECTED" => PaymentStatus.Rejected, _ => PaymentStatus.Unknown // лог + алерт, но не exception };
Неизвестный статус не роняет обработчик: платёж остаётся в промежуточном состоянии, дежурный получает алерт, а поллер из второй грабли дотащит транзакцию до правды, когда мы разберёмся, что это было. Рубежи работают вместе: толерантный парсинг безопасен лишь потому, что под ним страховка. И второе правило, которое я теперь завожу в первый день любой интеграции: сырые payload’ы – в лог, целиком. Когда придёт поле, которого нет в спеке, единственным источником истины окажется то, что реально пришло по сети.
Чеклист, который я унёс с собой
Если вам предстоит интеграция, а песочницы нет или про неё говорят “лучше без неё”:
Обработчик нотификаций – идемпотентный и монотонный с первого дня: дубли и беспорядок приедут с первым трафиком.
Нотификация – оптимизация. Гарантия – полинг.
Входящие события матчить по своему идентификатору, который существует с момента создания операции.
Неузнанное событие – отложить и повторить, не отказать.
Неизвестный статус – лог и алерт, не exception.
У вечных промежуточных состояний есть TTL; у закрытых по TTL – обработка посмертных событий.
Сырые payload’ы в лог с первого дня.
Канареечный запуск работает только в паре с метрикой, по которой видно деградацию. Для платежей это возраст самого старого незавершённого платежа.
Чего в этом списке нет – призыва выбросить стабы. Против двойника можно и нужно гонять свою логику: статусную машину, идемпотентность, обработку ошибок. Просто это тесты вашей логики, а не их поведения, и зелёный цвет таких тестов ничего не говорит о проде. Контрактные тесты в духе Pact закрывают дыру честно, но требуют участия второй стороны: провайдер должен гонять ваши контракты у себя. Нашей второй стороной был PDF.
Самым информативным тестовым окружением в итоге оказался сам прод – с обороной в три рубежа, метрикой возраста и логом сырых событий он рассказал о протоколе больше, чем песочница и документация вместе. Хотелось бы узнавать такое до тикетов в саппорт, но для этого нужна песочница, которая ведёт себя как прод. Судя по моему опыту, в индустрии платежей это самый дефицитный артефакт.
Что почитать
Michael Nygard - “Release It!” (2nd ed., Pragmatic Bookshelf, 2018). Точки интеграции, таймауты, circuit breaker и почему внешняя система обязательно поведёт себя не по документации. Половина этого поста – частный случай его глав про integration points.
Vladimir Khorikov - “Unit Testing: Principles, Practices, and Patterns” (Manning, 2020). Управляемые и неуправляемые зависимости: какие можно подменять в тестах без вреда, а какие проверяются только настоящими. Банк тут – неуправляемая зависимость, очень показательная.
Stripe Docs - “Best practices for using webhooks”. Короткий трезвый список того, что провайдер нотификаций обещает, а чего не обещает. Лучше читать до интеграции, чем после.
