Миграция клиентов с v1 на v2 публичного API без downtime
TL;DR: старую и новую версии API держим параллельно 12 месяцев, cursor-пагинация заменяет offset без поломки клиентов, `X-API-Deprecation` едет в каждом ответе старой версии, а audit-лог в кабинете показывает, кто ещё ходит на `1.0.0`. Ниже — три инженерных блока: критерий breaking change и оповещение, cursor против offset с SQL и разбором race condition, журнал запросов как инструмент самообслуживания интегратора. Код роутера, миграционный гайд «было/стало» и SQL для выборки отстающих проектов — точь-в-точь как в проде.
Коротко
- Breaking change — любое изменение, которое ломает код интегратора без его участия. Критерий фиксируется в политике версионирования до первого внешнего пользователя, а оповещение идёт через машиночитаемый
X-API-Deprecationв каждом ответе старой версии — не через блог, который разработчик уже не читает. - Cursor заменяет offset. На 10 млн записей
LIMIT 20 OFFSET 1000000физически не работает — таймаут или блокировка реплик. Cursor даёт O(log N) на любой странице и снимает race condition при обходе меняющейся коллекции. Переход offset → cursor — это breaking change, поэтому едет через миграционный гайд «было/стало». - Audit-лог API в кабинете — не диагностика поставщика, а инструмент самообслуживания интегратора:
request_id, статус, версия ключа, длительность. Он же одним SQL-запросом показывает, какие проекты за 30 дней ещё слалиX-API-Version: 1.0.0— адресный список для приоритетной коммуникации перед sunset.
Исходные данные. У нас публичный API 1.0.0 с offset-пагинацией на api.botix.pro/public/v1/. Выпускаем 1.1.0: cursor-пагинация на всех list-эндпоинтах, заголовки X-RateLimit-* в каждом ответе и три bulk-эндпоинта (POST /contacts/bulk, POST /messages/bulk, POST /tags/bulk). Между «выпустили новое» и «отключили старое» — срок поддержки 12 месяцев, в течение которого обе версии живут параллельно. За это время нужно перевезти всех интеграторов без команды «прекращаем поддержку через неделю, мигрируйте срочно». Дальше — три тезиса, каждый со своим кодом.
Тезис 1. Что считается breaking change — и как об этом уведомить
Версионирование — это не строка в URL, а контракт между поставщиком и интегратором. Когда команда подключается к публичному API, она в неявной форме подписывается на условия: «этот эндпоинт возвращает такие поля, коды ошибок выглядят так, коллекция пагинируется так». Если поставщик решит, что одно из утверждений больше не верно, у интегратора посыпется прод — и не у одного, а у всех, кто поставил интеграцию и больше к ней не возвращался. А таких большинство.
Поэтому первое, что должно быть зафиксировано ещё до первого внешнего пользователя, — политика версионирования. Не «у нас семвер», а развёрнутый документ с тремя обязательными разделами: что считается breaking change, как долго живёт старая версия после анонса депрекации, как клиент узнаёт об изменениях. В BOTIX это developers.botix.pro/versioning.
Критерий breaking change. Это любое изменение, которое заставит существующий код интегратора сломаться или начать работать иначе без явного действия разработчика. К ним относятся:
- удаление эндпоинта;
- удаление поля из ответа;
- переименование поля;
- изменение типа поля (был
string, сталobject); - изменение HTTP-статуса (был
200, стал202); - добавление обязательного параметра в существующий запрос;
- изменение формата идентификаторов;
- изменение семантики кода ошибки.
Non-breaking — всё, что не влияет на корректность существующих интеграций: новый эндпоинт, новый опциональный параметр, новое поле в ответе (при условии, что клиент игнорирует неизвестные поля — это требование контракта, не предположение), расширение enum. Последнее — серая зона: если ваш OpenAPI описывает enum как закрытое множество, а клиент сгенерировал SDK через openapi-generator со строгой валидацией, добавление значения сломает ему парсинг. Значит, либо документируем enum как открытый, либо считаем расширение breaking. Третьего не дано.
Отдельная ловушка — rate-limit как non-breaking. Было 1000 запросов в минуту на ключ, стало 100. Формально структура запросов и ответов не изменилась. Фактически у интегратора, спроектировавшего воркер под старый лимит, всё встанет. Это breaking, потому что нарушается наблюдаемый контракт. И семантический контракт: если POST /contacts/import раньше считал дубликаты ошибкой, а теперь тихо их пропускает — это breaking, даже если формат запроса и ответа идентичен. Семантика — часть контракта. Полный разбор, что считается breaking change и во сколько обходится каждый, — здесь.
Срок поддержки. Индустрия сходится на интервале 6–24 месяца с момента анонса депрекации. Меньше — неуважение к интегратору, который встроил вас в продакшен; больше — лишние затраты на поддержку. В BOTIX выбран срок 12 месяцев и зафиксирован публично. Депрекация не значит «брошено», значит «новых фич не будет, безопасность поддерживается» — безопасностные фиксы накатываются на обе версии, и это уточнение тоже стоит в политике явно.
Параллельное существование версий. Старый и новый API живут одновременно через X-API-Version:
X-API-Version: 1.0.0→ роутер v1, старый контракт;X-API-Version: 1.1.0или отсутствие заголовка → роутер v1.1 (новая дефолтная).
Альтернатива — URL-версионирование (/v1/contacts, /v1.1/contacts). Оба паттерна валидны; смешивать в одном API нельзя — это создаёт путаницу («какой контракт работает, если в URL /v1, а в header 1.1?»). Выбирайте одно. Оба роутера ходят в ту же БД через ту же бизнес-логику; разница — на слое сериализации: v1 формирует {"contacts": [...], "total": 12354, "offset": 100}, v1.1 — {"data": [...], "next_cursor": "...", "has_more": true}.
public function list(Request $request)
{
$projectId = $this->resolveProjectId($request);
Auth::assertProjectAccess($projectId);
$apiVersion = $request->header('X-API-Version', '1.1.0');
$contacts = $this->repo->list($projectId, $request->all());
return match (true) {
version_compare($apiVersion, '1.1.0', '>=') => $this->formatV11($contacts),
default => $this->formatV10($contacts), // deprecated
};
}
private function formatV10($contacts): JsonResponse
{
return response()->json([
'contacts' => $contacts->items,
'total' => $contacts->total,
'offset' => $contacts->offset,
])->withHeaders([
'X-API-Deprecation' => 'version=1.0.0; sunset=2027-05-22; replacement=/public/v1.1',
]);
}Оповещение — X-API-Deprecation в каждом ответе старой версии. Самый недооценённый механизм. Разработчик уже не следит за блогом, не читает email, не подписан на RSS. Единственный канал, который он гарантированно видит, — сам API:
X-API-Deprecation: version=1.0.0; sunset=2027-05-22; replacement=/public/v1.1Формат машиночитаемый. Хороший SDK ловит заголовок и логирует предупреждение:
if 'X-API-Deprecation' in response.headers:
logger.warning(
"API endpoint deprecated: %s. Migration: developers.botix.pro/migration-guide",
response.headers['X-API-Deprecation']
)Хорошая команда мониторит лог и заводит задачу задолго до sunset. Те, кто не мониторит, увидят 410 Gone в день sunset. Рядом с депрекационным есть X-API-Notice — для информационных уведомлений, не привязанных к депрекации («через час окно обслуживания», «вы достигли 80% месячного лимита»).
Каналы оповещения — не один, а пять. Один канал ненадёжен: email уходит в спам, RSS никто не подключил, заголовок игнорит самописный клиент. Дублируем:
| Канал | Когда |
|---|---|
| Email-рассылка | T-0 (анонс), T+3 мес, T+9 мес, T+12 мес (sunset) |
| RSS на changelog | На каждое изменение |
X-API-Deprecation | В каждом ответе старой версии |
| Запись в публичном changelog | T-0 |
| Сообщение в кабинете | T-0 для проектов с активным v1.0 |
Email-подписка — таблица btx_api_changelog_subscribers, RSS — developers.botix.pro/changelog/rss (специально оставлен: у команд, встраивающих API в прод, обычно уже есть feed-агрегатор или Slack-бот). Минимальный пакет на каждую версию: запись в changelog с конкретным списком изменений, миграционный гайд «было/стало» на всех языках SDK, дата релиза, дата начала депрекации, дата окончательного отключения — всё в developers.botix.pro/migration-guide.
Тезис 2. Cursor vs offset: почему cursor выигрывает на любом масштабе
Offset-пагинация выглядит проще ровно до момента, когда коллекция перерастает несколько миллионов записей или начинает активно меняться во время обхода. В учебниках пагинация показана как ?limit=20&offset=100 — интуитивно, поддерживается фреймворками из коробки, ORM транслирует в SQL за строчку. На этом плюсы заканчиваются.
Проблема 1 — производительность. Запрос SELECT * FROM contacts WHERE project_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET 1000000 для БД означает «отсортируй миллион записей, отбрось их, верни следующие 20». Даже с правильным индексом по (project_id, created_at) БД проходит миллион строк, чтобы их пропустить. На 10 млн записей offset просто не работает: таймаут, либо съеденная память, либо заблокированные читающие реплики.
Проблема 2 — race condition при обходе. Клиент запросил страницу 1 (записи 1–20). Между этим и запросом страницы 2 в начало списка добавились 5 новых записей (сортировка created_at DESC). Клиент запрашивает страницу 2 с offset=20 и получает записи, которые теперь занимают позиции 26–45. Записи 21–25 (бывшие 16–20, сдвинутые новыми) он получит дважды, а записи 26–30 (бывшие 21–25) не получит вообще. На лентах, листингах и журналах сообщений это известный артефакт, и от него нельзя избавиться, пока вы в парадигме offset.
Cursor решает обе. Вместо «пропусти N записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор, выданный сервером в предыдущем ответе. Формат курсора — деталь реализации, клиент относится к нему как к чёрному ящику. Внутри обычно закодированная пара (значение_сортировки, идентификатор), например (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 не уникален: будь в курсоре только created_at, две записи с одинаковым временем могли бы попасть на стык страниц.
В BOTIX API 1.1.0 все list-эндпоинты перешли на cursor. Формат ответа:
{
"data": [...],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yMlQxNDozNyIsImlkIjoxNTQyfQ==",
"has_more": true
}Следующая страница:
GET /public/v1/contacts?cursor=eyJjcmVhdGVkX2F0Ijo...&cursor_limit=50Когда has_more=false — обход завершён. Параметр cursor_limit управляет размером страницы (по умолчанию 20, максимум 200). Фильтры применяются до курсора: если клиент меняет фильтры посреди обхода, курсор от предыдущей выборки уже невалиден — он относился к другому множеству. Сервер обязан это поймать и вернуть 400 INVALID_CURSOR, а не отдать произвольный кусок отфильтрованной коллекции.
Race condition при cursor почти исчезают. Если коллекция отсортирована created_at DESC и в неё добавляются новые записи (свежее created_at), они имеют меньший курсор-приоритет, чем уже выданные, и просто не попадут в текущий обход — клиент получит снимок на момент начала. Удаление записи тоже не страшно: курсор указывает на «значение, после которого начинаем», БД просто пропустит удалённую.
Миграция пагинации — раздел developers.botix.pro/migration-guide. Для каждого breaking — конкретный код в обоих языках, не «теперь используйте cursor», а полный пример.
Было (v1.0):
offset = 0
while True:
response = client.contacts.list(limit=50, offset=offset)
for contact in response['contacts']:
process(contact)
offset += 50
if offset >= response['total']:
breakСтало (v1.1):
cursor = None
while True:
response = client.contacts.list(cursor_limit=50, cursor=cursor)
for contact in response.data:
process(contact)
if not response.has_more:
break
cursor = response.next_cursorРазличия:
offset→cursor(непрозрачная строка от сервера);total→has_more(cursor не даёт дешёвогоCOUNT(*));response['contacts']→response.data(унифицированное имя списка).
Минусы cursor — честно. Нельзя «прыгнуть на страницу 47»: только последовательный обход. Для UI с номерами страниц — проблема; решение — «бесконечная лента» (стандарт мобильного UX) либо cursor для API и offset для UI с пониманием его лимитов. Нельзя показать «всего 12 354 записи»: COUNT(*) на больших таблицах сам тяжёлый, при cursor его обычно не делают; если счётчик критичен — держать его в отдельной материализованной таблице. И реализация на сервере чуть сложнее: кодировать/декодировать курсор (base64 от JSON), валидировать, подписывать HMAC, чтобы клиент не подменил курсор и не дотянулся до чужих данных. Как SDK, сгенерированный из OpenAPI, работает с cursor из коробки — в разборе про breaking changes, cursor и SDK.
Вывод: для 50 записей оба подхода одинаковы, для 10 млн offset не работает физически, cursor продолжает. Делать сначала offset, потом мигрировать на cursor — это breaking change, который переживается болезненно (у нас так и было: 1.0.0 использовал offset, после стресс-теста на проекте с миллионом контактов стало ясно, что таймауты неизбежны). Если проектируете публичный API с нуля — закладывайте cursor сразу.
Тезис 3. Audit-лог API в кабинете — обязательная часть интеграции
Через 6 месяцев после анонса на старой версии в идеале — 0 проектов. В реальности — пара десятков. Чтобы понять, кто именно ещё ходит на 1.0.0:
SELECT
project_id,
COUNT(*) AS request_count,
MAX(created_at) AS last_request
FROM btx_api_log
WHERE api_version = '1.0.0'
AND created_at >= NOW() - INTERVAL 30 DAY
GROUP BY project_id
ORDER BY request_count DESC;Это адресный список для приоритетной коммуникации — с каждым проектом работаем индивидуально. Но журнал запросов нужен не только поставщику. В любой интеграции рано или поздно случается сцена: команда интегратора видит, что контакт не создался, заявка не привязалась, webhook не пришёл. Открывает свои логи — запрос отправлен. Дальше нужно понять: дошёл ли до API, что ответил сервер, какой код ошибки. Если у поставщика нет публичного журнала — единственный путь тикет в поддержку. Один такой тикет растягивает диагностику с пятнадцати секунд (взгляд в журнал) до полудня, а на стороне интегратора в этот момент стоит прод. Поэтому audit-лог в кабинете — не диагностическая функция поставщика, а обязательный инструмент самообслуживания интегратора.
Что должно быть в журнале. Минимальный набор полей, без которых он бесполезен:
- время запроса с точностью до миллисекунд в UTC;
- HTTP-метод;
- путь с query-параметрами, но без секретных значений;
- код ответа и размер ответа;
response_time_ms— длительность обработки на сервере;request_id(UUID) — тот же, что возвращается клиенту вX-Request-Id;- IP-адрес отправителя;
- идентификатор API-ключа — без самого ключа, только last4 или хеш;
- версия API;
Idempotency-Key, если был задан.
Этого хватает на 90% задач отладки. Интегратор пишет «не создался контакт, вот UUID» — поставщик одной командой находит запрос и одной строкой говорит код ответа и причину. В BOTIX журнал лежит на app.botix.pro/developers, раздел «API Log»: метод и путь, статус, размер, request_id, last4 ключа, версия API, timestamp. Доступ — только владельцу проекта, не агентам и не операторам: метаданные чувствительны для безопасности. Разработчик видит, что его код всё ещё шлёт X-API-Version: 1.0.0, и знает, где искать.
Чем это отличается от server-side логов. Серверный лог поставщика — лог инфраструктуры: запросы всех клиентов, внутренние вызовы, периодические задачи, пересечение тенантов. Журнал в кабинете — вид, отфильтрованный по project_id, с удалёнными чувствительными полями (тела запросов и ответов целиком не хранятся, чтобы не утекли персональные данные через интерфейс) и удобной фильтрацией: по методу, по статусу (2xx / 4xx / 5xx по отдельности), по пути с подстановкой через *, поиск по request_id. Над журналом — KPI-блок из четырёх чисел: запросов за 24 часа, доля 2xx, 4xx, 5xx. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog или Grafana; если поставщик её отдаёт — снимает с интегратора одну задачу.
Webhook delivery — отдельная вкладка. Когда webhook-endpoint не получает событий, причин несколько: платформа не сгенерировала событие; сгенерировала, но не положила в очередь; положила, но получила timeout; получила 5xx и поставила в retry; получила 2xx, но интегратор перепутал тело. Для входящих запросов места этому нет — это исходящая попытка доставки, поэтому она вынесена в отдельный дашборд. Для каждой попытки видны: event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа (первые 4 KB — прочитать сообщение об ошибке), номер попытки, время следующего retry. В BOTIX доставка идёт через WebhookDispatcher с retry-расписанием 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов, подписью X-Botix-Signature (HMAC-SHA256) и заголовками X-Botix-Event, X-Botix-Request-Id; после 5 неудач — статус failed. Интегратор открывает свой endpoint, видит, что вернул 500 на седьмое событие подряд, и понимает, что ошибка на его стороне — без дашборда тот же кейс превращается в дневной тикет «вы не присылаете webhook'и». Смежная тема доставки и надёжности — в разборе про retry и подпись webhook.
Retention и compliance. В BOTIX btx_api_log хранится 90 дней, дальше архивация. Под compliance-аудит (SOC 2, ISO 27001, 152-ФЗ, GDPR) даже этого может быть мало — энтерпрайз требует год и дольше. Причина не в том, что аудитор смотрит старые запросы каждый день, а в том, что инцидент всплывает через два месяца, и журнал должен быть на месте. Тема упирается в стоимость хранения: при 10 RPS на проект 90 дней — это 70–80 млн записей. Индустриальное решение типовое — горячий журнал с быстрой фильтрацией в основной БД плюс архивный лог в холодном хранилище (S3 или аналог), доступный по запросу. Отдельный нюанс: выгрузка содержит персональные данные (как минимум IP), поэтому по 152-ФЗ «Скачать журнал за год» одной кнопкой не работает — нужен контролируемый flow с подтверждением владельца, записью подтверждения в audit-trail платформы и ограничением скачивания одним разом.
Чего не делать в журнале. Не показывать полные тела запросов и ответов — типичный источник утечки персональных данных. Не делать журнал в реальном времени с поллингом каждую секунду — в 99% случаев хватает обновления по кнопке. Не прятать журнал за платным тарифом — это инструмент, без которого интеграция страдает на любом тарифе; в BOTIX журнал доступен с тестового триала. Rate-limit headers, replay-защита и audit-логи как единый слой надёжности — в отдельном разборе.
Sunset и 410 Gone
В день окончания срока поддержки старые эндпоинты начинают отдавать:
HTTP/1.1 410 Gone
{
"code": "API_VERSION_RETIRED",
"message": "API version 1.0.0 is no longer available. Migrate to 1.1.0.",
"details": {
"retired_version": "1.0.0",
"current_version": "1.1.0",
"migration_guide": "https://developers.botix.pro/migration-guide"
},
"request_id": "req_..."
}Это жёсткий sunset: 12 месяцев было время мигрировать. Альтернатива — soft sunset (после даты 410 первые 7 дней, потом удаление). Главное — sunset публично объявлен заранее. Никаких «продлили ещё на месяц по просьбе одного крупного клиента»: это разрушает доверие у всех, кто мигрировал в срок.
Чего не делать по всему циклу: не прятать breaking changes в патч-релизах с формулировкой «улучшение надёжности»; не использовать silent deprecation (эндпоинт меняет структуру без анонса); не сокращать срок поддержки под предлогом «мы стартап» — 6 месяцев минимум, 12 стандарт; не считать «расширение enum» non-breaking без проверки строгой валидации в SDK; не путать изменение формата с изменением семантики.
Чек-лист миграции
Политика опубликована и неизменна · срок ≥12 месяцев · обе версии параллельно (разные роутеры, общая логика) · критерий breaking change зафиксирован письменно · X-API-Deprecation в каждом ответе старой версии · миграционный гайд «было/стало» на всех языках SDK · email-подписка с ≥4 точками · RSS-фид · cursor вместо offset на list-эндпоинтах · audit-журнал на платформе с фильтром по версии (SQL по btx_api_log) · audit-журнал в кабинете интегратора с KPI-блоком · webhook delivery отдельной вкладкой · жёсткий sunset с 410 Gone. Хотя бы один пункт пропущен — миграция рискует стать инцидентом.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Публичный API v1: breaking changes, cursor и SDK из OpenAPI
Публичный API-контракт: deprecation, rate-limit, white-label
Idempotency-Key, rate-limit и audit-лог публичного API
Три кита production-ready API: idempotency, cursor, retry
