После изменения ответа клиент может показать пустой итог, остановиться на обработке статуса или отправить request без ожидаемого эффекта. Симптом похож на «сломалась версия API», но это ещё не причина. Цена поспешного исправления высока: можно удалить новый путь, не сохранив body и parser rule, либо повторить state-changing request и скрыть исходный разрыв за другой ошибкой.
Начинать надо с evidence, которое не требует доступа к реальному production. Для одного наблюдаемого случая достаточно contract ID или revision, endpoint, method, sanitized request, status, response body с исключёнными секретами, версии reader-а и результата compatibility test. Если не разделить missing key, unknown request key и semantic change, команда выберет rollback вслепую и не узнает, какой consumer должен получить переходный договор.
Сначала фиксируем границу случая
Учебный endpoint здесь тот же: POST /api/orders/{orderId}/confirm. v1 response обязан содержать id, status: "confirmed" и totalMinor. v2 добавляет optional deliveryWindow. У request свои правила: confirmationCode обязателен, deliveryPreference optional; absence означает «не менять», null — «очистить», а неизвестный key отклоняется. Такая запись делает следующую проверку конечной: можно сравнить конкретный body с одним contract sample.
| Артефакт | Что записать | Чего не заключать без проверки |
|---|---|---|
| Граница | method, endpoint, contract revision, reader v1/v2 | что URL сам определяет совместимость |
| Response | status и sanitized JSON body | что HTTP success означает корректную semantics |
| Request | keys, presence/absence/null без секретов | что unknown field был применён, если ответ успешный |
| Reader result | missing field, unsupported value или ignored key | что любой parse error вызван сервером |
| Fixture | assertion и candidate revision | что in-memory result описывает реальный deployment |
Особенно важно не подменять evidence рассказом. Если в body нет totalMinor, это structural break для v1 contract. Если key есть, но status равен accepted, это semantic break в данной модели. Если client v1 получает extra deliveryWindow и reader выбирает прежние три поля, additive response прошёл именно для этого reader-а. Эти три случая оставляют разный след и требуют разных действий.
Короткий диагностический reader
Ниже минимальная функция, которая превращает body в наблюдаемый вывод. Она не знает HTTP и не даёт совет повторять запрос. Её задача — назвать первое нарушенное правило: обязательное поле, ожидаемую semantics или форму денежного значения. Unknown response fields остаются вне view; это явный policy данной fixture, а не предположение о каждом JSON parser-е.
function readV1(response) {
for (const field of ['id', 'status', 'totalMinor']) {
if (!Object.hasOwn(response, field)) throw new Error('missing ' + field);
}
if (response.status !== 'confirmed') throw new Error('status semantics changed');
if (!Number.isInteger(response.totalMinor)) throw new Error('invalid totalMinor');
return {
id: response.id,
status: response.status,
totalMinor: response.totalMinor,
// Неизвестные response-поля сознательно не попадают в view.
};
}
Для request доказательство строится отдельно. Если v2 послал новое known field, current service принимает его после проверки значения. Если пришла опечатка deliveryPrefrence, результат должен быть отказом unknown-request-field, а не невидимой потерей намерения. В пакете absence deliveryPreference и null также не склеиваются: первое оставляет прежнее предпочтение, второе просит очистить. В логике изменения это разные команды даже при одинаковом endpoint.
function acceptRequest(request) {
const known = new Set(['confirmationCode', 'deliveryPreference']);
const unknown = Object.keys(request).filter((key) => !known.has(key));
if (unknown.length) return { ok: false, reason: 'unknown-request-field', unknown };
if (!request.confirmationCode) return { ok: false, reason: 'confirmationCode-required' };
if (!Object.hasOwn(request, 'deliveryPreference')) return { ok: true, preference: 'unchanged' };
if (request.deliveryPreference === null) return { ok: true, preference: 'clear' };
return { ok: true, preference: 'set' };
}
acceptRequest({ confirmationCode: 'A7K9' }); // unchanged
acceptRequest({ confirmationCode: 'A7K9', deliveryPreference: null }); // clear
acceptRequest({ confirmationCode: 'A7K9', deliveryPrefrence: 'weekday' }); // reject typo
Матрица симптомов не заменяет raw evidence
| Наблюдение | Вероятная причина | Минимальная проверка | Rollback-safe действие |
|---|---|---|---|
| v1 не видит сумму | totalMinor удалён или переименован | v1 reader на candidate body | оставить legacy field, остановить retirement |
| HTTP success, но ветка клиента другая | status изменил смысл | проверить допустимый vocabulary reader-а | вернуть прежний semantic contract или подготовить явный переход |
| Новое предпочтение не применилось | unknown/опечатанное request-поле | validator на keys и absence/null | не ретраить вслепую; отклонить key и исправить request contract |
| v2 не показывает окно | absent и null перепутаны либо object неполный | v2 reader на три representations | сохранить старый response и уточнить смысл optional field |
Удобный формат evidence — один object с неизменяемыми samples, именем assertion и коротким выводом. В fixture такой object не пытается моделировать мониторинг: он хранит только contract facts. Это защищает от знакомой ошибки «мы уже знаем причину»: пока v1 reader не запущен на candidate response, отсутствие totalMinor остаётся гипотезой, а не диагнозом.
Пауза перед retirement — нормальное техническое действие
В учебной fixture candidate retirement удаляет totalMinor и публикует только amountMinor. v1 reader обязан его отклонить. После этого timeline фиксирует не «rollback deployment», а retirement-paused-before-change: legacy response fields сохранены, v2-only request mode не включён ради removal, data mutation равна false. Это обратимый шаг, потому что он не требует восстанавливать состояние и не выдаёт непроверенный candidate за новую норму.
import { runApiVersioningFixture } from './upgrade-2021-09.mjs';
const fixture = runApiVersioningFixture();
for (const [name, passed] of Object.entries(fixture.assertions)) {
if (!passed) throw new Error('failed: ' + name);
}
console.log(fixture.timeline.map(({ stage }) => stage));
// retirement-candidate-rejected → rollback-safe-pause
Если причина не в removal, пауза всё равно помогает. При semantic change нужно вернуть договорённое значение или явно подготовить reader к новому vocabulary. При unknown request key нужно исправить contract/клиент, а не «добавить совместимость» через молчаливое игнорирование. При путанице absent/null нужно выбрать один письменный смысл и обновить v2 tests. В каждом случае evidence остаётся рядом с решением, поэтому следующая проверка не начинается заново.
Маршрут: симптом → причина → проверка → действие
- Симптом. Зафиксировать один конкретный contract case: endpoint, method, reader revision, sanitized request/response и наблюдаемое поведение. Не смешивать несколько клиентов в один incident-like рассказ.
- Причина. Классифицировать first failing rule: required response field отсутствует, known value изменил semantics, optional field имеет неверную форму или request содержит unknown key.
- Проверка. Запустить compatibility fixture на base/additive/candidate response и v1/v2 request. Сверить absence,
nullи object отдельно. Для retirement проверить именно старый reader. - Действие. При failed gate остановить retirement до change, сохранить legacy representation и записать условие повторного рассмотрения. При request error вернуть явный rejection, а не повторять state-changing operation без отдельного правила проекта.
Как сообщить о deprecation без ложного обещания
Сообщение о deprecation должно содержать не только слово «устарело», но и границу: какой resource или representation затронут, какие fields остаются, какой migration sample считается готовым и где проверяется старый consumer. RFC 8594 описывает Sunset как hint о вероятной будущей недоступности определённого resource; сам заголовок не гарантирует момент вывода и не заменяет переходный контракт. Поэтому его можно использовать только как дополнительный сигнал к проверяемому плану.
Не стоит заявлять, что fallback «вернём /v1» всегда безопасен. Если v1 и v2 меняют side effect request, простой маршрутизатор не создаёт совместимость. Здесь endpoint и данные учебные, а pause происходит до data mutation. В реальной системе сначала нужно определить owner запроса, повторяемость операции, авторизацию и способы обнаружить потребителя — это другие проверки, которых данная fixture не выполняет.
Ограничения
В этой статье нет реального инцидента, deployment, клиентов, SLA, метрик, HTTP-трафика, базы или API-вызовов. Мы не утверждаем, что любой parser игнорирует unknown response fields и что любой сервер обязан отвергать unknown request fields. OpenAPI и RFC используются как исторические первичные источники для описания API, request body и retirement signal, а не как сертификат совместимости. Практическое решение требует проверить конкретные consumers и их parsers на изолированных examples.
Проверяемые источники
- OpenAPI Specification 3.0.3 — 20 февраля 2020 года — официальная фиксированная редакция: отличает версию самой спецификации от версии API, описывает request body, parameter и response; это описание контракта, а не готовая проверка конкретного клиента
- OpenAPI Specification 3.1.0 — 15 февраля 2021 года — официальная редакция, доступная к сентябрю 2021 года: Schema Object задаёт форму input/output, а прикладная семантика не выводится автоматически из формы
- RFC 8594: The Sunset HTTP Header Field — май 2019 года — первичный документ IETF: Sunset сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем