Idempotency-Key, rate-limit и audit-лог публичного API
TL;DR: три механизма, без которых публичный API не выходит в прод под корпоративную нагрузку — защита от двойной отправки (`Idempotency-Key`), наблюдаемость лимитов (`X-RateLimit-*` + backoff) и журнал запросов в кабинете. Каждый по отдельности выглядит как «инженерная гигиена», вместе они закрывают весь цикл жизни запроса. Разбираю, как они устроены в Public API BOTIX v1.1.0 — с заголовками, TTL, кодами ошибок и именами таблиц.
Коротко
- Idempotency-Key делает retry безопасным. Сервер дедуплицирует повтор по ключу и отдаёт сохранённый ответ, а не создаёт второй заказ/сообщение. В BOTIX — TTL 24 часа, при повторе заголовок
Idempotent-Replayed: 1, кешируется только успех. - Rate-limit — это не «увидел 429, повторил сразу». Это backoff по заголовкам
X-RateLimit-*иRetry-After: exponential с jitter реактивно, а лучше — проактивно поX-RateLimit-Remaining, чтобы 429 вообще не наступал. - Audit-лог в кабинете превращает отладку из тикета в поддержку (полдня) во взгляд в журнал (15 секунд): метод, статус,
request_id, длительность. Webhook-доставки — отдельной вкладкой.
Три механизма проектировались как единая система и закрывают весь цикл «запрос вошёл → обработали → ответили → ошиблись → повторили → нашли в логе». Ниже — каждый по отдельности и связь между ними.
Механизм 1. Idempotency-Key против дублей при retry
Картина, знакомая каждому, кто отлаживал интеграцию с платёжным шлюзом. Клиент отправляет POST, в сетевом канале сбой — TCP-сброс, таймаут балансировщика, обрыв у провайдера. Клиент не получил ответа, но запрос дошёл до сервера и был успешно обработан. Клиент делает retry. На сервере появляется второй заказ, второе списание, второе исходящее сообщение. Дубль.
Идемпотентность — это свойство операции, при котором повторное выполнение с теми же входными данными даёт тот же результат, без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP (теоретически). POST по определению создаёт ресурс, и без дополнительной защиты повторный вызов создаст второй. Индустриальный стандарт решения — Idempotency-Key: клиент перед отправкой POST генерирует случайный UUID (назовём его K), подставляет его в заголовок и при retry повторяет тот же запрос с тем же K. Сервер сначала смотрит в хранилище: был ли уже запрос с этим K. Если был — возвращает сохранённый ответ первой попытки, без повторного выполнения. Если не был — выполняет запрос, сохраняет ответ под ключом K и отдаёт клиенту.
В BOTIX заголовок Idempotency-Key поддерживается на мутирующих POST-эндпоинтах — создание контакта, отправка сообщения, bulk-операции, запуск сценария (POST /scenarios/{id}/run). Формально заголовок необязателен (required: false, maxLength: 255), но по контракту production-интеграции его стоит передавать на каждой мутации: опциональная идемпотентность — это идемпотентность только для дисциплинированных интеграторов, остальные забудут, и в проде появятся дубли. Лучше сгенерировать лишний UUID, чем тихо производить мусор в БД.
curl -X POST https://api.botix.pro/public/v1/messages \
-H "Authorization: Bearer XXXXXXXX" \
-H "Idempotency-Key: 0c2f8e4a-1b3d-4f5c-9e7a-8b6d2f5c1e3a" \
-H "Content-Type: application/json" \
-d '{"contact_id": 12345, "text": "Заказ подтверждён"}'Повтор этого запроса с тем же Idempotency-Key в течение 24 часов не создаст второе сообщение — вернётся сохранённый ответ первой попытки с дополнительным заголовком Idempotent-Replayed: 1. По нему клиент видит, что ответ пришёл из кеша, а не из повторного выполнения.
Тонкости реализации, которые часто упускают:
- TTL хранилища — 24 часа. Это компромисс между «не потерять идемпотентность для долгих retry-стратегий» и «не раздувать хранилище». Платёжные системы обычно ставят 24–72 часа. Меньше суток опасно: retry может прийти через 6 часов, а ответ уже забыт. В BOTIX ключ живёт в Redis (
papi:idem:{apiKeyId}:{key}) ровно 24 часа; после истечения тот жеIdempotency-Keyсоздаст новую операцию — это ожидаемое поведение, и клиент должен использовать свежий K для повторов в новый календарный день. - Кешируется только успех. В BOTIX под ключом сохраняется только успешный результат (2xx), а не ошибки доставки. Это важное свойство: если первая попытка упала на стороне канала, retry с тем же K не «застрянет» на сохранённой ошибке, а попробует доставить снова.
- Ключ привязан к логической операции, а не к HTTP-вызову. Это место, где интеграторы ошибаются чаще всего. Если код в цикле отправляет 100 контактов — каждый контакт отдельная логическая операция, и для каждого нужен свой K. Если при отправке одного контакта случился таймаут и вы делаете retry — это та же операция, и K должен быть тем же. Сгенерируете новый UUID на каждую попытку — идемпотентность потеряна, и вы вернулись к исходной проблеме дублей.
- Что нельзя брать за K. Auto-increment ID из локальной БД — не уникален между средами. Timestamp — два параллельных процесса получат одинаковый. Хеш от тела запроса — совпадёт при двух *разных* логических операциях с одинаковыми параметрами (два повторных списания одной суммы — это две операции, не одна). Берите UUIDv4 или ULID: K уникален для операции, а не для входных данных.
- Где генерировать K — в SDK, не в пользовательском коде. SDK автогенерируют
Idempotency-Keyчерез uuid4 на каждый мутирующий метод — разработчик, который работает через SDK, об идемпотентности не думает, она встроена. Если интеграция пишется на голом HTTP-клиенте — генерируйте UUIDv4 до отправки и переиспользуйте его для всех retry одной операции. - Bulk — отдельная семантика.
POSTbulk-контактов/сообщений принимает до 100 item за раз, каждый в своей мини-транзакции (частичный успех допустим, см. поляcreated/failed/results). При этом bulk-вызов сIdempotency-Keyзащищён от дубля целиком, как одна операция.
Когда без ключа формально можно обойтись: GET — по семантике HTTP; PUT/DELETE — идемпотентны по определению. Но если PUT триггерит побочный эффект (email при смене статуса) или DELETE возвращает 404 на уже удалённый ресурс — наблюдаемый результат перестаёт быть идемпотентным, и защитный ключ имеет смысл и здесь. Вывод: идемпотентность — не «опция надёжности на потом», а базовое свойство контракта POST-эндпоинта. Любая интеграция в реальной сети столкнётся с сетевыми ошибками — это не if, а when.
Механизм 2. X-RateLimit-* заголовки и backoff
Любой публичный API ограничивает число запросов на ключ за окно, иначе один неоптимальный воркер парализует сервис для всех. Стандартная реакция на превышение — 429 RATE_LIMIT_EXCEEDED. Видя 429, многие SDK «повторяют запрос», и здесь начинается недоразумение: повторить сразу — второй 429, в цикле — десятый, параллельным воркером — лавина ретраев, которая переживёт любые попытки лимитера удержать клиента.
Корректная обработка rate-limit — не «увидел 429, попробовал снова», а операции над заголовками, которые платформа отдаёт в каждом ответе. Индустриальный стандарт (Stripe, GitHub, Cloudflare) — три заголовка в каждом ответе и четвёртый в 429:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460
Retry-After: 13 # только в 429Limit — сколько запросов допускается в текущем минутном окне по тарифу. Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. Retry-After — явная команда платформы (RFC 7231 §7.1.3), присутствует только в 429.
В BOTIX все четыре заголовка реализованы в PublicApiAuth middleware (app/Middleware/PublicApiAuth.php). Лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты. Остаток лимита можно посмотреть и явно — служебный GET /me возвращает контекст ключа вместе с остатком rate-limit. Важное послабление: bulk-операция считается в лимите как 1 запрос (а не 100) — сознательное поощрение bulk-вызовов.
Минимально жизнеспособный backoff — читать Retry-After при 429 и спать столько, сколько сказала платформа:
def call_api(method, url, **kwargs):
while True:
response = requests.request(method, url, **kwargs)
if response.status_code != 429:
return response
time.sleep(int(response.headers.get('Retry-After', 1)))Это уже лучше «retry-immediately», но при нескольких параллельных воркерах возникает *thundering herd*: все разом упёрлись в 429, все разом получили Retry-After: 13, все разом проснулись и выдали новый залп. Чтобы этого избежать, добавляют случайную компоненту (jitter) и экспоненциальный рост задержки при повторных 429:
import time
import random
def call_with_backoff(send_fn, max_retries=5):
attempt = 0
while attempt < max_retries:
response = send_fn()
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×, 16×), jitter размазывает повторы по времени, min(…, 60) — потолок, чтобы воркер не завис. После N неудач функция падает с ошибкой — это важно, чтобы при систематической проблеме воркер не висел молча. Готовое, как правило, лучше своего: у tenacity логика встроена через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retry есть backoff_factor и respect_retry_after_header.
Зрелый паттерн — проактивный 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:
wait = max(0, reset - time.time())
time.sleep(wait)Когда осталось меньше 5 запросов — клиент спит до обнуления окна, и 429 в нормальной работе не наступает вовсе. Особенно полезно для batch: воркер по 10 000 контактов при лимите 60/мин получает ровную скорость 1 req/sec, без всплесков и отскоков.
Распределённый случай: когда один ключ используется параллельно многими воркерами, локальный счётчик в памяти каждого не отражает реальной картины — воркер A думает, что осталось 47, а B параллельно сделал ещё 50, и оба ловят 429. Решение — централизованный счётчик (Redis или leaky-bucket перед вызовом API): у aiolimiter (Python) и bottleneck (Node) есть готовые реализации. Для двух-трёх воркеров хватает того, что каждый уважает заголовки; при десяти и больше — централизованный счётчик обязателен.
Чего не делать: не игнорировать Retry-After и не подменять его константой — платформа знает точное время следующего окна, клиент не знает. Не путать 429 с другими 4xx: 429 — «попробуй позже», 401 INVALID_API_KEY — «ключ невалиден, повторять бесполезно», VALIDATION_ERROR — «запрос некорректен». Retry срабатывает только на 429 (и опционально на 5xx с Retry-After). Не обходить лимит ротацией ключей — лимит считается на уровне ключа в рамках project_id, это нарушение условий; нужно больше — это сигнал к смене тарифа. Контракт ошибок целиком, чтобы клиент отличал «повторяемое» от «бесполезно повторять», разбираем отдельно — в Observability API: контракт ошибок, лимиты, журнал.
Механизм 3. Audit-лог API в кабинете
Команда интегратора обнаруживает: контакт не создался, webhook не пришёл. В своих логах видит, что запрос ушёл. Дальше нужно понять: дошёл ли до API; что ответил сервер; если ошибка — какая. Без публичного журнала единственный путь — тикет в поддержку «у нас что-то не работает, посмотрите на своей стороне». Это растягивает диагностику с 15 секунд (взгляд в журнал) до полудня (тикет, переписка, эскалация), и всё это время у интегратора стоит прод.
Минимальный набор полей записи, который закрывает 90% задач отладки:
- Время запроса с миллисекундами.
- HTTP-метод и путь (без секретных значений в query).
- Код ответа, размер ответа,
response_time_ms— длительность обработки на сервере. request_id— UUID запроса, который возвращается клиенту (для webhook-доставок — в заголовкеX-Botix-Request-Id).- IP-адрес и user-agent отправителя.
- Идентификатор API-ключа (в кабинете — по префиксу из 12 символов, не сам ключ).
В BOTIX это таблица btx_api_log: метод, endpoint, статус-код, IP, user-agent, response_time_ms, request_id. Управление и статистика — в кабинете проекта на app.botix.pro, раздел настроек «API-ключи» (/settings?tab=api-keys): детальная статистика по ключу — график за 30 дней, топ используемых эндпоинтов, доля ошибок. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog/Grafana; если поставщик отдаёт её — одна задача снимается. Если интегратор пишет «не создался контакт, вот этот request_id» — запрос находится одной командой, вместе с кодом ответа и причиной.
Чем это отличается от server-side логов: серверный лог поставщика — это лог инфраструктуры, где вперемешку запросы всех клиентов, внутренние вызовы и периодические задачи, и доступ к нему есть только у сотрудников. Журнал в кабинете — вид, отфильтрованный по project_id, с удалёнными чувствительными полями (полные тела запросов и ответов не хранятся — это типичный источник утечек персональных данных через UI) и с удобной фильтрацией: по HTTP-методу, по статусу (2xx / 4xx / 5xx по отдельности), по пути с подстановкой через звёздочку, поиск по request_id.
Webhook delivery — отдельная вкладка. Когда webhook-endpoint не получает событий, причин несколько: платформа не сгенерировала событие; сгенерировала, но получила timeout при отправке; получила 5xx и поставила в retry; отдала 2xx, но интегратор перепутал тело. Для исходящих попыток доставки места в журнале входящих нет, поэтому webhook-доставки вынесены отдельно — таблица btx_api_webhook_deliveries (webhook_id, event, payload, scheduled_at, attempt, status = pending/delivered/failed, last_response_code, last_error). Для каждой попытки видно: event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа, номер попытки, время следующего retry. Расписание ретраев на стороне сервера — 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов; после 5 неудач статус переводится в failed. Интегратор открывает свой endpoint, видит, что вернул 500 на седьмое событие подряд, и понимает, что ошибка на его стороне — без этого дашборда тот же цикл занимает день. Защиту webhook от перехвата и повторов (HMAC + timestamp/nonce) разбираем в Replay-защита webhook: подпись, timestamp, audit.
Retention и compliance. В BOTIX btx_api_log хранится 90 дней, дальше — архивация (пока не автоматизирована, TODO). 90 дней хватает не потому, что аудитор смотрит на старые запросы ежедневно, а потому что инцидент может всплыть через два месяца — и тогда журнал должен быть на месте. Для более длинного compliance-retention (SOC 2, ISO 27001, 152-ФЗ, GDPR требуют иногда год и дольше) индустриальное решение типовое: горячий журнал с быстрой фильтрацией в основной БД + архивный лог в холодном хранилище (S3 или аналог) по запросу. Отдельный нюанс — кто вправе скачать архивную выгрузку: по 152-ФЗ она содержит персональные данные субъектов (как минимум IP-адреса), поэтому «кнопка скачать за год» не годится — нужен контролируемый flow с подтверждением владельца и записью подтверждения в audit-trail платформы.
Доступ к журналу — на уровне владельца проекта, не агентов и операторов: записи содержат чувствительные для безопасности метаданные. Управление ключами и разграничение доступа как часть UX разбираем в API-ключи, audit-лог и OAuth: безопасность как UX. И главное — journal не прячут за платным тарифом: это инструмент, без которого интеграция страдает на любом тарифе, поэтому он доступен уже с триала.
Как три механизма связаны
Запрос → middleware (PublicApiAuth):
1. Проверяем rate-limit (X-RateLimit-* headers в ответ)
2. Проверяем Idempotency-Key (если есть и в окне 24ч — поднимаем
кешированный ответ + Idempotent-Replayed: 1)
3. Выполняем
4. Логируем в btx_api_log (request_id, метод, endpoint, статус, response_time_ms)
5. Возвращаем ответ
Webhook delivery → отдельный воркер (cron-webhook-dispatcher):
1. Подписываем тело HMAC-SHA256 секретом подписки
2. Шлём POST на endpoint клиента (X-Botix-Event, X-Botix-Signature, X-Botix-Request-Id)
3. Логируем в btx_api_webhook_deliveries (attempt, status, last_response_code)
4. При не-2xx — retry: 30с / 5м / 30м / 2ч / 6ч, после 5 неудач → failed
5. Дашборд показывает все попытки клиентуЗапрос с Idempotency-Key попадает в btx_api_log вместе с этим ключом — клиент находит оригинал и его retry в одном поиске. Заголовки X-RateLimit-* в момент инцидента показывают, был ли клиент близок к лимиту. request_id — та строка, которую интегратор пишет в поддержку, и по ней запрос находится мгновенно. Три механизма не независимы: идемпотентность делает retry безопасным, rate-limit заставляет retry быть вежливым, а audit-лог позволяет увидеть, что именно произошло — в терминах API, а не догадок.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Observability публичного API: ошибки, лимиты, журнал
API-ключи, audit-логи и OAuth 2.0: безопасность как часть UX
Три грабли в webhook-receiver: HMAC, replay, audit-лог
HMAC-подписи, X-RateLimit и защита от replay: три уровня защиты API и edge cases из прода
