Попробовать API
</>
Webhook и polling: retry-политика, dead-letter и backoff по 429
webhooksРазбор
15 мин

Webhook и polling: retry-политика, dead-letter и backoff по 429

TL;DR: между polling и webhook чаще выбирают наугад — и платят потерей событий, лишними запросами или блокировкой ключа. Собрал критерии выбора, разобрал серверную retry-политику webhook (1m/15m/1h/24h + dead-letter) и показал, как клиент делает backoff по `X-RateLimit-*`, чтобы не уйти в 429-лавину. Всё с кодом на Python.

Коротко

  • Способ доставки — не вопрос вкуса. Polling выигрывает на редких событиях, локальной разработке и cron-job; webhook — на интерактивных сценариях и объёмах. Есть конкретные критерии, а не «как удобнее на старте».
  • Retry живёт на сервере платформы. BOTIX ретраит webhook по экспоненте (1m → 15m → 1h → 24h), потом кладёт в dead-letter с ручным повтором из дашборда. Идемпотентность по event_id — обязанность клиента, потому что доставка at-least-once.
  • Backoff по заголовкам — не опция. X-RateLimit-Remaining и X-RateLimit-Reset в каждом ответе позволяют тормозить заранее и не доводить до 429; на 429 — спать по Retry-After, а не константой.

Два способа получать события — и почему выбор не косметический

При подключении к публичному API за получением событий доступны два пути. Либо самому периодически дёргать API «что нового», либо подписаться на webhook и получать события push'ем. Внешне разница мелкая, в реальной эксплуатации — принципиально разная нагрузка, задержка и требования к инфраструктуре. Неправильный выбор обходится либо потерями событий, либо ненужными расходами, либо нарушениями rate-limit и блокировкой ключа.

Polling — GET «дай мне всё, что появилось после такой-то отметки». Клиент сохраняет cursor последнего обработанного события, через N секунд делает следующий запрос. Простота — главный плюс: не нужен публичный endpoint, HTTPS-сертификат, обработка ретраев. Воркер — while-loop с requests.get и cursor'ом. Обратная сторона: период опроса определяет лаг, а частый опрос быстро упирается в rate-limit.

Webhook — HTTP-запрос, который платформа отправляет на endpoint клиента в момент события. Задержка минимальная — десятки или сотни миллисекунд. Никакого периода опроса, никаких пустых запросов. Цена — инфраструктура: публичный endpoint, валидный HTTPS, обработка повторных доставок, проверка подписи (HMAC — отдельная тема, webhook без подписи = открытая дверь для фишинга).

Сравнение в таблице

ПараметрPollingWebhook
ЗадержкаПериод опроса (1-60 сек)Сетевой round-trip (десятки мс)
Нагрузка на APIПостоянная (пустые запросы)Только на реальные события
Публичный endpointНе нуженОбязателен
HTTPS-сертификатНе нуженОбязателен
За NAT/firewallРаботаетБез туннеля нет
Сложность обработчикаМинимальнаяПодпись + replay + идемпотентность
Риск пропустить событиеПри сбое cursor'аПри недоступности endpoint'а
Нагрузка на rate-limitВысокаяНизкая
Cron-job раз в часИдеальноИзлишен
Интерактивный ботНе подходит (задержка)Идеально
ТестированиеМинимальноСимулятор + туннель

Правило 1. Меньше 1 события в час — polling

Поток редкий, cron-job раз в час забирает накопленное. Поднимать webhook бессмысленно — полчаса на endpoint, HTTPS, подпись против пяти строк polling-цикла.

Типичный сценарий — ночная синхронизация CRM с платформой. Раз в сутки забрать новых контактов, обновить локальную базу.

python
cursor = read_last_cursor()
while True:
    response = client.contacts.list(cursor=cursor, cursor_limit=200)
    for contact in response.data:
        sync_to_crm(contact)
    if not response.has_more:
        save_cursor(response.next_cursor)
        break
    cursor = response.next_cursor

Правило 2. Больше 100 событий в час с задержкой меньше минуты — webhook

Интерактивный бот в Telegram. Пользователь пишет — бот должен реагировать за пару секунд. Polling раз в секунду даст лаг до секунды и 86 400 запросов в сутки — убьёт квоту, rate-limiter заблокирует ключ.

Webhook здесь — единственный разумный путь. От события до прихода — обычно меньше 100 мс. Endpoint вызывается только когда есть, что обрабатывать.

В BOTIX основной паттерн — webhook. Около 15 типов событий (message.created, chat.opened, contact.created, payment.confirmed и другие). Подписка — через POST /public/v1/webhooks или кабинет на app.botix.pro/developers. Прежде чем поднимать endpoint, стоит прогнать payload через webhook-симулятор — так обработчик проверяется до подключения канала.

Правило 3. Локальная разработка — polling даже там, где в проде webhook

На локальной машине публичный endpoint через ngrok — лишний шаг, ломается при перезапуске туннеля. Удобнее polling через cursor:

python
# dev mode
while True:
    response = client.chats.list(cursor=cursor, cursor_limit=20)
    for chat in response.data:
        handle_chat(chat)
    cursor = response.next_cursor
    if not response.has_more:
        time.sleep(5)

Когда обработчик готов — тот же handle_chat вызывается из webhook-endpoint вместо polling-loop. Бизнес-логика не меняется.

В BOTIX polling доступен как fallback через cursor-пагинацию: GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts с параметром cursor и полем meta.next_cursor в ответе.

Правило 4. Критичные события — webhook + periodic reconciliation

Зрелый гибрид. Основной канал — webhook (низкая задержка). Резервный — periodic reconciliation: раз в час клиент опрашивает API и сверяет своё локальное состояние с состоянием платформы. Webhook потерялся, обработчик упал, ответ 5xx ушёл в dead-letter — реконсиляция исправит.

Полезно когда последствия пропущенного события дорогие: не доставили триггерное сообщение по платёжному статусу, не уведомили о смене подписки. Webhook закрывает 99% случаев, реконсиляция страхует оставшийся процент.

python
last_full_sync = read_last_full_sync_time()
response = client.payments.list(since=last_full_sync, cursor_limit=200)
for payment in response.data:
    if not local_db.has_payment(payment.id):
        handle_payment(payment)

Правило 5. Один ключ — один способ потребления для одного типа событий

Не делать polling и webhook одновременно для одного типа событий. Получаешь webhook, обрабатываешь, через 30 секунд polling-цикл забирает то же самое — дубль. Защита от дублей (event_id в кеше) у вас есть, но создавать себе работу странно.

Разделяйте по типам. Например: интерактивные сообщения через webhook, ночная сверка платежей через polling.

Retry-политика webhook на стороне сервера

Выбрали webhook — теперь вопрос, что происходит между «платформа отправила POST» и «клиент принял». Между ними лежит длинный список случаев, когда что-то идёт не так: сервер клиента упал, выкатился релиз и endpoint временно недоступен, истёк сертификат, реверс-прокси отдал 503, DNS не разрешается, connect timeout. Что делает платформа в каждом из этих случаев — определяет, надёжна ли интеграция вообще.

Повторные попытки можно реализовать в двух местах, и они не взаимозаменяемы:

  • На стороне получателя — клиент принимает webhook, кладёт событие в свою внутреннюю очередь и обрабатывает асинхронно с собственными ретраями. Правильно для финальной обработки, но не спасает, если клиент вообще не ответил 200 (timeout, упавший сервер, 5xx) — webhook просто не попал в его очередь, ретраить нечего.
  • На стороне отправителя — платформа сохраняет каждый webhook в своей БД до подтверждения доставки. Ответ ≠ 2xx или timeout — повтор через интервал. Это покрывает ровно тот класс отказов, который клиент покрыть не может: «я физически не получил запрос».

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

В BOTIX используется экспоненциальный backoff с четырьмя ступенями:

ПопыткаЗадержка от первойЗачем
10 (сразу)основная попытка
2+1 минутавременный сбой сети / перегрузка
3+15 минуткороткий простой / рестарт
4+1 чассредний инцидент / выкатка релиза
5+24 часадлительный простой / выходные

После пятой попытки webhook переходит в dead-letter — отдельная запись без автоматических ретраев, доставку из неё запускают вручную. Последовательность покрывает большинство реальных сценариев: мгновенный сбой (5xx из-за релоада nginx) закроется в попытке № 2, короткий релиз — в № 3, часовой инцидент — в № 4, длительная авария — в № 5 или уйдёт в dead-letter. Общее окно — около 25 часов: достаточно, чтобы дежурный заметил проблему и починил сервер. Дольше держать webhook в очереди обычно бессмысленно — событие за пределами суток теряет актуальность.

Что считается успешной доставкой

Любой ответ 2xx (200, 201, 202, 204) — успех, webhook больше не ретраится.

Что считается неуспехом и ретраится:

  • Connect timeout (по умолчанию 10 сек).
  • Read timeout (по умолчанию 30 сек).
  • Любой 5xx (500, 502, 503, 504).
  • 429 Too Many Requests с заголовком Retry-After — следующая попытка берётся не из таблицы, а из заголовка.

Что не ретраится:

  • 4xx кроме 429 (400, 401, 403, 404). Клиент сам сказал «не приму» — ретраи бессмысленны, webhook сразу попадает в dead-letter.

Это важное правило. Если endpoint возвращает 401, потому что клиент сменил токен авторизации webhook и не обновил его у себя, — нет смысла пинговать дальше. Лучше зафиксировать в dead-letter и дать владельцу проекта увидеть проблему в дашборде.

Dead-letter и дашборд Webhook Delivery

Dead-letter — конечная точка для webhook'ов, которые не удалось доставить. Без неё события просто теряются, и о факте потери никто не узнаёт.

В BOTIX каждая попытка доставки логируется в btx_api_webhook_deliveries: целевой URL, статус ответа, текст ошибки, время, номер попытки. После исчерпания ретраев или 4xx-ответа создаётся запись со статусом dead_letter. В кабинете на /developers есть раздел Webhook Delivery — это и есть дашборд:

  • Список всех попыток за последние 14 дней с фильтром по статусу.
  • Каждая запись разворачивается: целевой URL, payload, HTTP-код ответа, тело ответа, request_id, время.
  • Кнопка «Повторить отправку» — отправляет тот же webhook повторно (с новым request_id). Нужна, когда сервер починили и надо догнать пропущенные события.
  • Кнопка «Перенаправить» — отправляет webhook на временный тестовый URL (например, встроенный инспектор на webhook.botix.pro). Нужна, чтобы посмотреть тело webhook'а в безопасном окружении, не дёргая прод.

Без дашборда dead-letter — просто строки в БД, которые никто не смотрит. С дашбордом — рабочий инструмент для разбора инцидентов.

Идемпотентность на стороне клиента

Ретраи неизбежно приводят к тому, что один и тот же webhook доставлен несколько раз. Клиент обработал событие, начал отвечать 200, но connection порвался до того как платформа получила ответ — платформа считает доставку неуспешной и ретраит, клиент получает дубль. Это нормально и неизбежно: at-least-once delivery — стандарт для webhook-механик. Защита от дублей — на стороне клиента.

Правильный способ — использовать уникальный идентификатор события (event_id в теле или заголовке X-Event-Id) и хранить уже обработанные id в БД. Перед обработкой проверить, не было ли уже:

sql
INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;

Если INSERT вставил строку — событие новое, обрабатываем. Если ничего не вставилось (конфликт по event_id) — это дубль, игнорируем. Тот же подход защищает и от дубля не из-за ретрая (например, два воркера одновременно подхватили задачу из очереди) — в обоих случаях одна и та же проверка.

Несколько правил вокруг обработки, которые стоит соблюдать:

  • Sequence не гарантируется. Две параллельные доставки одного типа могут прийти в порядке, отличном от порядка событий. Полагайтесь на timestamp в теле, а не на порядок получения.
  • Размер payload ограничен. В BOTIX — 64 КБ на webhook. Если данных больше — отправляется только id, полный объект клиент запрашивает через API.
  • Тип события — обязательное поле. Без него нельзя маршрутизировать. В BOTIX это поле event в теле (contact.created, payment.succeeded, subscription.cancelled), полный список — в документации.

Заголовки, которые webhook несёт помимо подписи, тоже полезны:

X-BOTIX-Signature: 7d4a8c...
X-Event-Id: evt_01HQRX...
X-Event-Type: payment.succeeded
X-Delivery-Attempt: 2
X-Request-Id: req_01HQRX...

X-Delivery-Attempt особенно полезен — сразу показывает, что это ретрай, а не первая попытка. И отдельно: не реализуйте «бесконечные ретраи с разумным backoff» в надежде, что когда-нибудь дойдёт. Размер очереди становится непредсказуемым (один долго лежащий клиент оставит миллионы записей), а запоздалый webhook о «заказ принят», доставленный через три дня, приносит больше вреда, чем пользы. Конечное окно (24 часа) — правильный компромисс: что не доставилось за сутки, разбирается вручную.

Backoff по X-RateLimit-*: чтобы не словить 429

Polling упирается в rate-limit быстро. Триальный тариф — 30/мин, бизнес — 2000/мин. Polling раз в секунду на триале съест лимит за две минуты. Но проблема шире polling'а: любой воркер, который ретраит запрос сразу после 429, получает второй 429, потом десятый, а несколько параллельных воркеров образуют лавину, переживающую все попытки rate-limiter'а.

Правильный клиент не игнорирует заголовки. BOTIX отдаёт их в каждом ответе (не только в 429):

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460

Limit — сколько в текущем окне. Remaining — осталось. Reset — Unix-timestamp обнуления. На 429 добавляется:

Retry-After: 13

Retry-After — это RFC 7231 §7.1.3, явная команда «не пытайся раньше чем через 13 секунд». Минимально жизнеспособный backoff — спать Retry-After при 429:

python
def call_api(method, url, **kwargs):
    while True:
        response = requests.request(method, url, **kwargs, timeout=(5, 30))
        if response.status_code != 429:
            return response
        time.sleep(int(response.headers.get('Retry-After', 1)))

Лучше «retry-immediately», но при нескольких параллельных воркерах — thundering herd: все упёрлись разом, получили Retry-After, проснулись синхронно, дали залп.

С jitter и экспоненциальным ростом:

python
import time, random, requests

def call_with_backoff(method, url, max_retries=5, **kwargs):
    for attempt in range(max_retries):
        response = requests.request(method, url, **kwargs, timeout=(5, 30))
        if response.status_code != 429:
            return response
        retry_after = int(response.headers.get('Retry-After', 1))
        jitter = random.uniform(0, retry_after * 0.3)
        backoff = retry_after * (2 ** attempt) + jitter
        time.sleep(min(backoff, 60))
    raise RuntimeError(f'Rate limit not cleared after {max_retries} retries')

2 ** attempt — экспоненциальный рост (1×, 2×, 4×, 8×). jitter — размазывает повторы между воркерами. min(…, 60) — потолок. У tenacity та же логика через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retrybackoff_factor и respect_retry_after_header. Готовое лучше своего, если не хочется поддерживать код.

Проактивный режим — не доводить до 429

Backoff по 429 реактивен — реагируем после превышения. Более зрелый паттерн — следить за X-RateLimit-Remaining и тормозить заранее:

python
def maybe_sleep(response):
    remaining = int(response.headers.get('X-RateLimit-Remaining', 1000))
    reset = int(response.headers.get('X-RateLimit-Reset', 0))
    if remaining < 5:
        wait = max(0, reset - time.time())
        time.sleep(wait)

Когда осталось меньше 5 запросов в окне — клиент спит до его обнуления, и 429 в нормальной работе не возникает совсем. Особенно полезно для batch: воркер пробегает 10 000 контактов, для каждого запрос. На 60/мин проактивный backoff даст ровные 1 RPS без всплесков. Без него — серии из 60 запросов с двухминутными паузами: нагрузка та же, но в неприятной форме.

Распределённый случай: один ключ, много воркеров

Один ключ — несколько воркеров. Локальный счётчик каждого не отражает реальной картины. Воркер A посчитал, что осталось 47, B параллельно сделал 50 — на платформе осталось –3, оба получают 429.

Решение — централизованный счётчик: Redis или leaky-bucket. У aiolimiter (Python) и bottleneck (Node) готовые реализации. Для 2-3 воркеров достаточно, что каждый уважает заголовки; при десяти и больше — централизованный обязателен.

Что не делать

Не ретраить 4xx кроме 429. 401 — «ключ невалиден, не поможет». 422 — «запрос некорректен, не поможет». Бесконечный долбёж = либо ключ отозвали (стоит понять, почему), либо вы упёрлись в quota tier.

Не подменять Retry-After константой. «Жди 5 секунд всегда» — устаревший подход. Платформа знает точное время, клиент — нет.

Не обходить rate-limit ротацией ключей. Нарушение условий у большинства платформ. В BOTIX ключи привязаны к project_id, лимит считается на уровне ключа в проекте. Нужно больше — смена тарифа.

Не делать polling раз в секунду на триале. Триал 30/мин = непрерывный 429. Период минимум 5 секунд с backoff. Если 5 секунд неприемлемы — переходите на webhook.

Что в BOTIX

X-RateLimit-* реализованы в PublicApiAuth middleware, лимит берётся из настроек тарифа (api_rate_per_minute: триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты. Заголовки присутствуют в каждом ответе Public API V1.1.0. Webhook-подпись передаётся в X-Botix-Signature (HMAC-SHA256) от тела запроса, серверный retry и dead-letter работают через WebhookDispatcher и cron-диспетчер раз в минуту, доставки видны в разделе Webhook Delivery на /developers.

SDK всех трёх языков (composer require botix-pro/sdk, pip install botix, npm install @botix/sdk) делают exponential backoff с jitter автоматически. На голом HTTP — заголовки X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After присутствуют, описанные алгоритмы работают без модификаций.

Итог

Выбор между polling и webhook — не вопрос вкуса, а конкретные критерии по нагрузке, задержке, инфраструктуре. Polling выигрывает на редких событиях, локальной разработке и cron-job. Webhook — на интерактивных сценариях и объёмах. Гибрид — для критичных событий, где webhook + reconciliation страхуют друг друга.

Выбрали webhook — помните, что retry живёт на сервере платформы (экспонента до суток + dead-letter + дашборд), а идемпотентность по event_id остаётся вашей обязанностью, потому что at-least-once delivery — свойство любой надёжной webhook-системы, не баг. Выбрали polling или просто активно ходите в API — обязательный элемент клиента backoff по X-RateLimit-*: без него интеграция уходит в 429-лавину при первой нагрузке, с ним работает предсказуемо на любом тарифе.