Попробовать API
</>
Публичный API-контракт: deprecation, rate-limit, white-label
api-dizaynКейс
17 мин

Публичный API-контракт: deprecation, rate-limit, white-label

TL;DR: публичный API — это не строка в URL, а контракт, и ломается он дороже, чем кажется. На 23-й минуте после релиза мы получили первое письмо: «у нас вся интеграция в проде сыпется». Мы переименовали `created` в `created_at` в одном эндпоинте — косметика на бумаге, breaking change для 4 SDK. Ниже — три части контракта, за которые платит бизнес: что считается breaking change и зачем 12 месяцев deprecation, как клиент должен делать backoff по `X-RateLimit-*`, и что нужно от API, чтобы отдать его white-label-партнёрам без инцидента на первой неделе.

Коротко

  • Breaking change — это не про код, это про обязательство. Переименование поля, ужесточение rate-limit, тихая смена семантики — всё это ломает прод у всех интеграторов сразу. Мы зафиксировали 12 месяцев deprecation и машиночитаемый заголовок X-API-Deprecation, и это стоит 10–15% ресурса команды API.
  • Rate-limit нельзя игнорировать и ретраить сразу. Платформа отдаёт X-RateLimit-Limit/Remaining/Reset в каждом ответе и Retry-After на 429. Правильный backoff — это jitter + exponential, а зрелый режим — притормаживать заранее по Remaining, не доводя до 429.
  • White-label — набор архитектурных свойств, заложенных заранее: отдельный тип ключа, sub-проекты, жёсткая изоляция WHERE project_id = ?, Auth::assertProjectAccess() в каждом эндпоинте, отдельная документация и партнёрский канал уведомлений с форой в 90 дней.

BOTIX — российская SaaS-платформа AI-ассистентов и чат-ботов. С 2026 года у нас публичный REST API (api.botix.pro/public/v1/*, версия 1.1.0), три открытых SDK на github.com/BOTIX-pro (PHP, Python, Node), CLI и встроенный webhook-инспектор. API подключён к нескольким десяткам внешних команд — и какую цену каждая платит при breaking change, мы знаем с точностью до часа.

Контракт, а не код: почему breaking change стоит человеко-месяцев

Внутри одной команды breaking change в приватном API — разговор в чатике, час правок, деплой. В публичном ситуация другая. Контракт — это уже не код, это обязательство. Когда команда подключается, она в неявной форме подписывается на «этот эндпоинт возвращает такие поля, так выглядят коды ошибок, так пагинируется коллекция». Если поставщик решит, что одно из утверждений больше не верно — у интегратора посыпется прод. Не у одного — у всех, кто хоть раз поставил интеграцию и больше к ней не возвращался. А таких большинство.

История с created → created_at стоила трёх дней: откат миграции БД на копии, hotfix в SDK, рассылка интеграторам, созвон с двумя агентствами. Если бы изменение было заложено в политику и анонсировано за 12 месяцев — это были бы три коммита в changelog. После инцидента мы зафиксировали в developers.botix.pro/versioning документ из трёх разделов: что считается breaking, сколько живёт старая версия, как клиент узнаёт об изменениях. Без этого документа любой публичный API — это не контракт, а серия односторонних обещаний.

Что считается breaking change

Самая дорогая часть документа: каждый пункт связывает руки команде разработки на 12 месяцев вперёд. Breaking change — любое изменение, которое заставит существующий код интегратора сломаться или начать работать иначе без явного действия с его стороны:

  • Удаление эндпоинта.
  • Удаление поля из ответа.
  • Переименование поля (включая косметические — наш кейс с created).
  • Изменение типа поля: был string, стал object.
  • Изменение HTTP-статуса: был 200, стал 202.
  • Добавление обязательного параметра в существующий запрос.
  • Изменение формата идентификаторов: был int, стал ULID.
  • Изменение семантики кода ошибки.
  • Изменение порядка обработки, видимое через побочные эффекты.

Non-breaking — всё что не влияет на корректность: добавление эндпоинта, опционального параметра, нового поля в ответ (при условии что клиент игнорирует неизвестные поля — это требование контракта, не предположение). Именно так у нас проехала версия 1.1.0: X-RateLimit-*-заголовки, cursor-пагинация (cursor + meta.next_cursor параллельно с page/per_page) и bulk-эндпоинты добавились, ничего не сломав — все эндпоинты v1.0 работают без правок на стороне клиента.

Серая зона — расширение enum. Если ваш OpenAPI описывает enum как закрытое множество, а клиент сгенерировал SDK через openapi-generator со строгой валидацией, добавление нового значения сломает ему парсинг. Либо документировать enum как открытый, либо считать расширение breaking — третьего не дано. Мы выбрали путь «enum в публичном API всегда открытый, клиент обязан graceful fallback на неизвестное значение» (developers.botix.pro/best-practices).

Ещё частая ошибка — считать изменение rate-limit non-breaking. Было 1000 запросов в минуту, стало 100. Структура не меняется, но у интегратора, спроектировавшего воркер под старый лимит, всё встанет. Это breaking: нарушается наблюдаемый контракт. Мы больше не можем «незаметно» ужесточить лимит — обязаны анонсировать снижение за 12 месяцев.

И отдельно — семантический контракт. POST /contacts/import, ранее считавший дубликаты ошибкой, а теперь тихо их пропускающий — это breaking, даже если формат запроса и ответа идентичен. Семантика — часть контракта, её изменения тоже проходят через цикл депрекации.

12 месяцев deprecation — и во сколько это обходится

Индустрия сходится на интервале от 6 до 24 месяцев с момента анонса депрекации. Меньше — неуважение к интегратору, который встроил вас в продакшен; больше — лишние затраты. Мы выбрали 12 и зафиксировали публично: с момента, когда новая мажорная версия объявлена стабильной, старая доступна ещё 12 месяцев и гарантированно не ломается. Безопасностные фиксы при этом продолжают наезжать на обе версии — это уточнение тоже в политике явно.

Сколько это стоит бизнесу:

  • Полтора-два разработчика держат в голове две версии контракта одновременно.
  • Интеграционные тесты на старой версии в CI — каждый коммит прогоняется по обоим контрактам.
  • Документация в двух вариантах: changelog старой версии, migration-гайд между версиями.
  • Два набора x-codeSamples в openapi.yaml.

При команде из 5 инженеров 12-месячная поддержка съедает 10–15% ресурса разработки API — постоянная фоновая нагрузка. 6 месяцев — половина крупных агентств ушла бы к другому поставщику; 24 — нагрузка вдвое, пришлось бы поднимать тарифы. Это не «инженерная норма», это бизнес-решение в строке политики. Как проходить саму миграцию между мажорами без простоя интегратора — разбираем отдельно: миграция с v1 на v2 без downtime.

Как клиент узнаёт: заголовки депрекации и SDK

Минимум для любого публичного API: публичный changelog с RSS (developers.botix.pro/changelog/rss), email-подписка на анонсы, HTTP-заголовок депрекации в ответах API. Последнее особенно важно — если разработчик не следит за блогом и не читает email, единственный шанс достучаться это сам API. Каждый ответ депрекейтнутой версии содержит два заголовка:

X-API-Deprecation: version=1.0.0; sunset=2027-05-22; replacement=/public/v2
X-API-Notice: information=trial_quota_80pct; expires_at=2026-06-01T00:00:00Z

X-API-Deprecation — формальный машиночитаемый сигнал о выводе версии. X-API-Notice — информационные уведомления, не привязанные к депрекации («через час окно техобслуживания», «80% месячного лимита»).

Реализация на клиенте — три строки. PHP из нашего SDK (github.com/BOTIX-pro/sdk-php, src/Http/Response.php):

php
public function checkDeprecation(): void
{
    $deprecation = $this->headers['X-API-Deprecation'] ?? null;
    if ($deprecation === null) {
        return;
    }
    error_log("[BOTIX SDK] API version deprecated: {$deprecation}");
}

Без связки «SDK логирует → команда мониторит → заводит задачу заранее» 12-месячный срок — фикция: интегратор узнаёт о sunset за два дня, когда начинает приходить 410 Gone. Минимальный пакет на каждую новую версию: запись в changelog с конкретным списком изменений, migration-гайд с примерами «было/стало» на всех языках SDK, дата релиза, дата начала депрекации, дата отключения. У нас это собрано в developers.botix.pro/migration-guide, RSS специально оставлен — у команд, встраивающих API в прод, обычно уже есть feed-агрегатор или Slack-бот.

Что отвергли: URL против header против content-type

Версионирование через URL (/v1/, /v2/). Самый популярный подход. Отказывались от него неохотно: дублирование кода в маршрутизаторе, каждая версия требует новой документации, snippet'ов, CI-пайплайна.

Через Content-Type (application/vnd.botix.v1+json). Технически чище, но Swagger UI 5.x плохо живёт с content-negotiation — Try-It-Out превращается в неработающий артефакт.

Одновременно URL и header. Создаёт неопределённость: какой контракт работает, если в URL /v1/, а в заголовке Accept-Version: 2. Матрица из четырёх состояний на эндпоинт.

Итого. Оставили URL-версионирование (api.botix.pro/public/v1/*), без content-negotiation, без header-версионирования. Один способ узнать версию — путь. И не прятать breaking changes в патч-релизах «улучшение надёжности», не делать silent deprecation, не относиться к политике «гибко» под запрос: правило 12 месяцев одинаково для всех, включая самого крупного клиента. Как только крупный получит исключение, остальные узнают — и доверие пропадёт.

Rate-limit: как клиент должен делать backoff по X-RateLimit-*

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

Три заголовка в каждом успешном ответе и четвёртый в 429 (индустриальный стандарт — Stripe, GitHub, Twitter, Cloudflare):

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460

Limit — сколько запросов допускается в текущем окне (обычно minute). Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. При 429 добавляется четвёртый:

Retry-After: 13

Это явная команда — «не пытайся раньше чем через 13 секунд» (RFC 7231 §7.1.3, поддерживается во всех нормально написанных HTTP-клиентах). У нас все четыре заголовка реализованы в PublicApiAuth middleware (см. app/Middleware/PublicApiAuth.php). Лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик хранится в Redis с TTL до конца текущей минуты. При превышении дневного лимита api_rate_per_day эндпоинт отдаёт код ошибки RATE_LIMIT_EXCEEDED.

Минимально жизнеспособная реализация — читать Retry-After при 429 и спать столько, сколько сказала платформа:

python
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». Но при нескольких параллельных воркерах возникает проблема: все разом упёрлись в 429, все получили Retry-After: 13, все разом проснулись и выдали залп. Это *thundering herd*. Чтобы его избежать, к Retry-After добавляется случайная компонента (jitter), а backoff растёт при повторных 429 (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 неудачных попыток функция падает с ошибкой — важно, чтобы при систематической проблеме воркер не висел молча. В tenacity та же логика встроена через @retry(stop=stop_after_attempt, wait=wait_exponential_jitter), у urllib3.util.Retry есть backoff_factor и respect_retry_after_header. Готовое — лучше своего, если не хочется поддерживать собственный код.

Более зрелый паттерн — реагировать не после 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)

response = requests.get(url, headers=headers)
maybe_sleep(response)
# ... следующий запрос

Когда осталось меньше 5 запросов в окне — клиент спит до обнуления, и 429 в нормальной работе не возникает совсем. Особенно полезно для batch: воркер по 10 000 контактов при лимите 60/мин даёт ровную скорость 1 запрос в секунду без всплесков, а не серии из 60 запросов с двухминутными паузами.

Когда один ключ используется параллельно несколькими воркерами, локальный счётчик в памяти не отражает реальной картины: воркер A посчитал, что осталось 47, воркер B параллельно сделал ещё 50 — на платформе −3, оба получают 429. Решение — централизованный счётчик: Redis или leaky-bucket-лимитер перед обращением к API (aiolimiter в Python, bottleneck в Node). Для двух-трёх воркеров достаточно, чтобы каждый уважал заголовки; при десяти и больше — централизованный счётчик обязателен.

Чего не делать: не подменять Retry-After константой «жди 5 секунд всегда» — платформа знает точное время окна, клиент нет. Не путать 429 с другими 4xx — 429 «попробуй позже», 401 «ключ невалиден, повторять бесполезно», 422 «запрос некорректен, повторять бесполезно»; retry срабатывает только на 429 (и опционально на 503 с Retry-After). Как это ложится на идемпотентные POST — в разборе почему без Idempotency-Key API не production-ready, а как отделить 429 от остального в контракте ошибок — что должно быть в error response. И не обходить лимит ротацией ключей: ключи привязаны к project_id, лимит считается на уровне ключа в проекте — нужно больше, это сигнал к смене тарифа. Все три языка SDK (botix-pro/sdk, botix, @botix/sdk) делают exponential backoff с jitter автоматически; на чистом HTTP-клиенте описанный алгоритм работает без модификаций — заголовки присутствуют в каждом ответе.

White-label через API: что нужно, чтобы стать платформой

Третий и самый дорогой уровень контракта. Один эндпоинт /contacts не делает API платформой. Платформа начинается там, где появляется второй уровень: партнёр — агентство под своим брендом, ISV со своим SaaS, системный интегратор — встраивает наш API внутрь своего продукта и продаёт дальше. Каждый breaking change тогда ломает не одну интеграцию, а всех конечных клиентов партнёра — десятки или сотни. Партнёр не может «просто обновиться»: ему нужно протестировать на всех клиентах, выкатить, отработать инциденты. Это месяцы работы для агентства из 5 человек.

У прямого клиента контур простой: один аккаунт, один проект, несколько ключей, один webhook. У партнёра — многоуровневый: сам партнёр имеет аккаунт, внутри него десятки конечных клиентов, у каждого свой логический проект со своими данными, биллингом, webhook'ом. Обычный ключ работает в контексте одного проекта — партнёрский должен работать в контексте партнёрского аккаунта и переключаться между конечными. Скрыть это под одним эндпоинтом — путь к ошибкам. Минимальный набор архитектурных свойств, который мы закладываем под партнёрский слой:

  • Партнёрский уровень аутентификации — отдельный тип ключа (partner key) с более высокими привилегиями, чем у обычного project key: создавать новые проекты, перечислять существующие, получать ключи для каждого. Обращение к партнёрским операциям с обычным ключом возвращает 403.
  • Sub-проекты под одним партнёрским аккаунтом — иерархия «партнёр сверху, конечные проекты снизу», каждый со своими данными, webhook'ом, биллингом.
  • OAuth installations с реселлерскими scope — чтобы OAuth-приложение, установленное в партнёрский аккаунт, видело все sub-проекты, а не только тот, в который установлено напрямую.
  • Биллинг-события через API — партнёр биллит по своей модели, ему нужны сырые данные (количество запросов, сообщений, активных контактов по каждому sub-проекту за период): либо отдельный эндпоинт usage, либо webhook-событие в конце расчётного периода.
  • Audit-логи на партнёрском уровне — когда конечный клиент пишет «что-то не работает», партнёр диагностирует сам, без привлечения поставщика (cursor, polling и audit-логи).
  • Кастомные домены и брендирование — сам API кастомного домена не требует; если в партнёрском решении есть UI под брендом партнёра, поставщику нужны white-label-домены. У нас этот пункт пока в плане — текущая партнёрская работа идёт через API без кастомного фронтенда.

Критическая граница — изоляция данных. Внутри одного партнёрского аккаунта могут быть sub-проекты конкурирующих компаний; возможность одного увидеть данные другого — катастрофа. Граница держится на трёх уровнях: на уровне БД — WHERE project_id = ? в каждом запросе; на уровне API — явный project_id в каждом запросе и проверка, что он принадлежит партнёрскому аккаунту; на уровне ключей — нельзя получить ответ по одному sub-проекту, передав ключ другого. У нас Auth::assertProjectAccess($projectId) вызывается в начале каждого эндпоинта, работающего с проектом:

php
// PublicApi\V1\<Resource>Controller — начало каждого метода
$this->requireScope('contacts:read');
Auth::assertProjectAccess($projectId);

Без этой защиты данные потекут между проектами на первой же ошибке в коде. Партнёрский уровень добавляет слой: проверяется не только доступ к sub-проекту, но и то, что sub-проект принадлежит партнёрскому аккаунту, выпустившему ключ.

И отдельная фиксация в политике: партнёрам breaking change анонсируется не только в публичный changelog, но и в partner-channel (email + Telegram-канал) за 90 дней до публичного анонса. У партнёров есть три месяца форы. Без этого правила партнёрская модель не работает: партнёр готов жить с тем, что API меняется, но не готов узнавать об изменении в один день со всеми. Готовность к партнёрской работе оценивается за полчаса по чек-листу: отдельный тип ключа с расширенными правами? концепция sub-проектов? WHERE project_id = ? в каждом запросе? Auth::assertProjectAccess() в начале каждого эндпоинта? отдельные scope для партнёров в OAuth? способ получить сырые usage-данные? аудит-журнал по всем sub-проектам? отдельная документация и Postman-коллекция? Если «нет» начиная с третьего пункта — API технически не готов. Партнёрский API не навешивают на обычные эндпоинты флагами «если ты партнёр, передай ещё параметр» (запутанная семантика — лучше отдельный префикс /partner/v1/*), не выдают «по запросу» избранным по NDA (работает пока партнёров три, при двадцати начинаются теневая документация и техдолг) и не ставят высокий порог входа: на старте партнёрский слой — это маленькие команды, из которых вырастают крупные.

Тенденция в индустрии

Stripe держит 12-месячный sunset как стандарт больше пяти лет. GitHub Apps работают по схожей политике. AWS поддерживает версии SDK по 24–36 месяцев, Twilio — 12–24. На этом фоне мы с 12 месяцами — посередине спектра. Чем серьёзнее компания относится к публичному API как к продукту, тем длиннее sunset и тем раньше в архитектуру заложены rate-limit-заголовки и партнёрский слой. Решение о white-label — продуктовое, не техническое: не каждый API должен быть платформой. Но если решение принято, семь свойств закладывают с самого начала, а не на третьей итерации после первых жалоб.