{ "index": 226, "slug": "editorial-2021-09-field-api-versioning", "title": "Когда поле исчезло: как диагностировать несовместимость API", "excerpt": "Клиент получил успешный HTTP-ответ, но не смог прочитать данные. Разбираем missing field, смену семантики и unknown request field, а затем выбираем обратимое действие.", "contentHtml": "

Клиент получает HTTP 200, но экран заказа остаётся пустым. В логах нет сетевой ошибки. Через несколько минут выясняется: backend заменил поле totalMinor на amountMinor, а старый reader всё ещё ищет первое имя. Похожий симптом возникает и при другой причине: сервер оставил имя, но поменял значение status с confirmed на accepted. В обоих случаях транспорт работает. Ломается договор о данных.

\n

Цена ошибки — не только один красный экран. Если откатить сервер вслепую, можно вернуть старое поле, но потерять уже опубликованный новый путь. Если молча игнорировать неизвестное поле запроса, пользователь выберет доставку, а заказ сохранит прежнюю настройку. Если повторить state-changing request без проверки идемпотентности, система может создать второй эффект. Поэтому версия API должна описывать совместимость представления и операций, а не только номер в URL.

\n

Тезис: сначала нужно зафиксировать конкретный body и первое нарушенное правило, затем проверить старого reader-а на candidate response. Только после этого выбирается действие: сохранить legacy-поле, вернуть прежнее значение, отклонить неизвестный key или поставить retirement на паузу.

\n

Что именно считается изменением

\n

Рассмотрим учебный endpoint POST /api/orders/{orderId}/confirm. Пример ограничен проверкой формы и семантики JSON. Он не изображает реальный production-трафик, базу данных или работу авторизации.

\n

Ответ v1 содержит обязательные поля id, status и totalMinor. Для status reader принимает значение confirmed. Ответ v2 может дополнительно содержать deliveryWindow. Если v1 reader получает это дополнительное поле, он может его не использовать: обязательные поля остались на месте, а смысл прежних полей не изменился.

\n

Удаление обязательного поля — structural break. Переименование totalMinor — тот же break, даже если новое поле содержит ровно ту же сумму. Замена confirmed на accepted — semantic break. Она требует решения о vocabulary, а не нового JSON-пути. Добавление optional-поля — additive change только для reader-а, который действительно умеет пережить неизвестный ключ.

\n

У request другой набор правил. confirmationCode обязателен. deliveryPreference optional: отсутствие означает «не менять», а null означает «очистить». Опечатка deliveryPrefrence — неизвестный key. Если сервер его молча пропустит, запрос формально завершится, но намерение пользователя исчезнет.

\n

Механизм проверки

\n

Разделите ответ на transport layer, shape и meaning. HTTP status говорит, дошёл ли запрос и какой общий результат сообщил сервер. JSON reader проверяет обязательные keys и их типы. Доменный слой проверяет допустимые значения и связывает их с действием интерфейса. Успешный status на первом слое не доказывает успех на двух следующих.

\n
function readV1Response(body) {\n  for (const key of ['id', 'status', 'totalMinor']) {\n    if (!(key in body)) return { ok: false, error: 'missing:' + key };\n  }\n\n  if (body.status !== 'confirmed') {\n    return { ok: false, error: 'unsupported-status:' + body.status };\n  }\n\n  if (!Number.isInteger(body.totalMinor) || body.totalMinor < 0) {\n    return { ok: false, error: 'invalid-totalMinor' };\n  }\n\n  return { ok: true, value: {\n    id: body.id,\n    status: body.status,\n    totalMinor: body.totalMinor,\n  } };\n}\n\n// Учебный reader: неизвестные response fields не используются.\n// Это правило нужно подтвердить для конкретного parser-а проекта.
\n

Функция намеренно не делает fetch и не повторяет запрос. Она принимает уже полученное представление и возвращает первую проверяемую причину отказа. Такой порядок важен: сообщение missing:totalMinor полезнее общего «не удалось разобрать ответ». Он связывает симптом с контрактом, но не утверждает, почему backend изменил body.

\n

Для request нужно отдельно решить политику unknown keys. Строгий validator лучше молчаливого игнорирования, если операция меняет состояние. В учебной модели он различает отсутствие optional-поля, явный null и неизвестное имя:

\n
function readConfirmRequest(body) {\n  const allowed = new Set(['confirmationCode', 'deliveryPreference']);\n  const unknown = Object.keys(body).filter((key) => !allowed.has(key));\n  if (unknown.length) return { ok: false, error: 'unknown-request-field' };\n  if (typeof body.confirmationCode !== 'string' || !body.confirmationCode) {\n    return { ok: false, error: 'missing-confirmationCode' };\n  }\n\n  return {\n    ok: true,\n    preference: Object.prototype.hasOwnProperty.call(body, 'deliveryPreference')\n      ? body.deliveryPreference\n      : 'unchanged',\n  };\n}
\n

Этот код не задаёт универсальную политику для всех API. В одном проекте неизвестные поля могут быть разрешены для forward compatibility. В другом они должны приводить к 400, чтобы опечатка не превращалась в потерянное намерение. Решение нужно записать в контракте и проверить на реальном parser-е, а не выводить из одного примера.

\n

Симптом → причина → проверка → действие

\n
Минимальная матрица диагностики
СимптомПричинаПроверкаДействие
v1 не показывает суммуtotalMinor удалили или переименовалиЗапустить v1 reader на сохранённом candidate bodyСохранить legacy field и остановить retirement
HTTP 200, но клиент выбрал другую веткуstatus изменил семантикуСверить vocabulary и допустимые значения reader-аВернуть прежнее значение или подготовить явный переход
Новое предпочтение не применилосьОпечатка или unknown request fieldПроверить keys, presence, absence и nullОтклонить запрос и исправить contract или client
v2 не показывает окно доставкиПерепутали absent, null и неполный objectПроверить три формы одним reader-омУточнить смысл optional field и сохранить старый response
\n

Матрица ускоряет сортировку, но не заменяет исходный body. Для каждого случая сохраните method, endpoint, revision, sanitized request, status, response body и версию reader-а. Секреты и персональные данные удаляйте до записи. Не заменяйте эти данные пересказом вроде «после релиза всё сломалось»: такой пересказ не позволяет отличить удаление поля от смены значения.

\n
\"Диагностическое
Рисунок 1. Диагностика начинается с сохранённого request и response. Rollback или retirement идут после проверки совместимости.
\n

Почему retirement нужно уметь остановить

\n

Предположим, команда хочет удалить totalMinor после перехода на amountMinor. Сначала запускается старый reader на candidate response. Если он возвращает missing:totalMinor, это не повод менять данные или повторять операцию. Это сигнал: старый consumer ещё зависит от поля либо переходный договор не доказан.

\n

Безопасное действие — зафиксировать retirement-paused-before-change, оставить legacy representation и указать условие повторной проверки. Такой шаг обратим: он не требует восстанавливать состояние после уже выполненной мутации. Если причина — semantic change, нужно вернуть прежнее значение или расширить reader с явным маппингом. Если причина — unknown request key, нельзя добавлять молчаливый fallback только ради зелёного HTTP status.

\n

Заголовок deprecation тоже не заменяет проверку. Он может сообщить потребителю, что ресурс планируют вывести, но не доказывает, что потребитель найден, миграция завершена или старый parser принимает новую форму. Retirement имеет смысл только после проверки всех известных consumers и условия отката.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите один contract case: endpoint, method, revision, sanitized request, response, HTTP status и видимое поведение клиента. Не смешивайте в один вывод несколько приложений.
  2. Назовите первое нарушенное правило. Проверьте обязательный response key, тип, допустимое значение, форму optional object и неизвестные request keys. Отделите отсутствие от null.
  3. Сравните представления. Запустите старый и новый reader на base, additive и candidate response. Для request отдельно проверьте обязательное поле, optional absence, optional null и опечатку.
  4. Выберите обратимое действие. При failed retirement gate сохраните legacy field и остановите removal. При semantic break согласуйте vocabulary. При unknown request field верните явный отказ, если этого требует contract.
  5. Добавьте защиту. Оставьте compatibility test для старого reader-а, проверку нового reader-а и наблюдаемый сигнал для rejected request. Повтор state-changing operation разрешайте только по отдельному правилу идемпотентности.
\n

Ограничения

\n

Примеры выше учебные. Они не доказывают результат в production и не моделируют авторизацию, rate limit, cache, proxy, сериализацию undefined, retry policy, базу или фактическое распространение мобильного клиента. Не каждый JSON parser игнорирует неизвестные response fields. Не каждый сервер обязан отвергать неизвестные request fields. Эти свойства нужно проверить в конкретном стеке.

\n

OpenAPI описывает форму входа и выхода, но сама спецификация не выполняет миграцию и не находит всех потребителей. Версия URL также не гарантирует совместимость: два разных пути могут использовать один несовместимый reader, а один путь может безопасно обслуживать additive response. Готовность retirement нельзя выводить из даты релиза или единственного успешного запроса.

\n

Проверяемый критерий готовности

\n

Изменение готово к рассмотрению, когда старый reader проходит на каждом сохранённом response, новый reader проходит на новой форме, request validator различает отсутствие, null и unknown key, а отрицательные проверки возвращают ожидаемые ошибки. Для removal отдельно доказано, что известные consumers больше не зависят от legacy field и что есть обратимое действие до первой несовместимой мутации. Если хотя бы одно условие не выполнено, retirement остаётся на паузе.

\n

Проверяемые источники

\n" }