Чек-лист интеграции: идемпотентность, 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. Ошибки доставки не кешируются: если первая попытка упала на отправке в канал, повтор пойдёт по-настоящему.
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*):
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, у tenacity — wait_exponential_jitter.
Проактивный backoff — зрелый уровень. Следим за X-RateLimit-Remaining в каждом ответе и притормаживаем до 429:
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: как получать события
Третий механизм — как узнавать о новых событиях на стороне платформы. Здесь нет «правильного по умолчанию», есть выбор под сценарий, и это не вопрос вкуса — способы дают разную задержку, разную нагрузку и разные требования к вашей инфраструктуре.
| Webhook | Polling | |
|---|---|---|
| Задержка | десятки–сотни мс (сетевой 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 доставки).
Проверка подписи — на каждом входящем, и здесь два места, где обычно ошибаются:
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:
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, но первым ломается при масштабировании.
Сводный чек-лист перед релизом
Порядок сборки сверху вниз — что включить, чтобы к проду ни один пункт не остался «на потом»:
Idempotency-Keyна всех мутирующих POST — из SDK, повторяется неизменным при retry одной логической операции, укладывается в 24ч TTL. Проверка: один ключ дал один результат, повтор вернулIdempotent-Replayed: 1.- 429 обрабатывается по
Retry-AfterиX-RateLimit-*, с экспонентой и jitter; для batch — проактивный backoff поRemaining; retry только на 429. - Канал доставки выбран под сценарий — webhook основной, polling через
cursorfallback; на приёме webhook — подпись по raw body сcompare_digestи дедуп поX-Botix-Request-Id. - Наблюдаемость. В кабинете
app.botix.pro/developersесть журнал запросов (btx_api_log: метод, путь, статус,response_time_ms,request_idUUID, хранение 90 дней) и delivery-дашборд по вебхукам с попытками и их статусами.X-Botix-Request-Idиrequest_idиз лога — те идентификаторы, которые вы приложите к тикету, если что-то пойдёт не так.
Прогоните smoke-сценарий и сверьте все четыре пункта до выкатки — тогда первый прод-инцидент не будет про дубли, лавину ретраев или потерянное событие.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Multi-tenant, polling vs webhook и OAuth для агентства
Multi-tenant интеграция BOTIX: ключи, события, изоляция
OAuth, хранение ключей и white-label в партнёрском API
Third-party на OAuth: multi-tenant и хранение токенов
