Перейти к содержанию

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 типах