Отладка вебхука часто начинается с одной строки в логах: обработчик вернул 200 OK. Если событие всё равно «пропало», команда смотрит соседние строки, затем логи прокси, затем переписку с провайдером. К этому моменту уже неясно, о каком запросе идёт речь: о том, который отправитель считает доставленным, о том, который принял ваш сервер, или о повторной попытке несколькими минутами позже.
Вебхук выглядит как обычный HTTP-запрос. Сложность появляется из-за того, что это распределённая доставка без общего журнала. Отправитель, сеть, приёмник, очередь, обработчик и побочные действия живут в разных системах и отвечают на разные вопросы.
Провайдер → сеть → приём → доставка → обработчик → побочное действие → HTTP-ответ
Ошибка может произойти на любом участке. Ещё важнее другое: разные участки могут одновременно выглядеть успешными. Именно поэтому webhook debugging сложнее, чем проверка одного статус-кода.
Почему отладка вебхуков сложнее, чем кажется
В синхронном API клиент и сервер разделяют один запрос. Если операция не удалась, это обычно видно в том же ответе. Вебхук устроен иначе. Провайдер отправил событие и пошёл дальше. Ваше приложение узнаёт о нём позже, иногда с другой машины, через повторную попытку или уже после того, как пользователь увидел неверный статус заказа.
К моменту разбора инцидента исходных данных может уже не быть. Провайдер хранит свой лог доставки. Балансировщик — факт HTTP-ответа. Приложение — то, что успело записать после разбора тела. Если эти журналы не связаны одним идентификатором события, команда склеивает историю по времени и приблизительным полям payload. Два похожих платежа начинают выглядеть как один повтор, а один повтор — как два разных события.
Наблюдаемость здесь нужна не как набор красивых графиков. Она нужна, чтобы ответить на конкретные вопросы:
запрос вообще покинул провайдера;
его принял ваш входной URL;
какое тело и какие заголовки пришли на самом деле;
в какую цель его пытались доставить;
чем закончилась каждая попытка;
это новое событие, повтор той же доставки или сознательный Replay.
Пока эти ответы приходится собирать после сбоя, отладка остаётся догадкой. Ниже — пять типичных случаев, в которых лог 200 OK особенно легко вводит в заблуждение.
Payload lost: запрос исчез, и непонятно, на каком участке
«Вебхук потерялся» — слишком широкая формулировка. Payload может не дойти по нескольким разным причинам, и у каждой свой набор улик.
Запрос мог не покинуть провайдера: неверный URL, ошибка на стороне отправителя, событие не попало в его очередь. Мог потеряться в сети до вашего приёмника. Мог быть отвергнут на входе из-за метода, размера, лимита или недоступности инфраструктуры. Мог быть принят, но так и не доставлен в обработчик. Мог дойти до приложения и исчезнуть уже там: процесс перезапустился до записи в базу, очередь приложения не приняла сообщение, а исходное тело в обычные логи не попало.
Без сохранённого входа эти ситуации почти неразличимы. Провайдер говорит: «мы отправили». Приложение говорит: «мы ничего не получили». Обе стороны могут быть правы относительно своей границы.
Особенно коварен отвергнутый запрос. Если приёмник отклонил HTTP-сообщение до полной регистрации, в системе может не остаться тела, заголовков и пути — только факт отказа и его причина. Тогда спор «какой payload пришёл» уже нельзя закрыть данными: содержимое не сохранилось и не восстановить.
Отдельный случай — истёкшее хранение. Даже принятый запрос нельзя разобрать, если к моменту инцидента его уже нет. Observability вебхуков существует только в том окне, пока запрос ещё сохранён. Добавить недостающие данные задним числом нельзя.
Поэтому первый вопрос диагностики — не «почему обработчик не сработал», а «был ли запрос принят и что именно в нём было». Пока нет ответа на него, чинить бизнес-логику рано.
Timeout: ответ не пришёл, но работа могла уже выполниться
Тайм-аут выглядит как простой сбой: «получатель не ответил вовремя». Для вебхуков это одна из самых двусмысленных ошибок.
Обработчик мог не получить запрос. Мог получить его, начать работу и не успеть ответить. Мог полностью выполнить операцию — создать заказ, начислить баланс, отправить письмо — а ответ потерялся в сети или пришёл после истечения тайм-аута. Со стороны отправителя все три случая выглядят одинаково: доставка не подтверждена.
Именно поэтому неуспешный тайм-аут нельзя читать как доказательство, что побочного действия не было. Он говорит только о том, что ожидаемый HTTP-ответ не был получен вовремя. Следующая попытка может повторить уже выполненную операцию.
Тайм-аут также плохо стыкуется с обычными access-логами. В логе приложения может появиться успешная обработка. В логе отправителя — ошибка по тайм-ауту. Оба журнала формально корректны. Без истории попыток с длительностью, номером попытки и итоговым статусом невозможно понять, это один запрос или уже повтор.
Для диагностики здесь нужны не только статус-коды, но и время: когда попытка началась, сколько она длилась, оборвалась ли на DNS, TLS, соединении или уже после передачи тела. Формулировка «timeout» слишком общая, пока не видно, на каком этапе запрос остановился.
Duplicated delivery: повтор — штатное свойство доставки, а не обязательно баг
Повторная доставка вебхука часто воспринимается как ошибка инфраструктуры. В распределённой системе это обычное следствие тайм-аута, потери ответа, перезапуска потребителя или политики retries.
Один и тот же логический платёж может прийти дважды, потому что первая попытка уже изменила состояние, а успешный 200 OK не дошёл до отправителя. Может прийти дважды, потому что провайдер и промежуточный слой независимо решили повторить доставку. Может прийти дважды после ручного retry, который команда запустила, не увидев, что обработчик уже отработал.
Observability не отменяет идемпотентность. Она делает повторы видимыми. Без истории попыток команда видит в приложении два заказа и не знает, это два события или две доставки одного события. С историей видно: один Request, несколько попыток, одинаковый идентификатор доставки — или два разных Request, то есть два отдельных входа.
Сравнивать тела запросов для этой задачи недостаточно. Два независимых счета могут содержать одинаковую сумму и валюту. Один повтор может отличаться служебными заголовками. Надёжнее опираться на стабильный идентификатор события у провайдера и на явный ключ идемпотентности для конкретной доставки. Подробнее эту границу мы разбирали в статье Почему вебхуки нужно обрабатывать идемпотентно.
Здесь важно другое: если система не показывает, какая попытка за каким номером ушла в какую цель, идемпотентность приходится восстанавливать по косвенным признакам. Это медленно и легко даёт неверный вывод.
Partial failures: часть пути успешна, и это маскирует остальной сбой
Частичный сбой особенно плохо читается по одному логу 200 OK.
Самый прямой случай — несколько получателей. Один входящий вебхук уходит в CRM, биллинг и внутренний обработчик уведомлений. CRM отвечает 200 OK, биллинг — 500, уведомления ещё ждут подключения. Если смотреть только успешный лог CRM, событие кажется обработанным. Для биллинга оно ещё не завершено, а для уведомлений — даже не начато.
Есть и другой, более скрытый частичный сбой — внутри одного обработчика. Приложение записало платёж, не отправило письмо, затем упало до фиксации идемпотентного ключа или до HTTP-ответа. Со стороны транспорта это либо ошибка, либо тайм-аут. Со стороны данных — уже изменённое состояние. Повторная попытка может пройти «успешно» и при этом создать второе письмо либо, наоборот, отказаться от работы, хотя часть действий так и не выполнена.
Частичный результат нельзя свернуть в один статус без потери смысла. Нужна отдельная история по каждой цели доставки и отдельный разбор состояния самого приложения. Транспортный успех одной Destination не отменяет неуспех другой и не доказывает, что бизнес-операция завершена.
Именно поэтому полезно разделять статус входа и статус доставки. Request может быть принят и при этом иметь статус partial: часть маршрутов завершилась, часть ещё идёт через retry, часть исчерпала попытки.
Provider retries: у отправителя своя политика, и она не совпадает с вашей
Поверх ваших повторов почти всегда существуют повторы провайдера. Он решает, сколько раз отправлять событие, с каким интервалом и когда сдаться. Один сервис может повторять доставку часами. Другой — не повторять вовсе.
Если провайдер стучится напрямую в обработчик, эти политики смешиваются с поведением приложения. Медленный handler превышает тайм-аут отправителя — провайдер присылает тот же payload снова. Обработчик отвечает 500 — следует ещё одна серия. Команда видит пачку похожих запросов и не сразу понимает, это новые события, retries провайдера или собственные повторы.
Если вход и доставка разделены, картина становится яснее. Провайдер получает быстрый ответ от публичного приёмника и на этом обычно прекращает свои попытки. Дальнейшие повторы принадлежат уже слою доставки к Destination. В истории видно один принятый Request и несколько попыток доставки, а не несколько независимых входов, которые только выглядят как один платёж.
Но разделение не уничтожает retries провайдера полностью. Они появляются снова, если публичный URL отвечает ошибкой, слишком долго не отвечает или недоступен. Поэтому для разбора нужно видеть обе границы: что ответили отправителю на входе и что происходило потом с каждой Destination.
Пока эти два контура retries не разведены в интерфейсе, любой всплеск одинаковых payload выглядит как один и тот же инцидент. На деле это могут быть совершенно разные механизмы.
Почему логов «200 OK» недостаточно
Строка 200 OK отвечает на узкий вопрос: какой-то HTTP-сервер в какой-то момент вернул успешный статус. Для вебхука этого мало.
Во-первых, непонятно, чей это успех. Лог провайдера, крайний прокси, Сервер Adal, Destination, middleware приложения и фоновая очередь могут фиксировать разные события. Успех на одной границе не переносится на остальные.
Во-вторых, статус не содержит payload. Он не показывает заголовки подписи, idempotency key, идентификатор события, путь и query-параметры. Для вебхука эти поля часто важнее кода ответа: по ним отличают повтор от нового события и проверяют, что тело не изменилось по пути.
В-третьих, один успешный ответ не говорит, какая это была попытка. Первая доставка могла завершиться тайм-аутом после фактической обработки. Вторая — вернуть 200 OK уже по идемпотентной ветке. В логах приложения оба случая могут выглядеть как «успех», хотя побочное действие произошло только один раз — или, хуже, дважды.
В-четвёртых, 2xx подтверждает получение ожидаемого HTTP-ответа, а не завершение бизнес-операции. Обработчик мог ответить успешно, поставив работу в внутреннюю очередь, и упасть уже после этого. Мог вернуть 200 OK до записи в базу. Мог обработать только часть Destinations.
В-пятых, обычные логи — плохой архив вебхуков. В запросе бывают подписи, токены, персональные данные и платёжные сведения. Их не стоит копировать в общий журнал ошибок, аналитику или алерт. Значит, access-лог по замыслу неполный. Если единственный источник правды — этот неполный лог, после инцидента восстанавливать нечего.
Коротко: 200 OK — транспортный сигнал. Observability для вебхуков начинается там, где этот сигнал связывается с конкретным входом, конкретной целью, номером попытки и исходным запросом.
Что должно быть видно по каждому вебхуку
Чтобы разбирать описанные сбои, по запросу нужна не одна строка статуса, а связанная история.
На входе должно быть видно, принят запрос или отклонён, когда это произошло, в каком регионе, с каким методом, путём, заголовками и телом. Если запрос отвергнут, должна остаться хотя бы причина отказа — иначе «пропавший payload» нельзя отличить от ошибки отправителя.
Для доставки нужна отдельная запись по каждой Destination. Успех одной цели не должен скрывать неуспех другой. По каждой попытке полезны номер, время, длительность, HTTP-статус либо сетевая ошибка. Это позволяет отличить DNS от TLS, тайм-аут от 500 и ожидание CLI от уже начавшейся неудачной доставки.
Отдельно должны различаться три действия, которые со стороны приложения легко спутать:
Автоматический retry → та же доставка, следующая попытка
Ручной retry → новая попытка существующей доставки
Replay → новый Request на основе сохранённого запроса
Без этой границы команда не поймёт, почему обработчик увидел «тот же» вебхук ещё раз: система сделала retry или человек запустил Replay.
Наконец, слой доставки не заменяет журналы приложения. Он отвечает за вход и транспорт. Завершилась ли бизнес-операция, видно только в состоянии самой системы-получателя. Честная observability проводит эту границу явно, а не прячет её за общим зелёным статусом.
Как это выглядит в Adal
Adal как раз разделяет приём, хранение и доставку, чтобы эти вопросы можно было задавать по конкретным данным, а не по памяти участников инцидента.
Внешний сервис → Сервер Adal → Request → Delivery → Destination
Внешний сервис отправляет вебхук на постоянный публичный HTTPS URL Сервера Adal. Принятый запрос становится Request: в панели видны метод, путь, query-параметры, заголовки, тело, время получения и связанные доставки. Это прямой ответ на сценарий payload lost: можно проверить, пришёл ли запрос и что в нём было, даже если обработчик в тот момент был недоступен.
Для каждой Destination Adal ведёт отдельную Delivery. Один Request может быть успешно доставлен в первую цель, ожидать CLI во второй и стоять на retry в третьей. Статусы Request в панели — received, delivering, success, partial, failed и rejected. Агрегированный статус partial как раз фиксирует частичный сбой: часть доставок завершилась, часть — нет. Это и есть наблюдаемый partial failure, а не один общий 200 OK.
Каждая попытка записывается в историю: номер, время, статус, HTTP-код ответа, длительность, а при сбое — сведения о сетевой, DNS, TLS или иной ошибке доставки. Тайм-аут перестаёт быть словом из тикета и становится конкретной попыткой с понятным местом остановки.
Автоматический и ручной retry тоже перестают быть невидимыми. Автоматический retry относится к существующей Delivery и не создаёт новый Request. Ручной retry повторяет конкретную доставку в одну выбранную Destination. Replay создаёт новый Request. Если для Destination включён заголовок X-Adal-Idempotency, автоматический и ручной retry сохраняют тот же ключ, а Replay получает новый — потому что это уже новая операция внутри Adal.
Такое разделение помогает распутать provider retries и retry на стороне доставки. Если Сервер Adal принял запрос, провайдер обычно получает успешный входной ответ и не продолжает серию. Дальнейшая доставка к приложению остаётся в истории Request. Если же запрос не появился в панели, причина может быть на стороне отправителя, сети или отклонения на входе: для Request со статусом rejected Adal сохраняет только минимальные диагностические данные, без тела и заголовков.
У этой модели есть явные границы, и их важно не завышать. Успешная Delivery означает, что Adal получил от Destination HTTP-ответ класса 2xx. Это не доказательство, что приложение завершило бизнес-операцию. Тело и заголовки ответа Destination Adal не сохраняет. Хранение Request ограничено retention выбранного плана и настройки Сервера; после удаления историю нельзя ни просмотреть, ни повторить. Adal не даёт exactly-once доставку: обработчик по-прежнему должен быть идемпотентным и опираться на стабильный идентификатор события провайдера, когда он есть.
Для исходящих вебхуков тот же класс вопросов решает Adal Outbound: приложение передаёт сообщение, а очередь, retries и история попыток остаются наблюдаемыми после ответа 202 Accepted. И там, и во входящем потоке действует одно правило: факт приёма ещё не равен факту доставки.
Наблюдаемость вебхуков не появляется из access-лога с кодом 200 OK. Она появляется, когда у каждого события есть сохранённый вход, отдельная история по каждой цели и понятная хронология попыток. Тогда payload lost, timeout, duplicated delivery, partial failures и provider retries перестают быть одним общим «вебхук сломался» и становятся разными, проверяемыми случаями.