--- title: "Доставка уведомлений" url: "https://docs.x-hub.online/webhooks/delivery/" description: "Повторы доставки, идемпотентность и чеклист приёмника." relatedDocuments: - "https://docs.x-hub.online/webhooks/" --- # Retry-стратегия { #retry-strategiya } Лестниц ретрая **две**, и они независимы. Какая сработает — зависит от того, что ответил твой endpoint и денежное ли это событие. ## 1. Быстрая лестница — сбои доставки (`5xx`, `429`, сеть, таймаут) | Попытка | Задержка после предыдущей | |---|---| | 1 | сразу | | 2 | +1 сек | | 3 | +2 сек | | 4 | +4 сек | | 5 | +8 сек | | 6 | +16 сек | | 7 | +32 сек | | 8 | +64 сек (~1 мин) | | 9 | +128 сек (~2 мин) | | 10 | +256 сек (~4 мин) | Формула: `delay = 2^(attempt-1)` секунд. Бюджет — **10 попыток**; суммарные паузы дают ~8.5 минут. Потом доставка помечается `FAILED` и требует ручного разбора. !!! warning "«~8.5 минут» — это сумма пауз, а не гарантированный срок" Считаются только **попытки доставки**. Паузы, которые ставит [предохранитель](#circuit-breaker), счётчик попыток не увеличивают — при лежащем endpoint'е те же 10 попыток растягиваются на часы и сутки. Не строй логику на «если за 10 минут вебхука не было — его уже не будет»: он может прийти сильно позже. Для проверки состояния используй `GET /payments/:id`. ## 2. Медленная лестница — денежное событие + `4xx` Код ответа описывает состояние **твоей интеграции**, а не нужность уведомления. Поэтому для событий о фактическом движении денег `4xx` (кроме `429`) больше **не окончателен** — такая доставка уходит на отдельный редкий график: | Шаг | Пауза до следующей попытки | |---|---| | 1 | 15 минут | | 2 | 1 час | | 3 | 4 часа | | 4 | 12 часов | | 5 | 24 часа | Итого окно ≈ **41 час**, жёсткий потолок — **48 часов**, и отсчитываются они от **первого шага этой лестницы**, а не от момента создания доставки. Разница практическая: уведомление, пролежавшее несколько суток непереданным (у тебя не был прописан адрес, либо твой endpoint лежал и его держал предохранитель), при первом же `4xx` получает полную лестницу целиком, а не умирает «по старости». Сверху есть вторая, абсолютная граница по возрасту записи — **30 суток**. После исчерпания — `FAILED`. **Денежными считаются:** - `payment.status_changed` со `status`: `paid`, `settled`, `refunded`; - `withdrawal.status_changed` со `status: completed`; - `exchange.status_changed` со `status: completed`; - `refund.completed` и `refund.reversed`. Всё остальное (в т.ч. `amount_mismatch`, `expired`, `cancelled`, `failed`, `refund.rejected`, `refund.failed`, `subscription.status_changed`, `kyc.status_changed`, `gateway.status_changed`, `payment.delivery_proof_reminder`) — неденежное: `4xx` → `FAILED` сразу, без ретраев. `refund.failed` и `refund.rejected` неденежные не по важности, а по факту: денег они не двигали. Знать о них надо обязательно — просто повторять доставку сутками незачем. Бюджеты двух лестниц раздельны: медленные шаги не расходуют 10 быстрых попыток, и наоборот. !!! tip "На временных проблемах отвечай 5xx, не 4xx" `5xx` — «попробуй ещё раз», и ты получишь полную быструю лестницу. `4xx` для неденежного события — «не присылай это больше», и ретраев не будет. Если у тебя временный сбой (упала БД, идёт деплой) — верни `5xx`. ## Предохранитель (circuit breaker) { #circuit-breaker } Чтобы не долбить лежащий endpoint, доставка **всему мерчанту** временно ставится на паузу: | Переход | Условие | |---|---| | работает → пауза | ≥ **5** доставок за скользящее окно **5 минут** и ≥ **80 %** из них провалились | | пауза → пробная доставка | прошло время паузы: сначала **5 минут**, дальше — удвоение при каждой неудачной пробе, потолок **30 минут** | | пробная доставка → работает | пробная доставка прошла успешно (счётчики сбрасываются) | | пробная доставка → пауза | проба провалилась, пауза удваивается | Что важно знать интегратору: - **Пауза не тратит попытки.** Отложенная предохранителем доставка не увеличивает счётчик `attempts` — именно поэтому реальное окно жизни уведомления измеряется не минутами, а часами и сутками. - **Предохранитель общий на мерчанта, а не на событие.** Пока он открыт, придерживаются **все** твои уведомления, включая свежие. Как только пробная доставка проходит — очередь разбирается целиком. - **Пока предохранитель открыт, пробуется одна доставка за цикл.** Остальные ждут ~30 секунд и пробуют снова. - **`4xx` предохранитель не открывают.** Он реагирует на недоступность endpoint'а (сеть, таймаут, `5xx`), а не на ответ «плохой запрос». - **Потолок — 30 суток.** Доставка, чей endpoint всё это время был недоступен, помечается `FAILED` окончательно. - Восстановился раньше и не хочешь ждать паузу — напиши команде X-Hub, предохранитель сбрасывается вручную. # Идемпотентность Webhook может прийти **дважды** (retry после таймаута). Твой обработчик должен быть идемпотентным. ```javascript async function handleWebhook(event) { const paymentId = event.payment_id; const alreadyProcessed = await db.webhookLog.findUnique({ where: { paymentId_status: { paymentId, status: event.status } } }); if (alreadyProcessed) return; await db.webhookLog.create({ data: { paymentId, status: event.status } }); await grantAccess(event.external_id); } ``` # Чек-лист - [ ] Endpoint отвечает за < 10 секунд - [ ] HTTPS с валидным сертификатом - [ ] Проверка `X-Webhook-Signature` с использованием `X-Webhook-Timestamp + "." + raw_body` - [ ] Идемпотентная обработка по `X-Webhook-Id` (или `payment_id + status`) - [ ] Отвечаешь 200 **после** успешной обработки (не до) - [ ] Логируешь все приходящие webhooks для отладки - [ ] Обработчик не падает на неизвестных `event` типах ---