Попробовать API
</>
Чек-лист интеграции: идемпотентность, backoff, webhook
integraciyaИнструкция
12 мин

Чек-лист интеграции: идемпотентность, backoff, webhook

TL;DR: интеграция «работает на демо» и интеграция «держит прод» расходятся в трёх местах, которые обычно доделывают уже после первого инцидента — защита от дублей при retry, корректная реакция на rate-limit и осознанный выбор канала доставки событий. Разбираю каждый механизм отдельно: суть, рабочий код, где именно ломается. Всё на реальных механизмах Public API BOTIX v1.1.0 — ничего, чего нет в спецификации.

Коротко

  • Дубли лечит сервер, а не ваш код. Передавайте один и тот же Idempotency-Key на все retry одной логической операции: BOTIX кеширует успешный ответ на 24ч и на повторе отдаёт его же с заголовком Idempotent-Replayed: 1.
  • 429 — это не «повтори сразу», а «повтори по заголовкам». Читаем Retry-After и X-RateLimit-*, добавляем экспоненту и jitter; для batch-задач переходим на проактивный backoff и не доводим до 429 вообще.
  • Канал событий выбирается под сценарий, а не по вкусу. Webhook — основной паттерн (задержка в десятки мс, retry на стороне платформы), polling через cursor — fallback для локалки и cron-джобов.

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

Идемпотентность: один Idempotency-Key на всю логическую операцию

Первое, что ломается в проде, — дубли. Между «сервер обработал POST» и «клиент получил ответ» лежит сеть: TCP-сброс, таймаут балансировщика, обрыв у провайдера. Клиент ответа не увидел, но запрос дошёл и был выполнен. Клиент делает retry — и на сервере появляется второй заказ, второе списание, второе исходящее сообщение. Это не «if», это «when»: любая интеграция в реальной сети рано или поздно поймает сетевую ошибку между обработкой и ответом.

Идемпотентность — свойство операции, при котором повторное выполнение с теми же входными данными даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP. Для POST — нет: POST по определению создаёт ресурс, и голый повтор создаст второй. Индустриальный ответ — Idempotency-Key: уникальный идентификатор запроса, который клиент передаёт в заголовке и повторяет неизменным при retry.

В BOTIX заголовок Idempotency-Key поддерживается на мутирующих эндпоинтах — POST /public/v1/messages, POST /public/v1/scenarios/{id}/run, а также на bulk-эндпоинтах (POST /public/v1/contacts/bulk-create, /messages/bulk-send, /contacts/bulk-update). Механика простая и наблюдаемая: ответ на успешный запрос (200/201) кешируется в Redis под ключом papi:idem:{apiKeyId}:{key} на 24 часа. Повтор с тем же ключом не выполняет операцию заново — он возвращает сохранённый результат и добавляет заголовок Idempotent-Replayed: 1. Ошибки доставки не кешируются: если первая попытка упала на отправке в канал, повтор пойдёт по-настоящему.

bash
curl -X POST https://api.botix.pro/public/v1/messages \
  -H "Authorization: Bearer XXXXXXXX" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"contact_id": 12345, "text": "Заказ подтверждён"}'

Тонкое место, где интеграторы теряют идемпотентность, — слово «логическая». Ключ привязан к операции в логике вашего приложения, а не к одному HTTP-вызову:

  • Retry — тот же ключ. Отправляли одно сообщение, поймали таймаут, повторяете — это та же логическая операция, Idempotency-Key обязан быть тем же. Сгенерировали новый UUID на попытку — идемпотентность потеряна, вернулись к дублям.
  • Новая операция — новый ключ. Цикл по 100 контактам = 100 логических операций = 100 разных ключей. Один ключ на весь цикл склеит их в одну запись.
  • Ключ генерирует SDK, а не ваш код. SDK BOTIX (botix-pro/sdk для PHP, botix для Python, @botix/sdk для Node) автогенерируют Idempotency-Key через uuid4 на каждый мутирующий метод и переиспользуют его на встроенных retry. На голом HTTP-клиенте генерируйте UUIDv4 (или ULID) сами и держите его на всё время жизни попытки.

Чего в качестве ключа использовать нельзя: auto-increment из локальной БД (коллизит между средами), timestamp (два параллельных процесса получат одинаковый), хеш от тела запроса (совпадёт у двух разных операций с одинаковыми параметрами — два законных повторных списания одной суммы это две операции, не одна). Нужен случайный UUIDv4/ULID, уникальный именно для логической операции.

И про TTL: 24 часа — это потолок вашей retry-стратегии. Retry, пришедший через 30 часов, уже не защищён — ответ из кеша вытеснен, операция выполнится повторно. На стыке суток проще выдать новый ключ на новую попытку, чем закладываться на границу окна.

Rate-limit: backoff по X-RateLimit-* и Retry-After, а не «поймал 429 — повторил»

Второй частый провал — реакция на лимит. Любой публичный API режет число запросов на ключ: иначе один неоптимальный воркер парализует сервис для всех. Наивная логика «увидел 429 — отправил снова» превращается в лавину: сразу — второй 429, в цикле — десятый, параллельным воркером — шторм ретраев, который переживает все попытки rate-limiter'а вас удержать.

Корректная обработка строится на заголовках, которые платформа отдаёт в каждом ответе, а не только в 429. BOTIX (Public API v1.1.0) присылает три заголовка всегда и четвёртый — при превышении:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460
Retry-After: 13     # только в 429 (RFC 7231 §7.1.3)

Limit — сколько запросов в текущем окне, Remaining — сколько осталось, Reset — Unix-timestamp обнуления счётчика. Лимит берётся из тарифа (btx_plans.limits.api_rate_per_minute): триал — 30/мин, бизнес — 2000/мин; параллельно действует суточный api_rate_per_day, оба превышения дают RATE_LIMIT_EXCEEDED с HTTP 429. Счётчик живёт в Redis с TTL до конца текущей минуты. Отсюда два уровня обработки.

Реактивный backoff — минимум. Ловим 429, спим по Retry-After, добавляем экспоненту и jitter, чтобы параллельные воркеры не проснулись синхронно (это *thundering herd*):

python
import time, random, requests

def call_api_with_backoff(method, url, max_retries=5, **kwargs):
    attempt = 0
    while attempt < max_retries:
        response = requests.request(method, url, **kwargs)
        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))
        attempt += 1
    raise RuntimeError(f'Rate limit not cleared after {max_retries} retries')

2 ** attempt даёт экспоненциальный рост (1×, 2×, 4×, 8×…), jitter размазывает повторы по времени, min(…, 60) — потолок, чтобы не зависнуть, а падение после N попыток не даёт воркеру молча висеть при систематической проблеме. Не хочется поддерживать своё — у urllib3.util.Retry есть backoff_factor и respect_retry_after_header, у tenacitywait_exponential_jitter.

Проактивный backoff — зрелый уровень. Следим за X-RateLimit-Remaining в каждом ответе и притормаживаем до 429:

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:
        time.sleep(max(0, reset - time.time()))

Для batch-задач (импорт 10 000 контактов через bulk-эндпоинты) проактивный вариант даёт ровную скорость без всплесков и отскоков: при лимите 60/мин — стабильный ритм вместо серий из 60 запросов с двухминутными паузами. Нагрузка на платформу та же, но в форме, приятной для всех.

Что делать нельзя:

  • Не подменять Retry-After своей константой. Платформа знает точное время окна, вы — нет.
  • Не путать 429 с другими 4xx. 429 — «позже», 401 (INVALID_API_KEY/KEY_REVOKED) — «ключ невалиден, повтор бесполезен», 422 — «запрос кривой, повтор бесполезен». Retry только на 429 (и опционально 503 с Retry-After).
  • Не обходить лимит ротацией ключей. Ключи привязаны к project_id, лимит считается на уровне проекта; это нарушение условий использования. Нужно больше — это сигнал к смене тарифа, а не к костылю. Про хранение самих ключей и их антипаттерны — отдельный разбор про хранение API-ключей и OAuth vs API-key.

При десяти и более параллельных воркерах на одном ключе локальные счётчики в памяти расходятся с реальностью (воркер A думает, что осталось 47, воркер B уже потратил 50). Тогда обязателен централизованный счётчик — Redis или leaky-bucket перед вызовом API (aiolimiter в Python, bottleneck в Node). Для двух-трёх воркеров достаточно того, что каждый уважает заголовки. Тема пересекается с multi-tenant-интеграцией: один SDK на 100 ботов, где счётчики и ключи живут на всех клиентов сразу.

Webhook или polling: как получать события

Третий механизм — как узнавать о новых событиях на стороне платформы. Здесь нет «правильного по умолчанию», есть выбор под сценарий, и это не вопрос вкуса — способы дают разную задержку, разную нагрузку и разные требования к вашей инфраструктуре.

WebhookPolling
Задержкадесятки–сотни мс (сетевой round-trip)до периода опроса
Инфраструктурапубличный HTTPS-endpoint + проверка подписиничего, только исходящие запросы
Стоимость по запросамсобытие = один POST к вампустые опросы жгут rate-limit
Повторные доставкиесть (нужна идемпотентность на приёме)нет
Когда выбиратьпрод, реалтайм, много событийлокалка, cron-джобы, реконсиляция

Webhook — основной паттерн доставки в BOTIX. Подписка регистрируется через POST /public/v1/webhooks или в кабинете app.botix.pro/developers; вы указываете URL и типы событий. Из public-контроллеров эмитятся contact.created, contact.updated, contact.deleted, contact.tagged, message.sent, scenario.started. Доставку ведёт WebhookDispatcher через очередь в таблице btx_api_webhook_deliveries, которую разгребает cron cron-webhook-dispatcher.php (раз в минуту). Ретраи идут по расписанию 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов — до 5 попыток, после чего доставка помечается failed и видна в delivery-дашборде кабинета. Каждый запрос несёт три заголовка: X-Botix-Event (тип события), X-Botix-Signature (HMAC-SHA256 от тела через секрет подписки) и X-Botix-Request-Id (UUID доставки).

Проверка подписи — на каждом входящем, и здесь два места, где обычно ошибаются:

python
import hmac, hashlib

def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    got = header_sig.split('=', 1)[-1]  # "sha256=<hex>"
    return hmac.compare_digest(expected, got)

Считаем подпись по raw body, а не по перепарсенному и пересериализованному JSON — пересериализация меняет порядок ключей, и HMAC не сойдётся. Сравниваем compare_digest (timing-safe), не через ==.

Доставка гарантируется as-least-once: тот же webhook придёт повторно, если ваш сервер ответил 200, но соединение порвалось до того, как платформа это увидела. Значит, на приёме нужна дедупликация — по X-Botix-Request-Id:

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

Вставилась строка — событие новое, обрабатываем; ничего не вставилось — дубль, игнорируем. Тот же приём страхует от гонки двух ваших воркеров. Отвечать платформе стоит быстро (в пределах нескольких секунд): приняли, проверили подпись, положили в очередь, вернули 200 — тяжёлую обработку выносим в фон. Разбор такой асинхронной приёмки — в статье про очередь и event_id на входящих.

Polling — fallback через cursor-пагинацию. Эндпоинты GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts принимают cursor и возвращают meta.next_cursor. Клиент хранит последний cursor, опрашивает периодически, идёт по страницам вперёд до пустого next_cursor. Арифметика против polling как основного канала: опрос раз в секунду — это 86 400 запросов в сутки на ключ; при триальных 30/мин один воркер упрётся в потолок мгновенно, а растянуть период нельзя без потери отклика. Поэтому polling явно отмечен в developers.botix.pro/best-practices как fallback — для локальной разработки без туннеля и для CRM-джобов по своему расписанию.

Гибрид для дорогих событий. Если пропущенное событие стоит дорого (не отправили клиенту триггер по платёжному статусу), поверх webhook кладут periodic reconciliation: раз в час опрашиваем API через cursor и сверяем локальное состояние с платформой. Webhook закрывает 99% случаев в реалтайме, реконсиляция страхует остаток, если доставка потерялась после всех ретраев. Как этот выбор ложится на multi-tenant-архитектуру с OAuth — в разборе polling, webhook и OAuth на 100 ботов.

Простой алгоритм на старте: стабильный публичный endpoint и нужна задержка < 5 сек — webhook, без раздумий. Разработка на локалке — polling через cursor с периодом 30–60 сек, переход на webhook при выходе в прод. Запуск из cron раз в час — polling, webhook тут бессмысленен. И не сравнивайте их «по удобству на старте» без замера ожидаемого трафика и допустимой задержки: polling кажется проще на MVP, но первым ломается при масштабировании.

Сводный чек-лист перед релизом

Порядок сборки сверху вниз — что включить, чтобы к проду ни один пункт не остался «на потом»:

  1. Idempotency-Key на всех мутирующих POST — из SDK, повторяется неизменным при retry одной логической операции, укладывается в 24ч TTL. Проверка: один ключ дал один результат, повтор вернул Idempotent-Replayed: 1.
  2. 429 обрабатывается по Retry-After и X-RateLimit-*, с экспонентой и jitter; для batch — проактивный backoff по Remaining; retry только на 429.
  3. Канал доставки выбран под сценарий — webhook основной, polling через cursor fallback; на приёме webhook — подпись по raw body с compare_digest и дедуп по X-Botix-Request-Id.
  4. Наблюдаемость. В кабинете app.botix.pro/developers есть журнал запросов (btx_api_log: метод, путь, статус, response_time_ms, request_id UUID, хранение 90 дней) и delivery-дашборд по вебхукам с попытками и их статусами. X-Botix-Request-Id и request_id из лога — те идентификаторы, которые вы приложите к тикету, если что-то пойдёт не так.

Прогоните smoke-сценарий и сверьте все четыре пункта до выкатки — тогда первый прод-инцидент не будет про дубли, лавину ретраев или потерянное событие.