«Мелочи» публичного API: ошибки, cursor, rate-limit
TL;DR: формат ошибки, тип пагинации и rate-limit headers — не мелочи «на потом», а контракт с интегратором. Поменяешь их после публичного релиза — получишь breaking change, и интеграторы либо устают, либо застревают на старой версии. Разбираю возражение «это потом» на трёх вещах, которые мы переделали между BOTIX Public API 1.0 и 1.1, — с кодом и разбором, что стоило поспешности.
Коротко
- Формат ошибки, формат пагинации и заголовки лимитов — это контракт, а не деталь реализации. Меняешь их после релиза — breaking change на каждом эндпоинте.
- Три конкретные переделки 1.0 → 1.1:
{"error": "validation failed"}→ структурный error response с машиночитаемымcode; offset → cursor; голый429→X-RateLimit-*+Retry-After. - Вывод по опыту: заложить контракт в 1.0 дешевле, чем писать миграционный гайд «было/стало» в 1.1. «Потом» выходит дороже.
Большие архитектурные решения легко обсуждать на ретро. Мелочи — формат ошибки, тип пагинации, заголовки лимитов — обычно не обсуждаются никогда. А именно они потом превращаются в боль интегратора, который читает наш JSON в три часа ночи. Все три вещи ниже мы переделали между Public API 1.0.0 и 1.1.0, и все три кажутся неважными ровно до момента, когда становятся блокером. Общий принцип, к которому мы пришли, простой: в публичном API «внутренняя деталь» и «контракт» — разные вещи, и путать их дорого. Про то, как это выглядит со стороны клиента API, у нас есть отдельный разбор — 5 правил API-клиента, который не сломается в проде.
Мелочь первая: ответ ошибки — это контракт, а не строка
В первой версии ответ на 400 выглядел так:
{"error": "validation failed"}Логично и коротко. Через пару недель работы с первыми интеграторами стало ясно, чего здесь не хватает. Разработчик, отлаживающий интеграцию, видит 400 и {"error": "validation failed"} — и понимает только, что виноват он. Но какое из 12 полей невалидно? Какое значение не подошло? Что менять? Неизвестно. А ветвить логику по такому ответу нельзя вообще: единственный способ отличить «превышен лимит» от «невалидный email» — парсить message, который завтра мы можем переписать, и у интегратора всё посыплется.
Посмотрели, как это сделано в зрелых API — Stripe, GitHub, Cloudflare — и свели к одному формату на всех эндпоинтах:
{
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation",
"details": {
"fields": {
"email": "must be a valid email address",
"phone": "must start with +"
}
},
"request_id": "req_01HXY8Z2K5VQGN3PMAB7DEFGHJ"
}Разберём каждое поле:
code— машиночитаемый идентификатор, уникальный в рамках API. Это то, на что интегратор может полагаться вif (error.code === 'CONTACT_LIMIT_EXCEEDED') {...}. Публичный список из 16 кодов лежит наdevelopers.botix.pro/error-codes, каждый снабжён «когда возникает», «что делать», «когда retry имеет смысл». Список — часть контракта: удаление или переименование кода = breaking change.message— человекочитаемое описание для логов. Не локализуем: это сервисное сообщение для разработчика, оно идёт в его лог-агрегатор, где единый язык удобнее. Локализация для конечного пользователя — задача клиентского приложения, не API.details— структурированные данные, и главное правило здесь: схемаdetailsзависит отcode. ДляVALIDATION_ERROR—fieldsс картой{поле: причина}. ДляRATE_LIMIT_EXCEEDED—retry_after_secondsиlimit. ДляCONTACT_NOT_FOUND—resource_id. Иначе разработчик снова уходит в дебаг.request_id— тот же, что в заголовкеX-Request-Id, возвращается во всех ответах — успешных и ошибочных. Когда интегратор пишет в поддержку «почему запрос не работает», поrequest_idза секунды поднимается полная трассировка. Без него поддержка просит «пришлите время с точностью до секунды и IP», и на этом всё затухает.
Что мы сознательно не кладём в error response:
- Нет стектрейсов и имён внутренних классов.
NullPointerException at com.example.foo.Baz:127— утечка детали реализации (по сигнатуре видно фреймворк и версию) и мусор для интегратора. Стектрейсы уходят в Sentry на проде, не клиенту. - Нет SQL и имён колонок БД.
Error: column users.password_hash does not existв ответе клиенту — катастрофа с точки зрения безопасности. - Нет расплывчатостей вроде
Something went wrong, please try again later. Если это внутренняя ошибка —code: INTERNAL_ERROR,message: Internal server error,request_id, и всё. - Нет HTTP 200 OK с полем
errorв теле. Статус говорит «успех», body — «ошибка»: это ломает generic-обработку в SDK и HTTP-клиентах (axios, requests, Guzzle трактуют 200 как успех и не зовут error-handler). HTTP-статус соответствует результату операции.
По HTTP-кодам зафиксировали минимум: 400 — невалидное тело; 401 — нет/некорректна аутентификация; 403 — аутентифицирован, но нет прав; 404 — не найдено или скрыто (трактуем одинаково, иначе утечка факта существования); 409 — состояние противоречит операции; 422 — синтаксически валидно, семантически невозможно; 429 — rate limit; 500 — ошибка сервера; 502/503/504 — upstream. Разницу 400 vs 422 формулируем так: 400 — запрос не понятен серверу, 422 — понятен, но не может быть выполнен.
Что бы сделали иначе: зафиксировали бы список кодов с первого релиза. Когда коды формируются стихийно и разными людьми, получаются дубли типа CONTACT_NOT_FOUND и CONTACT_NOT_EXISTS. Фиксированный документированный список даёт интегратору право писать exhaustive switch и не бояться, что завтра в проде прилетит неизвестный код.
Мелочь вторая: пагинация, через offset в которой мы прошли
В первой версии — классический ?limit=20&offset=100. Интуитивно, ORM транслирует в SQL за одну строку, фреймворки поддерживают из коробки. На этом плюсы заканчиваются.
Дальше — история, которую знали в теории. Ранний интегратор выгружал контакты для CRM-синхронизации, около миллиона записей. Воркер шёл страница за страницей: LIMIT 100 OFFSET 0, OFFSET 100, OFFSET 200… К пятисотой странице запросы стали занимать секунды, к десятитысячной — двадцать секунд, на 950 000 — таймаут.
Причина в том, как БД исполняет offset. Запрос вида
SELECT * FROM contacts
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000для базы означает «отсортируй миллион записей, отбрось их и верни следующие 20». Даже с индексом по (project_id, created_at) БД вынуждена пройти миллион строк, просто чтобы их пропустить. Плюс race condition: между запросами страниц в начало коллекции (сортировка created_at DESC) вставляются новые контакты, всё сдвигается, часть записей клиент получает дважды, часть — не получает совсем. От этого артефакта нельзя избавиться, оставаясь в парадигме offset.
Перешли на cursor. Идея: вместо «пропусти N записей» клиент говорит «дай записи после этого курсора», где курсор — непрозрачный токен из предыдущего ответа. Формат ответа в 1.1.0:
{
"data": [...],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yMlQxNDozNyIsImlkIjoxNTQyfQ==",
"has_more": true
}Следующую страницу клиент запрашивает так:
GET /public/v1/contacts?cursor=eyJjcmVhdGVkX2F0Ijo...&cursor_limit=50Когда has_more=false — обход завершён. cursor_limit управляет размером страницы (по умолчанию 20, максимум 200). Внутри курсор — base64 от JSON с парой (created_at, id). Серверный запрос:
SELECT * FROM contacts
WHERE project_id = ?
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20Здесь критичны две вещи. Первое — индекс по (project_id, created_at, id) делает выборку O(log N) независимо от страницы: сервер не пропускает записи, а сразу прыгает в нужную точку индекса. Второе — пара (created_at, id), а не один created_at, гарантирует уникальность позиции, когда время создания у двух записей совпадает.
Про race condition при cursor: они почти исчезают. Новые записи (с более свежим created_at) имеют меньший курсор-приоритет, чем уже выданные, и в текущий обход не попадают — клиент получает предсказуемый снимок на момент старта. Фильтры применяются до курсора: если клиент меняет фильтр посреди обхода, старый курсор относился к другому множеству — сервер обязан поймать это и вернуть 400 INVALID_CURSOR, а не выдать произвольный кусок.
Что отдали взамен, честно: нельзя «прыгнуть на страницу 47», нельзя показать «всего 12 354 записи» без отдельного счётчика (COUNT(*) на больших таблицах сам тяжёлый). Для UI с номерами страниц cursor не подходит — там нужна offset-альтернатива на маленьких выборках. И реализация чуть сложнее: курсор нужно кодировать, валидировать и защищать от подмены.
Что бы сделали иначе: заложили бы cursor сразу в 1.0. Переход на cursor стал breaking change, поэтому миграционный гайд с 1.0 на 1.1 включает отдельный раздел про пагинацию с примерами «было/стало». Про смежные механики обхода — polling vs webhook и audit-логи — у нас есть отдельный разбор.
Мелочь третья: rate-limit headers и реакция клиента
В первой версии мы возвращали 429 при превышении лимита — и всё. Результат предсказуемый: половина интеграторов ретраит сразу, получает второй 429, ретраит снова; параллельный воркер превращает это в лавину, которая переживает все попытки rate-limiter'а удержать клиента. Написали в документации «при 429 — экспоненциальный бэкофф». Помогло слабо. Тогда переделали ответ — стали отдавать заголовки в каждом ответе, не только в 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 — явная команда «не раньше чем через 13 секунд», её поддерживают все нормально написанные HTTP-клиенты. Реализация — в PublicApiAuth middleware (app/Middleware/PublicApiAuth.php): лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты.
Минимально жизнеспособная реакция клиента — читать Retry-After и спать столько, сколько сказала платформа. Но при нескольких параллельных воркерах возникает *thundering herd*: все упёрлись в 429, все получили Retry-After: 13, все разом проснулись и снова дали залп. Поэтому в developers.botix.pro/best-practices мы дали шаблон с jitter и exponential:
import time
import random
import 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×, 16×), jitter размазывает повторы по времени, min(…, 60) — потолок, а падение после N попыток важно, чтобы воркер не висел молча при систематической проблеме. Интеграторы с готовыми клиентами (urllib3.util.Retry с respect_retry_after_header, axios-retry) перестали лавинно ретраить сразу — Retry-After они уважают из коробки.
Более зрелый паттерн — проактивный, без 429 вовсе: следить за X-RateLimit-Remaining и притормаживать заранее.
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)Особенно полезно для batch: воркер по списку из 10 000 контактов при лимите 60/мин идёт ровной скоростью ~1 запрос в секунду, без всплесков и отскоков. В SDK всех трёх языков (botix-pro/sdk, botix, @botix/sdk) backoff с jitter и слежение за Remaining сделаны автоматически. Отдельно: обходить лимит ротацией ключей нельзя — ключи привязаны к project_id, лимит считается на уровне ключа; нужно больше — это сигнал к смене тарифа.
Что бы сделали иначе: заголовки нужно было добавить сразу. Их отсутствие стоило нескольких неприятных тикетов «не понимаю, почему API то работает, то нет».
Что общего у трёх мелочей
Все три — про контракт. Не «как мы внутри работаем», а «что мы обещаем интегратору». Формат ошибки — контракт. Формат пагинации — контракт. Заголовки rate-limit — контракт. Когда команда говорит «сначала запустим, потом доработаем мелочи», для публичного API это означает breaking change на каждом релизе. Лучше потратить день на проектирование error response в 1.0, чем неделю на миграционный гайд в 1.1. Наш опыт показывает ровно это: «потом» выходит дороже.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
5 правил API-клиента, который не сломается в проде
Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
Production-ready POST: idempotency, retry и rate-limit
Idempotency-Key, rate-limit и audit-лог публичного API
