Попробовать API
</>
«Мелочи» публичного API: ошибки, cursor, rate-limit
api-dizaynВозражение
12 мин

«Мелочи» публичного 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; голый 429X-RateLimit-* + Retry-After.
  • Вывод по опыту: заложить контракт в 1.0 дешевле, чем писать миграционный гайд «было/стало» в 1.1. «Потом» выходит дороже.

Большие архитектурные решения легко обсуждать на ретро. Мелочи — формат ошибки, тип пагинации, заголовки лимитов — обычно не обсуждаются никогда. А именно они потом превращаются в боль интегратора, который читает наш JSON в три часа ночи. Все три вещи ниже мы переделали между Public API 1.0.0 и 1.1.0, и все три кажутся неважными ровно до момента, когда становятся блокером. Общий принцип, к которому мы пришли, простой: в публичном API «внутренняя деталь» и «контракт» — разные вещи, и путать их дорого. Про то, как это выглядит со стороны клиента API, у нас есть отдельный разбор — 5 правил API-клиента, который не сломается в проде.

Мелочь первая: ответ ошибки — это контракт, а не строка

В первой версии ответ на 400 выглядел так:

json
{"error": "validation failed"}

Логично и коротко. Через пару недель работы с первыми интеграторами стало ясно, чего здесь не хватает. Разработчик, отлаживающий интеграцию, видит 400 и {"error": "validation failed"} — и понимает только, что виноват он. Но какое из 12 полей невалидно? Какое значение не подошло? Что менять? Неизвестно. А ветвить логику по такому ответу нельзя вообще: единственный способ отличить «превышен лимит» от «невалидный email» — парсить message, который завтра мы можем переписать, и у интегратора всё посыплется.

Посмотрели, как это сделано в зрелых API — Stripe, GitHub, Cloudflare — и свели к одному формату на всех эндпоинтах:

json
{
    "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_ERRORfields с картой {поле: причина}. Для RATE_LIMIT_EXCEEDEDretry_after_seconds и limit. Для CONTACT_NOT_FOUNDresource_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. Запрос вида

sql
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:

json
{
    "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). Серверный запрос:

sql
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   # при 429

Limit — сколько запросов допускается в окне, 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:

python
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 и притормаживать заранее.

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:
        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. Наш опыт показывает ровно это: «потом» выходит дороже.