Retry-стратегия
Лестниц ретрая две, и они независимы. Какая сработает — зависит от того, что ответил твой 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 и требует ручного разбора.
«~8.5 минут» — это сумма пауз, а не гарантированный срок
Считаются только попытки доставки. Паузы, которые ставит предохранитель, счётчик попыток не увеличивают — при лежащем 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 быстрых попыток, и наоборот.
На временных проблемах отвечай 5xx, не 4xx
5xx — «попробуй ещё раз», и ты получишь полную быструю лестницу. 4xx для неденежного события — «не присылай это больше», и ретраев не будет. Если у тебя временный сбой (упала БД, идёт деплой) — верни 5xx.
Предохранитель (circuit breaker)
Чтобы не долбить лежащий endpoint, доставка всему мерчанту временно ставится на паузу:
| Переход | Условие |
|---|---|
| работает → пауза | ≥ 5 доставок за скользящее окно 5 минут и ≥ 80 % из них провалились |
| пауза → пробная доставка | прошло время паузы: сначала 5 минут, дальше — удвоение при каждой неудачной пробе, потолок 30 минут |
| пробная доставка → работает | пробная доставка прошла успешно (счётчики сбрасываются) |
| пробная доставка → пауза | проба провалилась, пауза удваивается |
Что важно знать интегратору:
- Пауза не тратит попытки. Отложенная предохранителем доставка не увеличивает счётчик
attempts— именно поэтому реальное окно жизни уведомления измеряется не минутами, а часами и сутками. - Предохранитель общий на мерчанта, а не на событие. Пока он открыт, придерживаются все твои уведомления, включая свежие. Как только пробная доставка проходит — очередь разбирается целиком.
- Пока предохранитель открыт, пробуется одна доставка за цикл. Остальные ждут ~30 секунд и пробуют снова.
4xxпредохранитель не открывают. Он реагирует на недоступность endpoint'а (сеть, таймаут,5xx), а не на ответ «плохой запрос».- Потолок — 30 суток. Доставка, чей endpoint всё это время был недоступен, помечается
FAILEDокончательно. - Восстановился раньше и не хочешь ждать паузу — напиши команде X-Hub, предохранитель сбрасывается вручную.
Идемпотентность
Webhook может прийти дважды (retry после таймаута). Твой обработчик должен быть идемпотентным.
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типах