DarkRiDDeR12 мин

Разбор: клиент перестал читать ответ API

APIАрхитектура

После изменения ответа клиент может показать пустой итог, остановиться на обработке статуса или отправить 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.

Evidence packet до любого исправления
АртефактЧто записатьЧего не заключать без проверки
Границаmethod, endpoint, contract revision, reader v1/v2что URL сам определяет совместимость
Responsestatus и sanitized JSON bodyчто HTTP success означает корректную semantics
Requestkeys, presence/absence/null без секретовчто unknown field был применён, если ответ успешный
Reader resultmissing field, unsupported value или ignored keyчто любой parse error вызван сервером
Fixtureassertion и 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
Диагностическое дерево: собрать sanitized request и response, различить missing field, semantic change и unknown request field, затем остановить retirement или уточнить контракт.
Рисунок 1. Путь разбора не начинается с rollback. Сначала сохраняется evidence, затем выбирается узкое compatibility condition и только после него — обратимое действие.

Удобный формат 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 остаётся рядом с решением, поэтому следующая проверка не начинается заново.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Зафиксировать один конкретный contract case: endpoint, method, reader revision, sanitized request/response и наблюдаемое поведение. Не смешивать несколько клиентов в один incident-like рассказ.
  2. Причина. Классифицировать first failing rule: required response field отсутствует, known value изменил semantics, optional field имеет неверную форму или request содержит unknown key.
  3. Проверка. Запустить compatibility fixture на base/additive/candidate response и v1/v2 request. Сверить absence, null и object отдельно. Для retirement проверить именно старый reader.
  4. Действие. При 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 сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем