Получатель записал заказ в базу, но соединение оборвалось до того, как отправитель получил ответ. Нужно ли отправлять вебхук повторно? Если не отправить, можно потерять событие. Если отправить, можно создать второй заказ.
С такого случая удобно начинать разговор о доставке. HTTP-клиент умеет сделать POST, но по таймауту не скажет, что произошло на другом конце соединения. Решать, когда повторять запрос и как обрабатывать повтор, придётся обеим сторонам.
Ниже разобраны пять задач, которые возникают при разработке такой системы: расписание повторов, обработка дублей, проверка подписи, изоляция недоступных получателей и защита от запросов во внутреннюю сеть. Примеры на PHP показывают отдельные механизмы; это не готовый обработчик, который можно целиком перенести в проект.
Повторы могут мешать получателю восстановиться
После ошибки хочется подождать пару секунд и попробовать снова. Затем подождать четыре секунды, восемь и так далее. Экспоненциальная задержка снижает частоту запросов, но у неё есть особенность: события, которые одновременно получили ошибку, будут повторяться примерно одновременно.
Допустим, несколько сотен запросов застали перезапуск сервера. Через две секунды они снова приходят вместе. Если сервер ещё не поднялся, через четыре секунды будет следующая волна. Уже работающий, но пока не готовый к полной нагрузке получатель может снова перестать отвечать.
Чтобы разнести повторы во времени, к задержке добавляют случайность — jitter. Например, выбирают задержку от нуля до текущего верхнего предела:
$delaySeconds = random_int(0, min(3600, 2 ** min($attempt, 12)));
Здесь $attempt — номер повтора, начиная с единицы, а задержка задана в секундах. Верхний предел растёт от двух секунд до часа. Ограничение показателя степени нужно, чтобы не вычислять огромные значения на больших номерах попыток. Такой вариант называется full jitter; сравнение с другими вариантами есть в разборе AWS.
Разброс уменьшает синхронность запросов, но сам по себе не ограничивает нагрузку. Если получатель допускает десять запросов в секунду, этот лимит нужно соблюдать отдельно.
Формулу стоит проверить на конкретных числах. При задержке 2 ** $attempt восьмой повтор ждёт 256 секунд, десятый — 1024 секунды. Суммарное ожидание до восьмого повтора без джиттера — 510 секунд. Если по договорённости доставка должна переживать суточный сбой, такого расписания недостаточно.
Поэтому расписание стоит проверить целиком: когда будет последняя попытка, сколько запросов придётся на первый час и что произойдёт после исчерпания попыток. Допустимый срок зависит от события. Уведомление, актуальное несколько минут, и изменение статуса заказа требуют разных решений. Недоставленное событие должно получить явный статус и остаться доступным для разбора.
Само ожидание лучше поручить очереди с отложенными заданиями. sleep() внутри цикла занимает воркер, который в это время мог бы доставлять другие события.
Остаётся определить, какие ошибки повторять. Для своего протокола можно начать с такой политики:
таймауты, обрывы соединения и большинство
5xx— повторять в пределах общего срока доставки;429— откладывать запрос с учётом корректногоRetry-After, если получатель его прислал;400,401,403,404,410— фиксировать ошибку и сообщать о ней владельцу интеграции.
Это договорённость между сторонами. Например, 404 бывает следствием временно сломанного маршрута при деплое, поэтому утверждать, что повтор всегда бесполезен, нельзя. Retry-After тоже нужно разбирать: он может содержать число секунд или HTTP-дату. Формат определён в HTTP Semantics.
Уникальный идентификатор ещё не обеспечивает идемпотентность
Вернёмся к заказу из начала статьи. Отправитель не получил ответ и повторяет запрос. Получателю нужен способ понять, что событие уже было принято.
Для этого у события должен быть стабильный идентификатор. Его можно передавать в теле или заголовке X-Event-Id. При повторе идентификатор сохраняется; отдельный ID попытки пригодится для логов, но не для распознавания дублей.
Однако таблица с обработанными ID сама по себе проблему не решает. Рассмотрим последовательность:
Получатель вставляет ID в таблицу обработанных событий.
Публикует задание в очередь.
Возвращает
200.
Между первым и вторым шагом процесс может завершиться или очередь может оказаться недоступна. При повторе ID уже будет в базе. Получатель ответит, что всё хорошо, хотя задание вообще не было создано.
Один из вариантов — сначала сохранять само событие в таблицу входящих событий, или inbox. В ней лежат идентификатор, тело и состояние обработки. Например, в PostgreSQL при наличии уникального ограничения на (source_id, event_id) приём можно выразить так:
INSERT INTO webhook_inbox (source_id, event_id, payload, status) VALUES (:source_id, :event_id, :payload, 'pending') ON CONFLICT (source_id, event_id) DO NOTHING;
Параметры передаются через подготовленный запрос. source_id определяется по проверенной интеграции: два независимых отправителя могут использовать одинаковые ID. Подпись и формат события проверяются до записи. Конфликт именно по указанной паре подавляется, остальные ошибки должны обрабатываться как ошибки — это поведение описано в документации PostgreSQL.
Успешный 2xx возвращается после фиксации записи в базе. Он означает: «событие принято, ответственность за обработку перешла к получателю». Отдельный воркер забирает необработанные записи из inbox. Если для ускорения его будят через очередь, должен оставаться способ найти записи, для которых такое уведомление потерялось.
Это закрывает разрыв между приёмом и постановкой в обработку, но у воркера есть собственный риск: выполнить действие и упасть до отметки об успехе. Если действие меняет ту же базу, изменение и отметку можно зафиксировать одной транзакцией, защитив запись от параллельной обработки. Если нужно вызвать внешний сервис, понадобится его механизм идемпотентности или отдельный способ сверять результат после неопределённого ответа.
Поэтому обещание «доставим ровно один раз» для обычного HTTP-вебхука вводит в заблуждение. Повторы допускают дубли, а ограниченное число попыток не гарантирует, что недоступный получатель вообще примет событие. Практическая задача — сохранить событие и сделать так, чтобы повтор не повторял бизнес-операцию.
На уже принятое событие следует отвечать успешным 2xx. Код 409 сообщает об ошибке: один отправитель повторит запрос, другой сочтёт доставку окончательно неудачной. Для успешно распознанного дубля оба результата неудобны.
Подпись нужно проверять до разбора события
Публичный адрес обработчика доступен не только вашему отправителю. Прежде чем менять заказ по входящему запросу, нужно проверить, кто мог его сформировать.
Один из распространённых вариантов — HMAC-SHA256 с общим секретом. Отправитель подписывает метку времени и точные байты тела:
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
Получатель вычисляет ожидаемую подпись по той же схеме. Для сравнения используется hash_equals($expected, $signature): эта функция предназначена для защиты сравнения от атак по времени выполнения. Обычное сравнение строк может раскрывать информацию о совпавшей части подписи через длительность операции. Подробнее — в документации PHP.
Метка времени нужна, чтобы ограничить срок действия перехваченного запроса. Получатель проверяет её формат и допустимое отклонение от своих часов. Например, Stripe использует окно в пять минут по умолчанию и при каждом повторе создаёт новую метку времени и подпись. ID события при этом остаётся прежним. Схема описана в разделе о защите от повторного воспроизведения.
Важно подписывать время текущей отправки. Если взять время создания события, легитимный повтор через час не пройдёт пятиминутную проверку. Часы обеих сторон должны быть синхронизированы. Внутри разрешённого окна запрос всё ещё можно воспроизвести, поэтому проверка времени дополняет идемпотентность.
Для подписи нужны исходные байты тела. В Laravel их получают так:
$payload = $request->getContent();
Если сначала разобрать JSON, а затем собрать его через json_encode(), могут измениться пробелы, экранирование и представление чисел. Содержание останется тем же, но подпись перестанет сходиться.
До бизнес-обработки проверяются наличие и формат заголовков, допустимость времени и сама подпись. После этого можно разбирать и валидировать событие. Идентификатор для дедупликации тоже должен быть защищён подписью: проще включить его в подписанное тело. Если доверять только неподписанному X-Event-Id, его можно менять при воспроизведении одного и того же запроса.
Один недоступный адрес может занять общую очередь
Предположим, получатель перестал отвечать, но новые события для него продолжают поступать. Каждая попытка занимает воркер до таймаута.
При тысяче событий в день и восьми попытках на каждое получится до восьми тысяч запросов на эту порцию событий. Если все они исчерпают десятисекундный таймаут, суммарно это около 22 часов занятого времени воркеров. При достаточной параллельности они выполнятся быстрее, но ресурсы всё равно будут потрачены. Быстрый отказ в соединении, напротив, не держит воркер все десять секунд — тип ошибки здесь имеет значение.
Начать стоит с ограничения числа одновременных запросов к одному адресу. Иначе один медленный получатель может занять весь пул, пока остальные ждут.
При длительных сбоях помогает circuit breaker. После серии ошибок он временно приостанавливает попытки к конкретному адресу. Затем разрешает один пробный запрос: успех возобновляет доставку, неудача продлевает паузу. В системе с несколькими воркерами разрешение на пробу должно выдаваться атомарно, иначе после паузы они снова пойдут все вместе.
Условия срабатывания нужно описать точно. «Двадцать ошибок подряд» и «двадцать ошибок за час» — разные правила. В первом случае успех сбрасывает счётчик. Во втором нужен учёт ошибок во временном окне. Простой TTL, установленный при первом увеличении счётчика, не превращает его в скользящее окно.
Числа зависят от потока: двадцать ошибок могут набраться за секунду или за неделю. Нет универсального порога, одинаково полезного в обоих случаях.
Пауза не должна терять события. Пока адрес недоступен, они остаются в хранилище с понятным статусом; отдельно решается, продолжает ли истекать их срок доставки. Владельцу интеграции нужны уведомление и возможность увидеть причину остановки. После восстановления накопившиеся события тоже стоит отправлять с ограничением скорости.
Пользовательский URL — это доступ к сети отправителя
Если пользователь задаёт адрес, по которому ваш сервер делает запрос, появляется риск SSRF. Вместо обработчика вебхуков он может указать внутренний сервис или адрес облачного metadata API, например 169.254.169.254. Последствия зависят от сетевых ограничений, метода запроса и того, может ли пользователь увидеть ответ: от проверки доступности внутренних адресов до чтения закрытых данных при уязвимой конфигурации.
Проверки строки URL недостаточно. Домен может разрешаться во внутренний адрес, а HTTP-клиент может получить другой DNS-ответ после вашей проверки. Один вызов gethostbyname() с проверкой IPv4 не закрывает эти случаи.
Для сервиса, который доставляет на произвольные публичные адреса, защита должна учитывать:
разрешённые схемы и порты, проверку TLS-сертификата;
IPv4 и IPv6, включая специальные и непубличные диапазоны;
привязку соединения к проверенному IP с сохранением исходного имени для TLS и HTTP;
запрет автоматических редиректов;
сетевые ограничения, запрещающие воркерам доступ к внутренним сервисам и metadata API.
Проверка нужна перед каждой новой попыткой: DNS-запись может измениться после регистрации адреса. Эти меры подробнее разобраны в рекомендациях OWASP по SSRF.
В Guzzle запрет редиректов и ограничения времени задаются, например, так:
$response = $client->request('POST', $url, [ 'body' => $payload, 'allow_redirects' => false, 'connect_timeout' => 3, 'timeout' => 10, ]);
Это фрагмент настройки времени и редиректов, а не вся защита от SSRF. Для connect_timeout в Guzzle нужен cURL-обработчик; подробности есть в документации параметров запроса.
Размер ответа тоже нужно ограничить во время чтения. Проверка длины после полной загрузки не спасёт от огромного ответа, а одному Content-Length доверять нельзя. Для доставки обычно достаточно статуса и небольшого фрагмента ответа, который поможет разобрать ошибку.
Что проверить перед запуском
Эти механизмы полезно проверить на сбоях между шагами. Получатель сохранил событие, но не ответил. Воркер выполнил действие, но не записал результат. Процесс завершился сразу после приёма запроса. Один адрес перестал отвечать, пока на остальные продолжает идти обычный поток.
Для каждого такого случая должно быть понятно, где осталось событие, кто попробует обработать его снова и что увидит владелец интеграции. В истории доставки пригодятся ID события и попытки, время, HTTP-статус или сетевая ошибка, дата следующего повтора. Тела ответов стоит хранить с ограничением размера и без секретов.
У ручного перезапуска тоже нужна определённая семантика. Повтор доставки сохраняет ID события. Если получатель уже принял его в inbox, повторный запрос не обязан заново запускать бизнес-обработку — для неё нужен отдельный механизм на стороне получателя.
Приём, хранение, повторы и история попыток — это и есть основной объём работы в такой системе. Но даже вынесенная в отдельный сервис доставка не может определить по таймауту, был ли создан заказ. Этот случай всё равно нужно закрыть на стороне обработчика.

