webhook-proxy

Промежуточный слой между нашими сервисами и вебхуками Discord. Он ставит события в очередь по цели, соблюдает лимиты 429 и повторяет ошибки 5xx, поэтому уведомления не теряются при внезапном трафике. Единая точка, через которую проходит вся исходящая коммуникация.

webhook-proxy
TL;DR

Единая точка выхода для всей исходящей коммуникации в Discord. Она ставит события в очередь по цели, уважает лимиты 429 с правильным backoff и повторяет ошибки 5xx вместо того, чтобы терять уведомления при всплеске трафика. Итог прост: событие не исчезает только из-за того, что случайно сработал rate limit.

Введение

Наши сервисы немало говорят в Discord: алармы мониторинга, уведомления о событиях, операционные логи. Каждое из них идёт через вебхук, а у вебхуков Discord жёсткие лимиты частоты. Пока трафик спокойный, этого никто не чувствует. Проблема начинается ровно тогда, когда уведомление нужнее всего - при всплеске, когда несколько сервисов шлют одновременно.

webhook-proxy появился, чтобы перестать лепить обработку лимитов в каждом сервисе по отдельности. Вместо этого вся исходящая коммуникация идёт через одну точку, которая берёт на себя доставку: ставит в очередь, ждёт когда надо, повторяет когда стоит и не теряет событие по пути. Для сервисов по ту сторону ничего не меняется - они шлют как раньше.

Проблема, которую видно только под нагрузкой

При спокойном трафике наивная отправка вебхука работает безупречно, и именно поэтому она коварна. Код стреляет запросом, получает 2xx, идёт дальше - и ни у кого нет повода подозревать, что что-то хрупко. Хрупок только путь ошибки, который при спокойном трафике почти никогда не срабатывает.

Достаточно всплеска, чтобы это вышло наружу. Несколько сервисов шлют уведомления одновременно, вы попадаете в лимит и получаете 429, либо Discord на мгновение отдаёт 5xx. В наивном подходе такое событие просто исчезает: код идёт дальше, исключение в лучшем случае попадает в лог, а в Discord не хватает записи, которая должна была там быть. Хуже всего то, что вы теряете его именно на пике трафика, то есть тогда, когда уведомления важнее всего.

Поэтому proxy - это не оптимизация производительности. Это гарантия доставки в условиях, где наивная отправка тихо подводит.

1
Очередь по цели

у каждого вебхука своя очередь, так что одна медленная цель не блокирует остальные.

2
Уважение к 429

при лимите proxy ждёт ровно столько, сколько говорит заголовок Retry-After, а не на глаз.

3
Повторы 5xx

ошибка сервера означает экспоненциальный backoff и очередную попытку, а не потерю события.

4
Жёсткая остановка на 4xx

неверный payload не станет верным с третьего раза, поэтому мы не тратим на него попытки.

Очередь, которая не блокирует остальных

Первое решение - отдельная очередь на каждую цель. Если бы все события шли через одну общую очередь, один медленный или заблокированный вебхук приостановил бы доставку ко всем остальным - одна забитая цель и тишина повсюду. Разделение очередей делает так, что проблема с одним вебхуком остаётся при этом одном вебхуке.

Та же структура даёт устойчивость к всплескам. Когда приходит волна, события не теряются - они попадают в очередь своей цели и ждут окна, в котором лимит отпускает. Снаружи это выглядит как временная задержка вместо потери, а это совсем другое качество: задержанное уведомление по-прежнему выполняет свою роль, потерянное не выполняет никакой.

Сердце: цикл доставки

Вся логика сводится к одному решению на ответ: успех завершает, 429 велит подождать, 5xx - повторить, прочие 4xx - конец без повтора. Различение этих случаев - это разница между proxy, который доставляет, и таким, который либо теряет события, либо бесконечно мелет неверный payload.

dispatch.ts · ts
async function dispatch(target: WebhookTarget, payload: Payload) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await post(target.url, payload);
    if (res.status < 300) return;
    if (res.status === 429) {
      await sleep(retryAfterMs(res));
      continue;
    }
    if (res.status >= 500) {
      await sleep(backoff(attempt));
      continue;
    }
    throw new PermanentError(res.status);
  }
}

Асимметрия: 429 против 5xx

Ключевое - асимметрия между 429 и 5xx. При 429 Discord сам говорит, сколько ждать - мы читаем это из заголовка и ждём ровно столько, не меньше и не больше. При 5xx никто ничего не говорит, поэтому мы добавляем экспоненциальный backoff, чтобы не добивать временно упавший сервер серией немедленных попыток.

!
Внимание

Самая частая ошибка - повторять всё подряд вслепую. На 429 повтор имеет смысл, но на 400 или 404 - нет: payload сам себя не починит, а вы лишь быстрее добираетесь до лимита и бесконечно мелете то, что никогда не пройдёт. Повторяйте то, что временное, и только это. Остальное должно упасть сразу, громко и один раз.

Эффект: всплеск, которого не видно по ту сторону

Для сервисов по ту сторону proxy невидим - они шлют ровно как раньше и ничего не знают о лимитах или повторах. Вся эта сложность сидит в одном месте, а не размазана по каждому сервису по отдельности. Это также значит, что правка в обработке лимитов идёт один раз, а не в несколько кодовых баз сразу.

Что proxy делает с ответом (иллюстративно)

2xx доставлено
92
429 подождать и повторить
5
5xx backoff и повторить
2
4xx жёсткая остановка
1

Разница видна лишь при следующей волне трафика, и в этом весь смысл. Уведомления не исчезают тихо в момент, когда они нужнее всего - они ждут в очереди и доходят, как только лимит отпускает. Одна точка выхода превратила тихую потерю событий максимум во временную задержку, и в этом вся разница между мониторингом, которому можно доверять, и таким, который молчит именно при пожаре.

Больше проектов

Другие работы из той же категории - посмотрите, как мы решаем похожие задачи.

Есть похожий проект?

Напишите нам - смета бесплатна и приходит в течение часа.