Версионирование схемы ответов без поломки клиентов
Версионирование схемы ответов помогает выпускать новые поля и enum без сбоев: разберем правила совместимости, раскатку, проверки и откат.

Почему клиенты ломаются на ровном месте
Самая частая причина скучная: клиент живет в мире, где формат ответа давно зашит в коде. Разработчик один раз описал JSON, написал парсер и больше к нему не возвращался. Сервер меняет одну мелочь, а клиент считает это ошибкой, даже если смысл ответа не изменился.
На бумаге новое поле кажется безопасным. На деле многие клиенты не игнорируют лишнее, а пытаются разобрать ответ строго по известной структуре. Если мобильное приложение, SDK или внутренний сервис ждут ровно пять полей, шестое поле может сломать валидацию, кеш или маппинг в объект. Пользователь видит не новую функцию, а пустой экран.
Чаще всего проблемы появляются в четырех случаях:
- клиент знает только старые имена полей и теряет данные, когда сервер переименовал поле или сменил вложенность
- мобильный код ожидает enum вроде
pendingиdone, а сервер добавляетpartial - парсер ждет число, а получает строку
"42"и останавливает разбор всего ответа - одна правка в контракте доезжает до нескольких клиентов сразу, и команда ночью выпускает hotfix
С enum проблемы особенно неприятны. Команда сервера думает: мы просто добавили еще один статус. Но клиент часто пишет switch по известным значениям без ветки default. Новый вариант не выглядит как расширение. Для старого приложения это неизвестное состояние, на которое код не рассчитан.
Смена типа еще опаснее. Поле price было числом, стало строкой с валютой. Поле created_at было ISO-датой, стало Unix time. Серверу кажется, что ответ стал удобнее. Клиенту приходится переписывать парсер, тесты и иногда бизнес-логику. Такая правка редко затрагивает только одно место.
Есть и организационная причина. Серверная команда выпускает изменение быстро, а мобильные и интеграционные клиенты обновляются неделями. В B2B это заметно еще сильнее: внешний клиент может жить на старой версии месяцами. Поэтому маленькая правка в ответе часто тянет большой хвост.
Если вы отдаете структурированные ответы через один API-шлюз, проблема становится шире. Один и тот же контракт читают веб, мобильное приложение, бэкенд и партнерские интеграции. Ошибка в схеме бьет сразу по всем. Поэтому версионирование схемы - не бюрократия, а нормальный способ не чинить прод ночью.
Какие изменения старые клиенты переживут
Старый клиент ломается не из-за самого JSON, а из-за слишком жестких ожиданий в коде. В версионирование схемы ответов лучше заложить простое правило: клиент читает только то, что ему нужно, и не делает лишних предположений о форме ответа.
Обычно безопасно
Новое необязательное поле обычно не мешает старым клиентам. Если клиент игнорирует незнакомые поля, он продолжит работать как раньше. Так ведут себя многие SDK и JSON-парсеры.
Чаще всего безопасны такие изменения:
- добавили поле
middle_name, а старый клиент читает толькоfirst_nameиlast_name - поменяли порядок полей в объекте
- добавили новый объект в ответ, который старый клиент не использует
- расширили enum, если клиент умеет принять
unknown
С порядком полей все просто: он не должен ничего значить. JSON-объект - это набор пар имя-значение, а не список с фиксированными позициями. Если клиент ждет, что status всегда идет раньше id, проблема в клиенте.
С enum тоньше. Новое значение вроде archived выглядит безобидно, но безопасно оно только там, где клиент не делает жесткий switch по старому списку. Нужна запасная ветка: unknown, other или хотя бы общее поведение для незнакомого значения.
Почти всегда ломает
Некоторые изменения требуют новой версии схемы, даже если на сервере они выглядят мелочью:
- обязательное поле вместо необязательного
- строка вместо числа, или число вместо строки
- смена формата даты, идентификатора или денежных сумм
- удаление старого поля, которое еще читают клиенты
Если вчера поле total было числом, а сегодня стало строкой "1999.00", старый клиент может молча посчитать все неверно или упасть на десериализации. Такие смены типа нельзя выпускать как обычное обновление.
То же правило работает и для OpenAI-совместимых ответов. Если команда проксирует модели через единый API, старые интеграции переживут новый необязательный блок метаданных, но не переживут смену типа, обязательности или списка значений без запасной ветки.
Где держать версию схемы
Лучше всего хранить версию в самом JSON-ответе. Поле schema_version сразу показывает клиенту, по каким правилам читать объект. Это надежнее, чем надеяться на документацию, письмо о релизе или память команды.
Когда версия приходит вместе с данными, клиентский код быстрее понимает, что делать дальше. Один сервис еще работает по 1.2, другой уже умеет 1.3, и оба живут спокойно, если сервер не ломает старый контракт. Такой подход особенно полезен в больших системах, где обновления идут не в один день.
Формат версии может быть любым: 2, 2.1 или дата вроде 2025-04-01. Важнее, чтобы правило было одно и то же для всех ответов этого типа. Если сегодня версия лежит в теле ответа, завтра в заголовке, а послезавтра только в wiki, команда сама создает себе лишние ошибки.
Одного поля в ответе мало. Ту же версию стоит дублировать в JSON Schema, в примерах ответа и в контрактных тестах. Тогда у вас не будет трех разных правд. Если схема говорит 1.4, пример показывает 1.3, а тесты вообще не проверяют версию, проблема всплывет уже после релиза.
Changelog нужен, но только как журнал изменений. Клиентский код changelog не читает. Разработчик тоже часто открывает его уже после сбоя. Если версия спрятана только там, о несовпадении вы узнаете слишком поздно.
Выносить новую версию в отдельный эндпоинт стоит только при ломающих правках. Например, если вы удаляете поле, меняете его тип или перестраиваете вложенный объект. Для нового необязательного поля или нового значения enum отдельный v2 обычно не нужен. Иначе API быстро зарастает копиями, которые сложно поддерживать.
Если команда подключает RU LLM и меняет только base_url на api.rullm.com, интеграция обычно остается на тех же SDK, коде и промптах. В такой схеме предсказуемый контракт ответа особенно важен: сюрпризов после переключения быть не должно.
Как выпустить новое поле по шагам
Новое поле чаще ломает не API, а слишком строгого клиента. Кто-то парсит ответ по точному списку свойств, кто-то валится на незнакомом JSON, кто-то собирает объект без допуска лишних полей. Поэтому версионирование схемы ответов лучше делать как маленький релизный процесс, а не как одну правку в коде.
Сначала добавьте поле как необязательное. Старый клиент должен получить тот же ответ, что и раньше, и спокойно отработать. Если у поля нет честного значения, не подставляйте случайный null или пустую строку только ради схемы. Лучше временно не присылать поле вовсе.
Потом сразу обновите все артефакты, которыми живет команда: схему, примеры в документации, фикстуры для тестов и контрактные тесты API. На практике именно фикстуры чаще всего выдают правду. Разработчик видит старый пример, копирует его в клиент и через неделю получает сюрприз на проде.
Если клиентские команды пишут SDK или внутренние адаптеры, попросите их игнорировать незнакомые поля. Это скучное правило, но именно оно держит обратную совместимость API. Для OpenAI-совместимых интеграций это особенно заметно: команда меняет только base_url, оставляет прежний SDK и ждет, что ответ останется читаемым без ручной переделки.
Рабочая последовательность обычно такая:
- Добавьте поле в схему как optional.
- Обновите примеры, фикстуры и контрактные тесты API.
- Проверьте, что клиенты не падают на лишних полях.
- Раскатайте поле на малую долю трафика, например на 1-5% запросов.
- Посмотрите на ошибки парсинга, ретраи и обращения в поддержку.
Малая раскатка нужна не для красоты. Она быстро показывает, где живут самые хрупкие клиенты. Один мобильный клиент со старой библиотекой может ломать больше трафика, чем десять новых сервисов. Если видите всплеск 4xx, 5xx или странные таймауты после чтения ответа, откатывайте флаг и разбирайтесь до полного релиза.
Пример безопасного добавления выглядит так:
{
"id": "ord_42",
"status": "paid",
"delivery_eta_minutes": 35
}
Старый клиент читает id и status, а delivery_eta_minutes просто пропускает. Новый клиент уже может показать ETA в интерфейсе.
Если без нового поля дальше нельзя, не ломайте v1. Оставьте поле необязательным в текущей версии, а обязательным делайте только в v2. Тогда у клиентов будет нормальный миграционный путь, а у команды не появится ночной hotfix после релиза.
Как добавить новое значение enum
Новые значения enum ломают клиентов чаще, чем новые поля. Причина простая: такое поле обычно разбирают через switch или жесткое сравнение строк. Как только сервер прислал незнакомое значение, старый клиент уходит в ошибку, показывает пустой экран или молча делает не то действие.
Самая дешевая защита - заранее оставить запасное значение вроде unknown или other. Даже если вы не используете его в первой версии, оно задает правило: список значений не закрыт навсегда. Клиенты это быстро считывают и не строят логику так, будто мир заканчивается на трех строках из документации.
В коде разбора enum всегда нужна запасная ветка. Если клиент получил новое значение, он не должен падать. Пусть он пишет событие в лог, сохраняет сырое значение рядом, выбирает нейтральное поведение и показывает понятный статус без поломки экрана.
Есть правило, которое нарушают особенно часто: не меняйте смысл старого значения задним числом. Если approved вчера значило заказ подтвержден, не превращайте его сегодня в деньги списаны. Для сервера это может казаться маленькой правкой, а для клиента это уже другой контракт. Если смысл изменился, добавьте новое значение и дайте старому спокойно дожить свой срок.
Новое значение лучше включать не сразу для всех. Сначала откройте его по флагу, по списку клиентов или по отдельным API-ключам. Так вы увидите, кто реально не обрабатывает неизвестные варианты. После этого можно расширять выдачу на весь трафик без ручного разворота патчей.
В тот же день обновите SDK, типы и примеры ответов. Если в TypeScript enum уже знает про retrying, а в Python и Go его еще нет, команды получают разные контракты и начинают чинить одно и то же в трех местах. Документация, генераторы типов и контрактные тесты должны ехать вместе.
Хороший тест для релиза простой: возьмите старого клиента и отправьте ему новое значение. Если он продолжает работать и выбирает запасной сценарий, значит enum расширяется безопасно.
Пример с ответом по заказу
Хороший тест на версионирование схемы ответов - обычный ответ по заказу. На старте API отдает только три поля: id, status и total. Старый мобильный клиент знает ровно этот контракт и рисует экран без лишней логики.
{
"id": "ord_4821",
"status": "paid",
"total": 14990
}
Через месяц команда хочет показать способ оплаты в личном кабинете. Самый безопасный ход - добавить payment_method как необязательное поле и не менять смысл старых полей. Клиенты, которые это поле не знают, просто пропустят его. Новые клиенты смогут вывести, например, sbp или card.
{
"id": "ord_4821",
"status": "paid",
"total": 14990,
"payment_method": "card"
}
Проблема обычно начинается позже, когда бизнес просит новый статус. Допустим, заказ вернули не полностью, и API начинает отдавать partially_refunded. Если старый клиент написал код в духе: если статус не paid, pending или refunded, выбросить ошибку, экран заказа упадет. Если клиент заранее умеет жить с незнакомым enum и сводит его к нейтральному состоянию вроде Статус обновляется, приложение продолжит работать.
Это выглядит скучно, но спасает релиз. Пользователь увидит заказ, сумму и базовую информацию, а не белый экран. Поддержка не получит волну жалоб только потому, что сервер научился новому значению.
Обычно переход идет в три шага: сначала сервер добавляет новое поле и убеждается, что старые клиенты его игнорируют; потом клиентские парсеры учат спокойно переживать неизвестные значения status; и только после этого для новых клиентов выпускают v2, где статус partially_refunded уже описан явно.
В v2 можно не только зафиксировать новый enum, но и уточнить смысл полей. Например, оставить total как исходную сумму заказа, а сумму возврата вынести в отдельное поле. Тогда контракт становится понятнее, а переход идет без ручного hotfix.
Смысл примера простой: новое поле почти всегда безопаснее нового поведения. Неизвестный payment_method старый клиент переживет легко. Неизвестный status он переживет только если вы заранее заложили fallback.
Где команды чаще ошибаются
Большинство поломок происходит не из-за сложной логики, а из-за спешки после merge. Поле уже добавили на сервер, тесты прошли, и команда сразу помечает его как required. Для нового клиента это нормально, а старый в этот момент еще живет в проде и ничего не знает о новом контракте. Так и появляется баг, который потом чинят hotfix.
С enum история еще неприятнее. Разработчик добавляет новое значение статуса, например archived, а клиентский код знает только new, paid и cancelled. Если в switch или match нет безопасной ветки по умолчанию, приложение может просто упасть или показать пустой экран. Новое значение должно выглядеть для старого клиента как неизвестный, но допустимый случай, а не как авария.
Еще одна частая ошибка прячется в автогенерации SDK. Команда делает слишком строгий клиент, который ожидает ровно те поля, что были в схеме на день генерации. Потом сервер начинает отдавать новое поле JSON, и клиент считает ответ невалидным, хотя по смыслу ничего страшного не произошло. Если клиент не умеет спокойно пропускать неизвестные поля, обратная совместимость API держится на удаче.
Плохо и то, когда меняют тип поля вместе с названием в одном релизе. Вчера было status: "paid", сегодня стало order_state: { code: "paid" }. Формально новая схема может быть чище, но старый клиент теряет и имя поля, и привычный тип сразу. Такой переход лучше разбивать: сначала добавить новое поле рядом со старым, потом перевести клиентов, и только потом убирать старое.
Мобильные команды часто получают самый тяжелый удар. Сервер уже выкатывает новую схему, а приложение для iOS или Android еще ждет модерацию в сторе. Несколько дней, а иногда и неделю, в проде живут старые сборки. Если сервер к этому не готов, пользователи получают падения там, где команда ждала обычное обновление ответа.
Обычно хватает трех правил:
- не делать новое поле обязательным в тот же релиз
- всегда обрабатывать неизвестные значения enum
- не ломать старые клиенты строгой проверкой лишних полей
Контрактные тесты API хорошо ловят такие ошибки, но только если они проверяют обе стороны: старый клиент против нового ответа и новый клиент против старого ответа. Если этот прогон не входит в релизный чек, команда почти всегда узнает о проблеме уже после выкладки.
Быстрый чек перед релизом
Перед выкладкой новой схемы полезно пройти короткий список и не решать спорные места уже после релиза. В теме версионирования схемы ответов это часто спасает не часы, а целые дни: новое поле добавить легко, а починить чужие интеграции потом намного тяжелее.
Обычно хватает пяти проверок:
- новое поле в текущей версии остается необязательным
- для каждого enum есть запасная ветка
- схему, SDK, фикстуры и контрактные тесты команда обновляет одним релизом
- логи пишут
schema_versionиclient_idна каждый запрос - команда умеет откатить формат ответа без миграции базы
У каждого пункта есть простой смысл. Старый клиент должен спокойно обработать ответ, даже если он ничего не знает о новом поле. Если завтра появится новое значение enum, клиент не должен падать на жестком сравнении, а должен уводить ответ в unknown, other или другой безопасный сценарий. Если типы уже новые, а примеры ответов и тесты старые, баг вы создаете сами.
Логи с schema_version и client_id особенно полезны там, где между моделью и клиентом стоит шлюз. Если вы используете RU LLM, такие данные удобно писать в аудит-трейл запроса и быстро видеть, кто именно перестал принимать ответ.
Есть одна частая ошибка: разработчики считают поле безопасным, потому что оно optional в схеме, а сериализатор все равно отправляет null или пустой объект. Для части клиентов это уже другое поведение. Если раньше поля не было, лучше и дальше не присылать его, пока клиент не готов.
С enum похожая история. Запасная ветка нужна не только в мобильном приложении или фронтенде, но и в бэкенд-консьюмерах, очередях, отчетах и правилах маршрутизации. Один строгий switch без default может сломать весь путь обработки.
Если хотя бы один пункт не проходит, релиз лучше сдвинуть. День на проверку почти всегда дешевле, чем hotfix, ручной откат и разбор того, почему один старый клиент внезапно начал считать валидный JSON ошибкой.
Что делать дальше в проде
После первого аккуратного релиза работа только начинается. Схема живет дольше, чем любой спринт, а клиенты обновляются неравномерно. Один сервис перейдет на новую версию сегодня, мобильное приложение - через месяц, партнерская интеграция - когда у них дойдут руки.
Поэтому правила совместимости не стоит держать в головах или в старых обсуждениях. Нужна одна короткая страница с четкими договоренностями: какие изменения вы считаете безопасными, как помечаете устаревшие поля, сколько живет старая версия и кто дает добро на снятие поддержки. Если правило нельзя проверить, это не правило, а пожелание.
В проде хорошо работает такой минимум:
- кладите
schema_versionв каждый ответ - пишите ту же версию в логи и аудит-трейлы
- гоняйте контрактные тесты в CI на старых и новых клиентах
- выпускайте изменение через канареечную раскатку, а не сразу на всех
- снимайте старые версии по календарю, с объявленной датой
schema_version в теле ответа решает сразу две задачи. Клиент понимает, по какому контракту он парсит JSON, а команда быстро находит источник сбоя в логах. Если в журнале видно, что ошибка пошла только на v2025-07, вы не тратите полдня на догадки.
Контрактные тесты лучше держать рядом с кодом, который формирует ответ, а не в отдельном репозитории для галочки. Пусть CI поднимает сборку и проверяет: старый клиент читает новый ответ, новый клиент читает старый, неизвестное поле не ломает парсер, новое значение enum не убивает обработчик. Канареечная раскатка после этого ловит то, что тесты обычно пропускают: реальные данные, редкие статусы и кривые интеграции.
Если вы отдаете структурированные ответы моделей через RU LLM, проще сразу держать одинаковые правила для всех клиентов на одном OpenAI-совместимом эндпоинте. Одна схема валидации, одно поле schema_version и одинаковое логирование версии в каждом запросе заметно снижают число сюрпризов.
Старые версии снимайте по расписанию, а не после ночного инцидента. Назначьте дату, предупредите команды, замерьте остаточный трафик и только потом отключайте. Так схема меняется спокойно, а клиенты не узнают о релизе по пустому экрану.