Попробовать API
</>
Observability публичного API: ошибки, лимиты, журнал
nadezhnostРазбор
15 мин

Observability публичного API: ошибки, лимиты, журнал

**TL;DR.** Интегратор не видит наш сервер. Всё, что у него есть для отладки — код ответа, тело ошибки, заголовки `X-RateLimit-*` и журнал запросов в кабинете. Если хоть один из трёх слоёв непрозрачный, интеграция ломается в первый же production-инцидент, а дальше — либо тикет с разбором по часам, либо смена платформы. Разбираю все три слоя в Public API BOTIX v1.1.0: формат `ErrorResponse`, набор rate-limit заголовков с корректным backoff и audit-лог в кабинете — с фрагментом `openapi.yaml` и рабочим кодом на Python.

Коротко

  • Контракт ошибок — часть публичного API. Тело ошибки обязано нести машиночитаемый code, человекочитаемый message, контекстный details и request_id; список из 16 кодов зафиксирован на developers.botix.pro/error-codes, его изменение — breaking change.
  • 429 — это не сигнал «повтори сразу». Корректный backoff читает Retry-After и X-RateLimit-Remaining, добавляет jitter и экспоненту; проактивный режим держит ровную скорость и вообще не доводит до 429.
  • Audit-лог в кабинете — инструмент самообслуживания интегратора, а не диагностика поставщика. Отфильтрованный по project_id вид с request_id, отдельная вкладка webhook-доставок и KPI-блок сокращают отладку с полудня до пятнадцати секунд.

Когда сервис публикует API, у внешнего интегратора нет доступа к серверным логам, метрикам и трассировкам. У него ровно три точки наблюдения: HTTP-ответ (статус, тело, заголовки) — главный канал диагностики на одном запросе; rate-limit состояние — где клиент находится относительно лимита прямо сейчас, без чего невозможен нормальный backoff; и журнал в кабинете — что ушло на сервер, что ответил сервер, когда и с какого IP. Если хоть один слой сделан плохо, интегратор обнаружит это в первый же production-инцидент. Ниже — каждый слой отдельно.

Слой 1. Контракт ошибок: что обязано быть в ErrorResponse

Самый частый антипаттерн — отдать 500 Internal Server Error с пустым телом или фразой Something went wrong. Это не ошибка, это объявление, что следующие несколько часов интегратор работает вслепую: что произошло, какое поле невалидно, на чьей стороне проблема, имеет ли смысл retry — ничего не понятно, просто стена. Форма 400 Bad Request с телом {"error": "validation failed"} лишь чуть лучше: разработчик понял, что виноват он, но какое поле, какое значение, что менять — снова неизвестно.

Контракт ошибок — это часть публичного API, и он должен быть продуман так же тщательно, как контракт успешных ответов. Минимум в любом теле ошибки зрелого API:

yaml
# openapi.yaml — фрагмент схемы ErrorResponse
ErrorResponse:
  type: object
  required: [error]
  properties:
    error:
      type: object
      required: [code, message, request_id]
      properties:
        code:
          type: string
          example: "VALIDATION_ERROR"
        message:
          type: string
          example: "One or more fields failed validation"
        details:
          type: object
          additionalProperties: true
        request_id:
          type: string
          format: uuid
          example: "req_01HXY8Z2K5VQGN3PMAB7DEFGHJ"
        retry_after:
          type: integer
          nullable: true
          description: "Секунды до следующей разумной попытки. Только для 429 и 503."

Почему каждое поле обязательно:

  • code — машиночитаемый идентификатор, уникальный в рамках API. То, на что код интегратора полагается в if (error.code === 'CONTACT_LIMIT_EXCEEDED') {...}. Без code единственный способ ветвить логику — парсить message, и при первой правке текста всё посыплется. В BOTIX зафиксирован публичный список из 16 кодов на developers.botix.pro/error-codes (MISSING_API_KEY, INVALID_API_KEY, KEY_REVOKED, INSUFFICIENT_SCOPE, API_NOT_AVAILABLE_ON_PLAN, TRIAL_READ_ONLY, RATE_LIMIT_EXCEEDED, VALIDATION_ERROR, CONTACT_NOT_FOUND, CONTACT_BUSY и др.), каждый снабжён описанием «когда возникает», «что делать» и «имеет ли смысл retry». Список — часть контракта; удаление или переименование кода — breaking change. Пока он фиксирован и документирован, интегратор пишет exhaustive switch и не боится, что завтра прилетит неизвестный код в проде.
  • message — человекочитаемое описание для лог-агрегатора разработчика. Единый язык (английский по умолчанию), локализовать не нужно: это сервисное сообщение, а не текст для конечного пользователя — его локализация задача клиентского приложения.
  • details — структурированный контекст, и главное правило: схема details зависит от code. Для VALIDATION_ERROR — карта {field: reason}, для RATE_LIMIT_EXCEEDEDlimit и window, для CONTACT_NOT_FOUNDresource_type и resource_id. Без этого разметочного места разработчик снова уходит в дебаг.
  • request_id — идентификатор запроса. Тот же uuid возвращается в заголовке X-Request-Id каждого ответа, включая успешные. Когда интегратор пишет в поддержку, единственное, что нужно — это request_id: по нему за секунды поднимается полная трассировка. Без него поддержка просит «пришлите время с точностью до секунды, IP и тело запроса», и на этом обычно всё затухает.
  • retry_after — секунды до разумной попытки. Заполняется только для 429 и 503. Для 500 платформа не знает, когда станет лучше — клиент решает сам.

В зрелых API встречаются и опциональные поля: documentation_url (прямая ссылка на страницу кода ошибки), error_chain (цепочка причин, если ошибка пришла из вложенного вызова). В минимальный контракт они не входят, но в больших API полезны.

Чего в теле ошибки быть не должно:

  • Стектрейсов и имён внутренних классов. NullPointerException at com.example.foo.Bar:127 — утечка детали реализации (по сигнатуре читается фреймворк и версия) и подсказка атакующему.
  • SQL и имён колонок. column users.password_hash does not exist — катастрофа с точки зрения безопасности.
  • Расплывчатых формулировок вроде Something went wrong, please try again later. Нет конкретного кода — code: INTERNAL_ERROR, message: Internal server error, request_id, и всё. Без расшаркиваний.
  • Переменного формата. Когда один эндпоинт отдаёт {"error": "..."}, другой {"message": "..."}, третий {"errors": [{"detail": "..."}]}, интегратор пишет три парсера вместо одного. Единый формат на всех эндпоинтах — базовое требование.

Отдельно — HTTP-статусы. Никогда не отдавайте 200 OK с полем error в теле: axios, requests, Guzzle интерпретируют 200 как успех и не вызывают error-handler, что ломает любую generic-логику. Минимальный набор: 400 — невалидное тело/параметры; 401 — нет или некорректна аутентификация; 403 — аутентифицирован, но нет прав; 404 — ресурс не существует или скрыт (трактовать одинаково, иначе утечка факта существования); 409 — конфликт состояния; 422 — синтаксически валидный, семантически невозможный; 429 — превышен rate limit; 5xx — наша проблема, retry с бэкоффом может помочь. Различие 400 и 422 смущает многих; эмпирика простая: 400 — запрос не понятен серверу, 422 — понятен, но не может быть выполнен.

Слой 2. Rate-limit headers: четыре заголовка и корректный backoff

Любой публичный API ограничивает число запросов на ключ за единицу времени — иначе один неоптимальный воркер парализует сервис для всех. Реакция на превышение — 429 Too Many Requests. Видя 429, многие SDK «повторяют запрос»: повторить сразу — второй 429, в цикле — десятый, параллельным воркером — лавина ретраев, которая переживает все попытки rate-limiter'а удержать клиента. Корректная обработка — это набор операций над заголовками, которые платформа отдаёт в каждом ответе, не только в 429:

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

Limit — сколько запросов допускается в окне (обычно минута). Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. Retry-After (RFC 7231 §7.1.3) — явная команда «не пытайся раньше», поддерживается всеми нормально написанными HTTP-клиентами. Индустриальный стандарт (Stripe, GitHub, Cloudflare) — те же три заголовка в успешных ответах и четвёртый в 429. В BOTIX все четыре реализованы в PublicApiAuth middleware; лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты.

Минимально жизнеспособная реализация — прочитать Retry-After при 429 и поспать столько, сколько сказала платформа. Это уже лучше, чем retry-immediately, но при нескольких параллельных воркерах они разом упрутся в 429, разом получат Retry-After: 13, разом проснутся и снова выдадут залп — *thundering herd*. Чтобы это убрать, к Retry-After добавляют случайную компоненту (jitter), а сам backoff растёт при повторных 429 (exponential):

python
# Реактивный backoff. Чистый requests без SDK.
import random, time, requests

def call_with_backoff(method, url, max_retries=5, **kwargs):
    for attempt in range(max_retries):
        r = requests.request(method, url, **kwargs)
        if r.status_code != 429:
            return r
        retry_after = int(r.headers.get('Retry-After', 1))
        # jitter (0..30%) размазывает «стаю» параллельных воркеров
        jitter = random.uniform(0, retry_after * 0.3)
        # экспонента 1×, 2×, 4×, 8×, 16×, потолок 60 сек
        time.sleep(min(retry_after * (2 ** attempt) + jitter, 60))
    raise RuntimeError('Rate limit not cleared')

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

Всё выше — реактивный паттерн: реагируем уже после превышения. Зрелый паттерн — проактивный: смотрим 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 - int(time.time())))

Когда осталось меньше 5 запросов в окне — клиент спит до обнуления, и 429 в нормальной работе не возникает совсем. Для batch это ключевой режим: воркер по 10 000 контактов при лимите 60/мин с проактивным backoff даёт ровную скорость 1 req/sec, без всплесков; без него — серии из 60 запросов с двухминутными паузами (нагрузка та же, но в неприятной для всех форме). Все три SDK BOTIX (botix-pro/sdk PHP, botix Python, @botix/sdk Node, тег v1.1.0) делают exponential backoff с jitter автоматически.

Три предостережения по 429. Первое — не путать 429 с другими 4xx: 401 — «ключ невалиден, повторять бесполезно», 422 — «запрос некорректен, повторять бесполезно», retry уместен только на 429 (и опционально на 503 с Retry-After). Второе — при десяти и более воркерах на один ключ локального счётчика мало (воркер A видит 47, воркер B параллельно сделал ещё 50 — на платформе −3, и оба ловят 429); нужен централизованный счётчик — Redis или leaky-bucket (aiolimiter в Python, bottleneck в Node). Третье — не обходить лимит ротацией ключей: ключи привязаны к project_id, лимит считается на уровне ключа в рамках проекта, обход нарушает условия использования. Нужно больше — это сигнал к смене тарифа.

Слой 3. Audit-логи в кабинете

Журнал API-запросов в кабинете — не «диагностический инструмент поставщика», а инструмент самообслуживания интегратора. Сцена типовая: команда встраивает API, контакт не создался; в своих логах видит, что запрос ушёл; дальше нужно понять — дошёл ли, что ответил сервер. Без журнала единственный путь — тикет в поддержку, и это растягивает диагностику с пятнадцати секунд (взгляд в журнал) до полудня (тикет, переписка, эскалация, ответ). А на стороне интегратора в этот момент стоит продакшен.

Минимум в записи, без которого журнал бесполезен:

  • Время запроса с точностью до миллисекунд в UTC.
  • HTTP-метод и путь (query без секретных значений).
  • Код ответа и размер ответа.
  • Длительность обработки на сервере (response_time_ms).
  • request_id — тот же, что в заголовке X-Request-Id и в теле ErrorResponse.
  • IP-адрес отправителя.
  • Идентификатор ключа (last4 или хеш, не сам ключ).
  • Версия API.
  • Если был задан Idempotency-Key — он тоже.

Этого набора хватает на ~90% отладки: интегратор пишет «не создался контакт, вот этот UUID», поставщик одной командой находит запрос и одной строкой говорит код ответа и причину. В BOTIX журнал лежит на app.botix.pro/developers в разделе кабинета владельца проекта (таблица btx_api_log). Доступ — только владельцу, не агентам и не операторам: записи содержат чувствительные для безопасности метаданные. Retention — 7 дней горячего хранения.

Чем это отличается от server-side логов. Серверный лог — это лог инфраструктуры: запросы всех клиентов, внутренние вызовы между сервисами, периодические задачи; в нём пересекаются данные разных тенантов, доступ — только сотрудникам. Журнал в кабинете — отфильтрованный по project_id вид с удалёнными чувствительными полями. Тела запросов и ответов целиком обычно не хранятся (чтобы исключить утечку ПД через интерфейс). Запрос в кабинет фильтруется по project_id владельца через Auth::assertProjectAccess() — ту же проверку, что и в самом API. Доступны фильтры: HTTP-метод, статус (2xx / 4xx / 5xx по отдельности), путь с подстановкой через звёздочку, поиск по request_id.

Webhook delivery — отдельная вкладка. Когда webhook-endpoint не получает событий, причин несколько: платформа не сгенерировала событие; сгенерировала, но не положила в очередь; положила, но получила timeout; получила 5xx и поставила в retry; получила 2xx, но интегратор перепутал тело. Для исходящей попытки доставки в журнале входящих запросов места нет — поэтому она вынесена в отдельный дашборд (таблица btx_api_webhook_deliveries). Для каждой попытки видны: event-type, endpoint, HTTP-код ответа интегратора, длительность, первые 4 KB тела ответа (чтобы прочитать сообщение об ошибке), номер попытки, время следующего retry. Интегратор открывает свой endpoint, видит, что вернул 500 на седьмое событие подряд, и понимает, что ошибка на его стороне. Здесь же виден каскад ретраев: доставка повторяется до 5 попыток в течение ~6 часов, после чего событие теряется — критичные операции через webhook без дополнительной защиты на стороне клиента строить не стоит.

Журнал как сигнал, а не только инструмент отладки. Он сразу отвечает на вопросы, которые иначе требуют отдельного мониторинга: сколько запросов в день делает интеграция, какие эндпоинты используются чаще всего, где 4xx превышает 1%. В BOTIX KPI-блок над журналом показывает четыре числа: запросов за 24 часа и доли 2xx / 4xx / 5xx. Это базовая обсёрвабилити, которую интегратор раньше строил у себя в Datadog или Grafana — если поставщик отдаёт её, с интегратора снимается отдельный сервис.

Чего не делать: показывать полные тела запросов и ответов (типичный источник утечек ПД через UI); делать журнал в реальном времени с poll каждую секунду (в 99% хватает обновления по кнопке); прятать журнал за платным тарифом — в BOTIX он доступен с тестового триала. Корректная обработка идемпотентности и повторов на стороне клиента — отдельная тема, разобранная в чек-листе Idempotency, rate-limit и audit-лог.

Ограничения нашей реализации

Честно про то, что пока не сделано:

  1. Retention 7 дней. Под compliance-аудит (SOC 2, ISO 27001, 152-ФЗ, GDPR) — мало: инцидент может всплыть через два месяца, а enterprise требует 90 дней и больше. Тема упирается в стоимость хранения: журнал на 7 дней при 10 RPS на проект — это 5–6 млн записей, на 90 дней — 70–80 млн. Индустриальное решение типовое: горячий журнал в основной БД плюс архив в S3-совместимом холодном хранилище. Он планируется под первого клиента с требованием compliance-retention — делать инфраструктуру раньше смысла нет.
  2. Нет async-выгрузки. Журнал в UI отображается до 1000 записей за раз; программная выгрузка за период через API не реализована. Отдельный нюанс — по 152-ФЗ выгрузка содержит ПД (как минимум IP), поэтому это не «кнопка Скачать за год», а контролируемый flow с подтверждением владельца, записью подтверждения в audit-trail и одноразовым скачиванием.
  3. Webhook signature ротация — окно 24 часа. При смене секрета старый принимается ещё сутки. Для крупных интеграторов с медленным циклом релизов иногда мало.

Эти ограничения зафиксированы на developers.botix.pro/best-practices явно, чтобы интегратор увидел их до того, как упрётся. Смежные механизмы защиты того же публичного API — HMAC, rate-limit и replay, а обсёрвабилити глазами SDK — в разборе контракта ошибок и retry.