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 без подписи = открытая дверь для фишинга).
Сравнение в таблице
| Параметр | Polling | Webhook |
|---|---|---|
| Задержка | Период опроса (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 с платформой. Раз в сутки забрать новых контактов, обновить локальную базу.
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:
# 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% случаев, реконсиляция страхует оставшийся процент.
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 с четырьмя ступенями:
| Попытка | Задержка от первой | Зачем |
|---|---|---|
| 1 | 0 (сразу) | основная попытка |
| 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 в БД. Перед обработкой проверить, не было ли уже:
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: 1716392460Limit — сколько в текущем окне. Remaining — осталось. Reset — Unix-timestamp обнуления. На 429 добавляется:
Retry-After: 13Retry-After — это RFC 7231 §7.1.3, явная команда «не пытайся раньше чем через 13 секунд». Минимально жизнеспособный backoff — спать Retry-After при 429:
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 и экспоненциальным ростом:
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.Retry — backoff_factor и respect_retry_after_header. Готовое лучше своего, если не хочется поддерживать код.
Проактивный режим — не доводить до 429
Backoff по 429 реактивен — реагируем после превышения. Более зрелый паттерн — следить за X-RateLimit-Remaining и тормозить заранее:
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-лавину при первой нагрузке, с ним работает предсказуемо на любом тарифе.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Тест webhook-интеграции: симулятор, polling и backoff
Webhook, polling или симулятор: какой инструмент на каком этапе разработки
Production-ready POST: idempotency, retry и rate-limit
Тест безопасности webhook: симулятор и репетиция replay
