Попробовать API
</>
Тест webhook-интеграции: симулятор, polling и backoff
webhooksИнструкция
13 мин

Тест webhook-интеграции: симулятор, polling и backoff

TL;DR: обработчик webhook можно довести до готовности раньше, чем подключён канал. Через симулятор `webhook.botix.pro` гоняем реальный payload за 30 секунд, для прототипов берём polling через cursor, а на боевой нагрузке уважаем `X-RateLimit-*` и отступаем по `Retry-After`. Всё с готовыми curl и Python, которые запускаются прямо сейчас.

Коротко

  • Симулятор развязывает две задачи. Подключить канал и отладить приёмник — это разные шаги. Bin на webhook.botix.pro даёт временный URL и показывает метод, headers, body и подпись любого входящего запроса — обработчик тестируется до того, как заведён бот.
  • Polling или webhook — не вопрос вкуса, а замер трафика и задержки. Polling через cursor запускается где угодно без публичного endpoint'а, но упирается в rate-limit; webhook даёт задержку в десятки миллисекунд ценой публичного HTTPS-приёмника. В BOTIX webhook — основной паттерн, polling — задокументированный fallback.
  • Backoff — это чтение headers, а не «увидел 429 — повторил». BOTIX отдаёт X-RateLimit-Limit/Remaining/Reset в каждом ответе и Retry-After на 429. Правильный клиент притормаживает заранее по Remaining и не доводит до каскада ретраев.

Проблема: обработчик готов раньше, чем канал

Типичный сценарий разработки чат-бота. Разработчик пишет обработчик, который должен принимать webhook от мессенджера, валидировать подпись, разбирать JSON и класть событие в очередь. Чтобы убедиться, что код работает, нужен реальный входящий запрос. И тут начинается лишнее: завести аккаунт у мессенджера, получить токен, настроить бота, попросить кого-то отправить сообщение, дождаться доставки. Часы вместо минут.

Сам обработчик к каналу не привязан. Ему нужен корректно сформированный HTTP-запрос с теми же headers, тем же payload и той же подписью, что отправил бы прод. Никакого специального состояния «канал подключён» внутри обработчика нет — значит, весь блок подключения канала на этапе разработки лишний.

Дальше — три тезиса, каждый из которых закрывает свой слой: симулятор для отладки payload, polling через cursor для прототипов без публичного endpoint'а и backoff по rate-limit-headers для боевой нагрузки. Про то, почему webhook без HMAC-подписи — открытая дверь для фишинга, у нас отдельный разбор проверки подписи webhook (HMAC); здесь фокус на тестировании и нагрузке.

Тезис 1 — Симулятор: реальный payload за 30 секунд

Webhook-инспектор — это сервис, который выдаёт временный публичный URL и показывает всё, что в этот URL приходит: метод, путь, query-параметры, headers, body, время прихода. Разработчик подставляет этот URL в настройки источника, источник стучится — инспектор фиксирует request один в один. Дальше можно изучить структуру payload в браузере либо скопировать запрос как curl и воспроизводить его сколько угодно раз на своём localhost.

В BOTIX инспектор живёт на отдельном поддомене webhook.botix.pro. Заходим на главную, нажимаем «Create new URL» — получаем что-то вроде https://webhook.botix.pro/r/8f3a1c9b. TTL по умолчанию — 7 дней, после этого срока bin и все накопленные запросы удаляются автоматически.

bash
# Проверяем что bin отвечает
curl -X POST https://webhook.botix.pro/r/8f3a1c9b \
  -H "Content-Type: application/json" \
  -H "X-Botix-Signature: sha256=test" \
  -d '{"event":"message.created","text":"hello"}'

# Открываем https://webhook.botix.pro/r/8f3a1c9b в браузере — видим запрос

Никакой регистрации, никакого канала, никакого токена. Инспектор намеренно изолирован от бизнес-логики платформы: bin не знает, к какому проекту привязан, и не требует авторизации — чтобы его доступность не зависела от состояния остальной платформы. 30 секунд между «открыл редактор» и «увидел реальный payload».

Полная цепочка: тестовая отправка на свой обработчик

Инспектор закрывает первую половину задачи — увидеть структуру запроса. Вторую половину, отладку самого обработчика, закрывает тестовая отправка со стороны платформы. В кабинете на app.botix.pro/developers в разделе webhook delivery есть кнопка «Тестовая отправка» (эндпоинт POST /public/v1/webhooks/{id}/test): указываем URL обработчика, выбираем тип события (contact.created, contact.tagged, message.sent, scenario.started и другие), нажимаем — платформа шлёт payload известной структуры с настоящей HMAC-подписью в заголовке X-Botix-Signature, плюс X-Botix-Event и X-Botix-Request-Id.

Если запускаем обработчик локально без публичного URL, помогает туннель типа cloudflared:

bash
cloudflared tunnel --url http://localhost:8000
# Получаем https://random-name.trycloudflare.com — указываем как target

Локальный обработчик принимает реальный webhook со всеми заголовками. Если 200 OK — цепочка работает. Если 401 — проверка подписи валит. Если 5xx — внутренняя ошибка в обработчике.

Секрет для проверки X-Botix-Signature (HMAC-SHA256) возвращается один раз при создании подписки через POST /public/v1/webhooks — в поле data.secret. Его нужно сохранить на своей стороне сразу; повторно платформа его не покажет. На доставке у платформы есть встроенный retry по расписанию 30 с / 5 мин / 30 мин / 2 ч / 6 ч, максимум 5 попыток, после чего доставка помечается failed и видна в delivery dashboard. Подробный разбор серверной retry-политики и dead-letter — в retry-политике webhook: 1m/15m/1h/24h.

Тезис 2 — Polling или webhook: критерии выбора

У разработчика, которому нужно реагировать на события на стороне платформы, есть две принципиально разные опции: самому периодически дёргать API и спрашивать «что нового» (polling) либо подписаться на webhook и получать события push'ем. Внешне разница небольшая, но в проде это разная нагрузка, разная задержка и разные требования к инфраструктуре.

Polling — это запрос вида «дай мне всё, что появилось после такой-то отметки». Клиент запоминает позицию последнего обработанного события, ждёт N секунд, делает следующий запрос. Простота — главное достоинство: polling работает без публичного endpoint'а, без HTTPS-сертификата, без обработки повторных доставок. Недостаток — обратная сторона простоты: период опроса определяет лаг. Опрос раз в секунду — лаг до 1 секунды, но 60 запросов в минуту и 86 400 в сутки на ключ. Раз в минуту — лаг до 60 секунд, для интерактивного бота уже неприемлемо.

Webhook — HTTP-запрос, который платформа отправляет на endpoint клиента в момент события. Задержка минимальная: сетевой round-trip плюс время обработки, обычно десятки-сотни миллисекунд. Цена — инфраструктура: публичный endpoint, HTTPS с валидным сертификатом, обработка повторных доставок, проверка подписи на каждом запросе, тайм-аут ответа в пределах нескольких секунд.

Как в BOTIX

Webhook — основной паттерн доставки событий. Получатель регистрируется через POST /public/v1/webhooks либо в кабинете на app.botix.pro/developers. Платформа поддерживает события contact.created, contact.updated, contact.deleted, contact.tagged, message.sent, scenario.started (эмитятся из public-контроллеров при мутирующих операциях); источники message.received, chat.opened, scenario.completed подключаются из bot-движка.

Polling доступен как fallback через cursor-пагинацию: эндпоинты GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts поддерживают параметр cursor и возвращают meta.next_cursor для следующей страницы.

python
import os
import time
import requests

API = "https://api.botix.pro/public/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BOTIX_API_KEY']}"}

def poll_messages():
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor

        r = requests.get(f"{API}/messages", headers=HEADERS, params=params)
        data = r.json()

        for msg in data["data"]:
            process_message(msg)

        cursor = data["meta"]["next_cursor"]
        if not cursor:
            time.sleep(30)  # окно опроса

Cursor запоминается между итерациями, пустой next_cursor означает «новых событий нет, ждём». Никакого публичного endpoint'а, никакой проверки подписи, никаких ретраев. Работает в Docker-контейнере, в Lambda, на ноутбуке.

В чистом виде polling в BOTIX не рекомендуется как основной способ доставки и явно отмечен в документации developers.botix.pro/best-practices как fallback. Причина проста: при росте числа клиентов один опрашивающий воркер быстро упрётся в rate-limit (X-RateLimit-Limit per minute зависит от тарифа), а растягивать период опроса нельзя без потери качества интеграции. Поэтому следующий тезис — научить клиента уважать лимиты.

Простой алгоритм выбора

  • Есть стабильный публичный endpoint и нужна задержка меньше 5 секунд — webhook, без раздумий.
  • Разработка на локальной машине, публичный endpoint поднять сложно — polling через cursor с периодом 30-60 секунд, пока идёт разработка, и переход на webhook в момент выхода в прод.
  • Интеграция запускается из cron-job раз в час — polling, webhook здесь бессмысленен.

И отдельный пункт: никогда не сравнивайте polling с webhook по «удобству на старте», не сделав замер ожидаемого трафика и допустимой задержки. Polling часто кажется проще на MVP, но при масштабировании первым ломается. В зрелых интеграциях иногда применяют гибрид: основной поток — webhook, резервный — periodic reconciliation раз в час, которая сверяет локальное состояние с платформой и подхватывает потерянные события. Полное сравнение критериев — в polling vs webhook: 5 правил выбора.

Тезис 3 — Backoff по X-RateLimit-*: клиент, который уважает лимиты

Любой публичный API ограничивает число запросов на ключ за единицу времени. Стандартная реакция платформы на превышение — HTTP 429 Too Many Requests. Самый частый антипаттерн — увидеть 429 и ретраить сразу: повторил сразу — получил второй 429, в цикле — десятый, параллельным воркером — лавину ретраев. Корректная обработка rate-limit — это операции над headers, которые платформа отдаёт в каждом ответе, не только в 429.

В BOTIX в каждом ответе присутствуют четыре заголовка:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460
Retry-After: 13   (только при 429)

Limit — допустимое число запросов в текущем окне. Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. Retry-After платформа добавляет к 429-ответу — это RFC 7231 §7.1.3. Лимит берётся из тарифа (api_rate_per_minute, от 30/мин на триале до 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты.

Шаг 1. Минимальный backoff на Retry-After

Минимально жизнеспособная реализация — читать Retry-After при 429 и спать столько, сколько сказала платформа:

python
def call_with_retry(method, url, max_retries=5, **kwargs):
    import random
    for attempt in range(max_retries):
        r = requests.request(method, url, headers=HEADERS, **kwargs)
        if r.status_code != 429:
            return r
        retry_after = int(r.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("Rate limit not cleared")

2 ** attempt даёт экспоненциальный рост (1×, 2×, 4×, 8×, 16×), jitter размазывает повторы по времени и спасает от thundering herd, когда все параллельные воркеры разом получают Retry-After: 13 и разом просыпаются, min(..., 60) — потолок, чтобы воркер не зависал. После N неудачных попыток функция падает с ошибкой — это важно, чтобы при систематической проблеме воркер не висел молча. В tenacity та же логика встроена через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retry есть backoff_factor и respect_retry_after_header.

Шаг 2. Проактивный режим — без 429

Бэкофф по 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)

r = requests.get(url, headers=HEADERS)
maybe_sleep(r)
# следующий запрос идёт уже после паузы

Когда осталось меньше 5 запросов в окне — клиент сам спит до обнуления. 429 в нормальной работе не возникает вовсе. Для batch-обработки 10 000 контактов при лимите 60/мин это даёт ровную скорость 1 запрос в секунду без всплесков; без проактивности были бы серии из 60 запросов с двухминутными паузами — нагрузка та же, но в неприятной для всех форме.

Шаг 3. Симулируем нагрузку и распределённый случай

Локально проверить backoff проще всего циклом из 200 запросов с подсчётом 429-ответов. После прогона смотрим журнал на app.botix.pro/developers: лимит срабатывал, клиент корректно отступал. Если в журнале каскад 429 без пауз — backoff не работает, нужно проверить, что Retry-After действительно читается.

Когда один ключ используется несколькими воркерами параллельно, локальный счётчик в памяти каждого не отражает реальной картины: воркер A посчитал, что осталось 47, воркер B параллельно сделал ещё 50 — на платформе осталось –3, и оба получают 429. Решение — централизованный счётчик: Redis либо leaky-bucket-лимитер перед обращением к API (aiolimiter в Python, bottleneck в Node). Для двух-трёх воркеров достаточно того, что каждый уважает headers; при десяти и больше — централизованный счётчик обязателен. И не обходите rate-limit ротацией ключей: в BOTIX ключи привязаны к project_id, лимит считается на уровне ключа в рамках проекта — если нужно больше, это сигнал к смене тарифа, а не к обходу.

Подводные камни

raw body для проверки подписи. Если фреймворк парсит JSON и сериализует обратно — порядок ключей и пробелы меняются, HMAC не сходится. В Express нужен bodyParser.raw, в Flask — request.get_data(), в Laravel — $request->getContent().

compare_digest вместо ==. Сравнение подписей через == уязвимо к timing-атакам. В Python — hmac.compare_digest, в Node — crypto.timingSafeEqual, в PHP — hash_equals. SDK BOTIX делают это автоматически.

Не путать 429 с другими 4xx. 429 — «попробуй позже», 401 — «ключ невалиден, повторять бесполезно», 422 — «запрос некорректен». Retry должен срабатывать только на 429 (и опционально на 503 с Retry-After), иначе воркер бесконечно долбит заведомо мёртвый запрос.

Конечное число retry. Бесконечный цикл с экспоненциальным бэкоффом превращается в зависший воркер при систематической проблеме. После N неудачных попыток функция падает с ошибкой, которая попадает в мониторинг.

Итог

Тестирование чат-интеграции до подключения канала — это три инструмента в одной связке: webhook-инспектор для расшифровки структуры запроса, тестовая отправка для проверки полной цепочки и polling через cursor для прототипов без публичного endpoint'а. Когда обработчик готов, на боевой нагрузке остаётся последний слой — backoff по X-RateLimit-*, без которого первая же серия запросов упирается в 429 и каскадные ретраи.

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