diff --git a/editorial/agent-rewrites/065.json b/editorial/agent-rewrites/065.json index 58b4144..8e55517 100644 --- a/editorial/agent-rewrites/065.json +++ b/editorial/agent-rewrites/065.json @@ -3,5 +3,5 @@ "slug": "editorial-2026-03-mechanism-data-contracts", "title": "Совместимость схемы — это направление, а не номер версии", "excerpt": "Как compatibility gate отделяет направление чтения, обязательные поля, объявленные additions и возможности consumer — и почему неопределённость должна останавливать проверку.", - "contentHtml": "
Симптом обычно выглядит безобидно: producer выпускает схему v2, consumer видит знакомые поля, а в ревью появляется короткое слово compatible. Затем старый reader получает данные с новым полем, не находит обязательный state или встречает поле другого типа. Ошибка проявляется уже на границе сервисов. Цена — отклонённые сообщения, неверные значения по умолчанию, ручная миграция и спор о том, что именно обещала версия.
Тезис простой: совместимость нельзя вычислять по номеру версии, пересечению имён или удачному примеру сериализации. Сначала нужно назвать направление, две точки схемы и конкретного consumer. Затем отдельно проверить обязательную поверхность, объявленные additions и capability reader. Если хотя бы одна часть неизвестна, gate должен остановиться. Такой отказ полезнее зелёного статуса без объяснения.
\nНазовём baseline старой схемой и candidate новой схемой. В выбранном направлении фиксированный producer создаёт candidate, а фиксированный consumer читает эту форму, опираясь на baseline как на точку отсчёта. Это не единственное возможное направление. Новый reader может читать старые данные, но это уже другой вопрос и другая карточка сравнения.
\nМинимальная запись отношения содержит пять значений: direction, family, baselineVersion, candidateVersion и consumerId. family не даёт сравнить случайные JSON-объекты только потому, что у них совпали ключи. Версии закрепляют обе точки. consumerId не позволяет заменить проверяемого reader абстрактным «клиентом». Пустое или изменённое значение даёт stop-implicit-comparison.
Первая проверка смотрит на обязательную поверхность baseline. Если required-поле исчезло из candidate или сменило тип, старый reader больше не получает обещанную форму. Например, замена state на phase может казаться переименованием с тем же смыслом. Gate не угадывает смысл имён. Для reader поле state отсутствует, поэтому результат — stop-backward-incompatible-schema.
Вторая проверка смотрит на новые поля. Candidate может сохранить id и state, но добавить priority. Это не разрушает обязательную поверхность. Однако producer должен явно назвать addition в manifest. Скрытое routingHint, появившееся в candidate без записи в manifest, даёт stop-undocumented-schema-field. Gate сначала требует объяснить новую поверхность, а потом спрашивает, принимает ли её reader.
Третья проверка смотрит на capability consumer. Tolerant reader может разрешать declared additions. Strict reader может отклонять неизвестные поля. Слово optional в схеме producer не меняет policy reader автоматически. Если strict consumer не принимает priority, результат — stop-incompatible-consumer. Gate не удаляет поле на лету и не придумывает adapter. Команда отдельно выбирает изменение reader, разделение формы, задержку candidate или миграцию.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Для двух схем написано compatible, но не указано направление | Один boolean склеил разные отношения producer и reader | Проверить direction, family, baseline, candidate и consumerId | Остановить как stop-implicit-comparison и оформить relation |
В candidate нет обязательного state | Required-поле baseline удалили или переименовали | Построить field map и сравнить required surface baseline | Вернуть поле или назвать отдельную migration |
Новый routingHint есть в данных, но нет в описании изменения | Фактический diff шире declared manifest | Сверить additions candidate с declaredAddedFields | Остановить как stop-undocumented-schema-field |
| Tolerant reader проходит, strict reader падает | Capability зависит от конкретного consumer, а не от версии producer | Проверить policy declared additions у named reader | Изменить reader, форму или завести migration |
| Отчёт говорит «75% совместимо» | Агрегат скрыл разные причины и владельцев следующего шага | Проверить исходный status и reason одного сравнения | Сохранить конкретный stop-status вместо процента |
Ниже — учебный JavaScript-подобный пример. Он не подключается к registry, сети, файловой системе, CI или production data. fixedCase возвращает заранее известный объект, а review выполняет только описанные проверки. Пример показывает форму решения, но не доказывает совместимость реального формата.
const baseline = {\n version: '1.0',\n required: { id: 'string', state: 'string' }\n};\n\nconst candidate = {\n version: '2.0',\n required: { id: 'string', phase: 'string' },\n additions: []\n};\n\nconst relation = {\n direction: 'backward',\n family: 'orders',\n baselineVersion: '1.0',\n candidateVersion: '2.0',\n consumerId: 'orders-reader'\n};\n\nconst report = review({ baseline, candidate, relation });\nconsole.log(report);\n// {\n// status: 'stop-backward-incompatible-schema',\n// removedRequiredFields: ['state'],\n// nextAction: 'retain-required-baseline-field-or-name-a-separate-migration'\n// }\nВажна не длина функции, а граница вывода. Gate обнаружил отсутствие state. Он не объявил новый phase эквивалентом, не выдал разрешение на deploy и не выбрал стратегию миграции. Следующий шаг зависит от владельца контракта и требований старого reader.
Положительный учебный случай тоже ограничен. Если candidate сохраняет id и state, добавляет объявленный priority, а named reader допускает declared additions, gate может вернуть synthetic hand-off. Это означает только то, что фиксированная проверка закончилась положительно. Это не означает, что parser, registry, права, нагрузка и выпуск в реальной системе готовы.
Если сначала спросить reader, принимает ли он неизвестные поля, tolerant policy может скрыть неописанное изменение producer. Поэтому gate сначала устанавливает отношение, затем проверяет required surface, потом сверяет manifest и только после этого проверяет capability.
\nТак распределяется ответственность. Producer называет новую поверхность. Сравнение проверяет буквальную форму. Manifest связывает diff с намерением. Consumer описывает границу принятия. Ни один слой не подменяет другой. Если переставить шаги, зелёный результат может появиться раньше, чем команда поймёт, что именно она выпускает.
\nconsumerId и остановите сравнение при неизвестной или неполной связи.Нельзя считать gate работающим только по зелёному fixed case. Передайте объект без direction. Ожидайте stop-implicit-comparison. Удалите state из candidate. Ожидайте stop-backward-incompatible-schema. Добавьте routingHint без manifest. Ожидайте stop-undocumented-schema-field. Замените tolerant reader на strict reader. Ожидайте stop-incompatible-consumer.
Каждый отказ должен сохранять следующий шаг. Неизвестное отношение требует уточнить карточку. Удалённое required-поле требует вернуть поверхность или назвать миграцию. Скрытый addition требует обновить описание изменения или убрать поле. Несовместимый reader требует решения владельца consumer. Общий статус «не прошёл» не даёт команде достаточного действия.
\nЭтот gate проверяет узкое отношение между фиксированными описаниями. Он не извлекает схемы из registry, не знает все deployment-версии, не проверяет реальные payloads и не подтверждает, что consumer честно описал свои потребности. Он также не решает семантическое изменение: строка state=active может сохранить тип и имя, но начать означать другой бизнес-статус.
Он не заменяет contract tests, миграцию данных, нагрузочную проверку, security review, SLA и план отката. JSON Schema, JTD и Avro дают полезные понятия для формы и чтения, но не определяют статусы этого gate. Поэтому результат нужно читать узко: механизм сделал одно сравнение явным и остановил неизвестность. Он не управляет релизом.
\nДля выбранной пары есть заполненные family, direction, baseline, candidate и consumerId. Gate возвращает отдельные результаты для неизвестного отношения, разрушенной required surface, скрытого addition и несовместимого reader. Есть один положительный учебный случай и отрицательные случаи для каждой остановки. Каждый report содержит reason и next action. Положительный report прямо говорит synthetic hand-off и не выдаёт право на deploy.
Если команда не может воспроизвести эти статусы на фиксированных входах или не знает, какой reader проверяется, критерий не выполнен. Номер версии и зелёный процент не заменяют evidence. Готовность здесь означает, что вопрос о совместимости имеет направление, named участников, отдельную причину и проверяемый следующий шаг.
\nСбой на границе сервисов часто начинается с безобидного изменения: producer выпускает JSON с новым полем, а consumer продолжает читать его как прежний. Один reader игнорирует неизвестный ключ, другой отклоняет объект целиком. Если при этом поле state заменили на похожее phase, ошибка становится ещё дороже: данные формально похожи, но старый код больше не видит обязательное значение.
Номер версии не отвечает на вопрос о совместимости. Нужно знать, кто пишет, кто читает, относительно какой схемы сравнивается изменение и какие правила действуют у reader. В этой статье разберём узкий compatibility gate: он сравнивает две зафиксированные формы, проверяет направление backward и останавливается, если поле, намерение producer или способность consumer неизвестны.
В статье используются четыре роли. Producer формирует новую запись. Consumer получает её. Writer schema описывает форму, с которой записали данные, а reader schema — форму, которую ожидает читатель. Для простого JSON API эти понятия могут находиться в одном OpenAPI-документе, в коде валидатора и в договорённости команды, но логика вопроса остаётся той же.
\nНазовём старую форму baseline, новую форму candidate, а направление backward определим так: reader, рассчитанный на baseline, должен принять запись candidate. Это локальное определение политики, а не универсальный смысл слова для всех платформ. Для проверки обратного сценария — новый reader читает старые данные — нужна отдельная relation. Совместимость не является симметричным boolean.
| Отношение | Что проверяем | Типичный риск | Минимальное решение |
|---|---|---|---|
backward | Старый reader принимает новую запись | Удалено required-поле или reader отвергает новый ключ | Сохранить обязательную поверхность и проверить policy reader |
forward | Новый reader принимает старую запись | Новый код ожидает поле, которого нет в старых данных | Задать default, миграцию или период двойного чтения |
full | Оба отношения проходят | Одно направление проверили, второе подразумевали | Запустить две отдельные проверки и хранить их результаты |
| Неизвестно | Нельзя определить baseline, candidate или consumer | Зелёный статус скрывает невыбранное отношение | Остановить сравнение и запросить недостающую связь |
JSON Schema действительно проверяет экземпляр по ограничениям вроде type, required и enum. Это полезный слой: валидатор может показать, что строка не стала числом или что обязательный ключ пропал. Но сама спецификация описывает валидность документа относительно схемы, а не направление миграции, список реальных consumer и значение слова active в конкретном бизнес-процессе.
Есть и менее очевидная граница. В JSON Schema 2020-12 ключ format может быть аннотацией; обязательное assertion-поведение зависит от заявленного vocabulary и реализации. Поэтому format: date-time нельзя молча считать проверкой существования даты, часового пояса или корректности бизнес-операции. Эти свойства следует закрепить отдельным правилом приложения и проверять тем же reader, который будет использовать значение.
В бинарных форматах правила могут быть другими. Avro сопоставляет writer schema и reader schema по своим правилам schema resolution. В Protocol Buffers номер поля участвует в wire format, поэтому удалённые номера нельзя бездумно переиспользовать. Эти документы полезны как официальные модели эволюции, но их ограничения нельзя переносить на произвольный JSON endpoint без проверки конкретного сериализатора.
\nСначала зафиксируйте relation. В записи должны быть family, direction, baselineVersion, candidateVersion, producer и consumerId. Строка «совместимо с v2» недостаточна: неизвестно, с чьей точки зрения и для какого reader. Если любой идентификатор пуст, результат — остановка, а не допущение.
Затем сохраните required surface. В выбранной политике backward каждое required-поле baseline должно остаться в candidate с совместимым типом. Переименование state в phase рассматривайте как удаление и добавление, пока старый consumer не адаптирован. Совпадение смысла в обсуждении не меняет фактического имени ключа.
После этого сверяйте additions. Если candidate добавил priority, producer должен объявить его в manifest изменения. Нельзя выводить намерение из одного удачно прочитанного sample: фактический diff мог также содержать routingHint. Скрытое поле сначала нужно объяснить или убрать.
Последним проверяйте capability reader. Tolerant reader пропускает дополнительные ключи, strict reader запрещает их. То, что поле optional у producer, не меняет настройки consumer автоматически. Один прошедший сервис не доказывает совместимость остальных readers; verdict нужно хранить для каждой названной пары.
\nНиже — минимальный Node.js-скрипт. Он не обращается к registry, сети, базе или реальным сообщениям. Для воспроизводимости сохраните блок во временный файл compatibility-check.mjs и запустите node compatibility-check.mjs. Функция проверяет только требуемые поля, типы, объявленные additions и способность reader принять дополнительные ключи.
const baseline = {\n required: ['id', 'state'],\n properties: { id: 'string', state: 'string' },\n};\n\nconst candidate = {\n required: ['id', 'state'],\n properties: { id: 'string', state: 'string', priority: 'number' },\n};\n\nconst relation = {\n direction: 'backward',\n family: 'orders',\n baselineVersion: '1.0',\n candidateVersion: '1.1',\n producer: 'orders-api',\n consumerId: 'billing-worker-v1',\n};\n\nfunction check({ baseline, candidate, relation, declaredAdded, acceptsAdditional }) {\n const relationFields = ['direction', 'family', 'baselineVersion',\n 'candidateVersion', 'producer', 'consumerId'];\n if (!relationFields.every((name) => relation[name])) {\n return { status: 'STOP', reason: 'relation-is-incomplete' };\n }\n\n const missing = baseline.required.filter(\n (name) => !candidate.required.includes(name),\n );\n const changedType = baseline.required.filter(\n (name) => candidate.properties[name] !== baseline.properties[name],\n );\n if (missing.length || changedType.length) {\n return { status: 'STOP', reason: 'required-surface-changed', missing, changedType };\n }\n\n const added = Object.keys(candidate.properties)\n .filter((name) => !Object.hasOwn(baseline.properties, name));\n const undocumented = added.filter((name) => !declaredAdded.includes(name));\n if (undocumented.length) {\n return { status: 'STOP', reason: 'addition-is-not-declared', undocumented };\n }\n if (added.length && !acceptsAdditional) {\n return { status: 'STOP', reason: 'reader-rejects-additional-fields', added };\n }\n return { status: 'PASS', reason: 'synthetic-structural-check-only' };\n}\n\nconsole.log(check({\n baseline, candidate, relation,\n declaredAdded: ['priority'],\n acceptsAdditional: true,\n}));\n// { status: 'PASS', reason: 'synthetic-structural-check-only' }\nУ этого запуска четыре важных свойства. Отношение заполнено. id и state сохранились с теми же типами. priority назван в declaredAdded. Reader явно допускает дополнительные поля. Положительный результат поэтому означает только прохождение перечисленных структурных правил, а не разрешение на выпуск.
Проверка считается полезной, когда она воспроизводимо останавливает опасные варианты. Для первого запуска удалите state из candidate.required. Ожидаемый результат: reason: 'required-surface-changed'. Скрипт не объявляет phase заменой, потому что имена и семантика не угадываются.
Верните state, добавьте в candidate.properties поле routingHint: 'string', но не внесите его в declaredAdded. Ожидается addition-is-not-declared. Это проверяет разницу между фактической формой и намерением изменения.
Наконец, оставьте только priority и замените acceptsAdditional: true на false. Ожидается reader-rejects-additional-fields. Так видно, почему capability относится к named consumer, а не к номеру версии producer. Для неполной relation удалите consumerId и ожидайте relation-is-incomplete.
status, reason и nextAction. Общий итог вычисляйте только после адресных результатов.Такой gate проверяет ограниченную структурную поверхность. Он не находит скрытых consumers, не доказывает, что владелец честно описал свои требования, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку, задержки, размер payload или откат. Для этих вопросов нужны contract tests, интеграционные тесты, наблюдаемость и план перехода.
\nОн также не ловит семантическую несовместимость. Строка state=active может сохранить имя и тип, но начать означать «активна подписка» вместо «активен заказ». Аналогичный риск есть у времени, денег, идентификаторов и nullable-полей: нужно явно закрепить часовой пояс, валюту, масштаб, источник и правило округления. Формально валидный JSON не делает эти значения правильными.
Нельзя переносить этот результат между технологиями без адаптации. Для Avro нужно учитывать schema resolution, для protobuf — номера и reserved-поля, для JSON — поведение конкретного parser и настройку дополнительных ключей. Если схема, reader policy или направление неизвестны, единственно честное решение — STOP с запросом к владельцу контракта.
\nПара готова к следующему этапу, когда зафиксированы baseline, candidate, direction и named consumer; обязательная поверхность не разрушена; все additions объявлены; capability reader подтверждена; пройдены положительный и отрицательные случаи; накопленные старые данные учтены. Report должен объяснять причину и следующее действие, а не сводить разные ситуации к проценту «совместимости».
\nЕсли проверка не различает backward и forward, принимает пустой consumerId или пропускает новое значение enum только потому, что тип остался строковым, она отвечает не на тот вопрос. Номер 1.1 может помочь найти две точки сравнения, но не заменяет их содержимое и policy чтения.
type, enum, required и format; она описывает валидность экземпляра, но не задаёт deployment-политику совместимости.Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки складывается из простоя, ручного восстановления данных и времени на поиск настоящего контракта.
\nПроблема начинается не с синтаксиса схемы. Она начинается с неявного обещания: «новое поле необязательное, значит всё совместимо». Это утверждение неполно. Нужно назвать исходную форму, новую форму, направление чтения, producer и конкретного consumer. Только после этого можно решить, additive change это или несовместимое изменение.
\nНомер v1.1 связывает две точки во времени, но не отвечает на главный вопрос. Может ли reader, рассчитанный на baseline, принять candidate? Ответ зависит от обязательных полей, типов, дополнительных ключей и правил самого reader. Один consumer игнорирует незнакомые поля. Другой отвергает их. Одинаковый JSON для них имеет разный результат.
Контракт данных — это не только схема. Это схема вместе с владельцем записи, ожидаемым reader, направлением совместимости и правилом изменения. Для практической проверки достаточно начать с одной пары: producer создаёт candidate, named consumer читает его как продолжение baseline. Остальные потребители требуют отдельных проверок.
\nСначала зафиксируйте baseline — форму, которую уже читает потребитель. Затем опишите candidate — форму после изменения. В manifest перечислите добавленные, удалённые и изменённые по типу поля. Направление backward в этом материале означает: старый reader получает новую запись. Это не означает, что новый reader обязательно прочитает старую запись.
Проверка должна идти в том же порядке. Сначала она убеждается, что обязательная поверхность baseline не исчезла. Затем проверяет, что каждое новое поле названо в manifest. После этого она спрашивает capability конкретного consumer: принимает ли он дополнительные ключи. Если входные данные не называют направление или consumer, проверка останавливается. Пустое сравнение нельзя считать зелёным результатом.
\nНиже — учебная функция. Она не подключается к registry, не читает реальные схемы и не доказывает совместимость сервиса. Её задача — сделать порядок решений видимым.
\nconst baseline = {\n id: { type: 'string', required: true },\n state: { type: 'string', required: true },\n note: { type: 'string', required: false },\n};\n\nconst candidate = {\n ...baseline,\n priority: { type: 'number', required: false },\n};\n\nconst change = {\n direction: 'backward',\n producer: 'work-item-api',\n consumer: 'billing-worker-v1',\n added: ['priority'],\n removed: [],\n changed: [],\n};\n\nfunction review({ baseline, candidate, change, acceptsAdditional }) {\n const requiredLost = Object.entries(baseline)\n .filter(([name, field]) => field.required && !candidate[name])\n .map(([name]) => name);\n\n if (!change.direction || !change.consumer) {\n return { status: 'stop-implicit-comparison' };\n }\n if (requiredLost.length > 0) {\n return { status: 'stop-backward-incompatible-schema', requiredLost };\n }\n\n const actualAdded = Object.keys(candidate)\n .filter((name) => !baseline[name]);\n const undocumented = actualAdded\n .filter((name) => !change.added.includes(name));\n\n if (undocumented.length > 0) {\n return { status: 'stop-undocumented-schema-field', undocumented };\n }\n if (actualAdded.length > 0 && !acceptsAdditional) {\n return { status: 'stop-incompatible-consumer' };\n }\n return { status: 'synthetic-compatibility-review-hand-off' };\n}\n\nconsole.log(review({\n baseline,\n candidate,\n change,\n acceptsAdditional: true,\n}));\n// { status: 'synthetic-compatibility-review-hand-off' }\nВ примере priority не удаляет id и state, поэтому структурная проверка проходит. Поле также записано в manifest. Tolerant consumer принимает дополнительные ключи, и функция возвращает ограниченный положительный статус. Этот статус означает только одно: фиксированная учебная пара прошла перечисленные правила. Он не означает deploy, миграцию базы или успешную обработку реального сообщения.
Теперь измените candidate: замените state на phase. Функция вернёт stop-backward-incompatible-schema. Имена похожи, но старый consumer всё ещё ищет обязательное поле state. Не пытайтесь исправить этот результат добавлением номера версии. Здесь нужен отдельный план миграции или сохранение старого поля на период перехода.
Третий случай — strict consumer. Оставьте additive candidate, но передайте acceptsAdditional: false. Результат станет stop-incompatible-consumer. Поле может быть корректным для одного reader и запрещённым для другого. Поэтому слово «optional» должно описывать не только schema declaration, но и поведение потребителя.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый reader падает на новом ключе | Consumer запрещает дополнительные поля | Проверить его parser policy и тест на candidate | Оставить поле вне старой формы, изменить reader или ввести отдельный контракт |
| После rename пропало значение | Удалено обязательное поле baseline | Сравнить required-поля baseline и candidate | Вернуть поле на переходный период или спроектировать миграцию |
| В review нет единого вердикта | Не задано направление или named consumer | Проверить manifest: direction, producer, consumer | Остановить изменение и сначала определить пару |
| Новый ключ появился без обсуждения | Diff шире заявленного manifest | Сравнить фактические ключи candidate со списком added | Назвать поле и его смысл либо удалить его из candidate |
| Тип остался строкой, но смысл изменился | Семантический breaking change не виден в structural diff | Сверить единицы, timezone, enum и документацию consumer | Дать новое имя или подготовить явную миграцию значения |
JSON Schema описывает структуру экземпляра: свойства, типы и дополнительные свойства. Это полезная граница, но schema validation не знает, какой сервис владеет полем, кто читает объект и можно ли менять смысл значения без новой версии. Две схемы могут быть валидными по отдельности и всё равно не образовывать безопасную пару.
\nТа же граница видна в JSON Type Definition. В RFC 8927 required properties и optionalProperties разделены явно. Режим дополнительных свойств тоже задаётся отдельно. Это хороший словарь для разговора о форме объекта. Но RFC не выбирает migration policy вашей команды и не сообщает, выдержит ли конкретный consumer изменение.
\nВ форматах с writer и reader schemas направление становится ещё заметнее. Apache Avro описывает schema resolution между схемой записи и схемой чтения. Для JSON-сервисов конкретные правила будут другими, но принцип переносим: нельзя обсуждать compatibility без указания стороны, которая пишет, и стороны, которая читает.
\nadded, removed и changed. Любое поле вне списка считается неоформленным.Учебный gate не видит неизвестных внешних клиентов. Он не проверяет кеши, очереди, сохранённые payload, SDK, базы и семантику бизнес-значений автоматически. Он также не определяет срок поддержки старой формы. Для этих вопросов нужны реальные инвентари потребителей, contract tests и план удаления.
\nДобавление необязательного поля часто безопаснее удаления обязательного, но это не универсальное правило. Строгий parser, подпись payload или downstream-система с закрытым набором ключей превращают additive change в остановку. Не называйте поле безопасным только потому, что оно не помечено как required.
\nОтдельный риск — изменение смысла без изменения типа. Строка amount может перейти с рублей на копейки. Timestamp может сменить timezone. Enum может получить другой смысл при том же наборе строк. Structural diff этого не докажет. Нужны доменное описание, тесты значений и проверка consumer.
Изменение готово к следующему review, если другая инженерная команда без устного пояснения может открыть одну карточку и ответить на пять вопросов: какая форма была baseline, какая стала candidate, что изменилось по manifest, кто читает результат и в каком направлении выполнялась проверка. Для additive change дополнительно нужен положительный тест tolerant consumer и отрицательный тест strict consumer. Для breaking change нужен отдельный migration или versioning decision.
\nЕсли хотя бы один ответ неизвестен, итогом должен быть stop, а не зелёный комментарий. Такая остановка дешевле аварийного отката: она превращает неясное обещание в конкретный вопрос, который можно проверить.
\nproperties и additionalProperties.properties, optionalProperties и режима дополнительных свойств.Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки — не только ошибка парсинга: в очереди остаются сообщения, повторная доставка увеличивает нагрузку, а поиск владельца контракта съедает время.
\nЧтобы не обсуждать совместимость на уровне догадок, нужно проверить одну конкретную пару: какая форма была исходной, какая стала новой, кто пишет данные и какой reader их читает. В этой статье разберём маршрут для JSON-подобного объекта. Он не заменяет интеграционные тесты и не объявляет изменение безопасным для неизвестных клиентов.
\nВерсия v1.1 сама по себе ничего не гарантирует. Старый reader может игнорировать незнакомые поля, а может использовать строгую проверку и отклонять их. Один и тот же candidate поэтому совместим с одним consumer и несовместим с другим.
Дальше под backward compatibility будем понимать одно проверяемое направление: старый reader получает запись, созданную новой схемой. Это определение относится только к выбранной паре. Оно не доказывает обратное направление, совместимость SDK, сохранённых сообщений или других потребителей.
\nBaseline — форма, которую уже принимает named consumer. Candidate — полная форма после изменения. Manifest — явный список добавленных, удалённых и изменённых полей. Наконец, поведение consumer — правила его парсера: допускает ли он дополнительные ключи и как обрабатывает неизвестные значения.
Нельзя подменять baseline коротким описанием вроде «добавили priority». Если одновременно изменились тип amount, enum state или единицы измерения, короткая заметка скроет breaking change. Полный candidate и diff должны быть доступны тому, кто будет читать запись после выпуска.
Ниже — самостоятельный пример на Node.js без сторонних пакетов. Сохраните код в файл contract-gate.mjs и запустите командой node contract-gate.mjs. Внутренняя модель намеренно мала: она проверяет имена, типы, обязательность, manifest и способность consumer принимать дополнительные ключи.
const baseline = {\n id: { type: 'string', required: true },\n state: { type: 'string', required: true },\n note: { type: 'string', required: false },\n};\n\nconst additive = {\n ...baseline,\n priority: { type: 'integer', required: false },\n};\n\nconst manifest = {\n direction: 'backward',\n producer: 'work-item-api',\n consumer: 'billing-worker-v1',\n added: ['priority'],\n removed: [],\n changed: [],\n};\n\nfunction sameList(left, right) {\n return [...left].sort().join('|') === [...right].sort().join('|');\n}\n\nfunction review({ baseline, candidate, manifest, consumer }) {\n if (manifest.direction !== 'backward' || !manifest.producer || !manifest.consumer) {\n return { status: 'stop-missing-contract-context' };\n }\n\n const baselineNames = Object.keys(baseline);\n const candidateNames = Object.keys(candidate);\n const added = candidateNames.filter((name) => !baseline[name]);\n const removed = baselineNames.filter((name) => !candidate[name]);\n const changed = baselineNames.filter((name) => {\n if (!candidate[name]) return false;\n return baseline[name].type !== candidate[name].type\n || baseline[name].required !== candidate[name].required;\n });\n const requiredLost = removed.filter((name) => baseline[name].required);\n\n if (requiredLost.length > 0) {\n return { status: 'stop-required-field-removed', fields: requiredLost };\n }\n if (changed.length > 0) {\n return { status: 'stop-field-definition-changed', fields: changed };\n }\n if (!sameList(added, manifest.added)\n || !sameList(removed, manifest.removed)\n || !sameList(changed, manifest.changed)) {\n return { status: 'stop-manifest-mismatch', actual: { added, removed, changed } };\n }\n if (added.length > 0 && !consumer.acceptsAdditional) {\n return { status: 'stop-consumer-rejects-additional-fields' };\n }\n return { status: 'compatible-for-named-reader', actual: { added, removed, changed } };\n}\n\nconsole.log(review({\n baseline,\n candidate: additive,\n manifest,\n consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));\nconsole.log(review({\n baseline,\n candidate: additive,\n manifest,\n consumer: { name: 'strict-billing-worker-v1', acceptsAdditional: false },\n}));\nconsole.log(review({\n baseline,\n candidate: { id: baseline.id, note: baseline.note, phase: { type: 'string', required: true } },\n manifest: { ...manifest, added: ['phase'], removed: ['state'] },\n consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));\nУ первого вызова результат compatible-for-named-reader: обязательные поля сохранились, priority попал в manifest, а consumer принимает дополнительные ключи. У второго тот же candidate отклоняется, потому что strict consumer запрещает новый ключ. Третий вызов останавливается на удалённом обязательном state. Это три разных решения для почти одинакового diff.
Пример можно проверить на чистой машине с Node.js 18 или новее: node --version покажет установленную версию, а node contract-gate.mjs выведет три объекта в консоль. Скрипт не обращается к сети, registry или вашему сервису, поэтому его положительный результат относится только к указанным данным.
| Результат | Что установлено | Следующий шаг | Граница вывода |
|---|---|---|---|
compatible-for-named-reader | Схемы и manifest совпали, reader допускает addition | Запустить contract/integration tests и проверить rollout | Неизвестные consumer и семантика значений не проверены |
stop-required-field-removed | В candidate исчезло обязательное поле baseline | Сохранить поле на период миграции или версионировать контракт | Переименование не исправляется номером версии |
stop-consumer-rejects-additional-fields | Reader не принимает добавленные ключи | Изменить reader, изолировать новый контракт или дождаться миграции | Свойство optional в схеме не меняет parser policy |
stop-manifest-mismatch | Фактический diff шире заявленного | Исправить candidate или manifest и повторить проверку | Gate не выясняет смысл незаявленного поля |
stop-field-definition-changed | Изменился тип или признак обязательности | Сделать отдельный migration plan и тесты значений | Даже тот же JSON-тип может скрывать смену единиц |
JSON Schema отвечает на вопрос о валидности экземпляра относительно набора ограничений. В спецификации 2020-12 есть структурные ключевые слова type, required и additionalProperties. Они помогают описать форму объекта и правила дополнительных ключей, но не знают, какой сервис владеет полем и какой reader будет обрабатывать запись.
Это видно и по JSON Type Definition (JTD). RFC 8927 разделяет properties и optionalProperties, а режим дополнительных свойств задаётся отдельно. Такой словарь дисциплинирует схему, но не выбирает срок поддержки старой формы и не проверяет ваш parser.
В Apache Avro терминология writer schema и reader schema встроена в механизм разрешения схем. Спецификация описывает, как reader сопоставляет поля, что происходит с отсутствующим полем и когда возникает ошибка. Для обычного JSON API правила Avro автоматически не применяются, но сам способ постановки вопроса полезен: всегда указывайте обе стороны чтения и записи.
\nДобавление необязательного поля часто проще, чем удаление обязательного. Но это не универсальное правило. Строгий parser, подписанный payload, whitelist ключей или downstream-система с фиксированным форматом могут отклонить additive change.
\nОпаснее всего изменение смысла без изменения типа. amount мог быть суммой в рублях, а стал суммой в копейках. Строковый timestamp мог перейти из локального времени в UTC. Enum state=ready мог означать «готов к отправке», а после изменения — «готов к оплате». Structural diff такие изменения не докажет. В manifest нужны единицы, timezone, допустимые значения и ссылка на владельца доменного смысла.
Если контракт использует JSON Schema, структурную валидацию можно выполнять отдельно, например через выбранный валидатор в CI. Его настройки должны быть зафиксированы: разные библиотеки могут по-разному трактовать форматные аннотации. Проверка format: date-time не заменяет проверку бизнес-часового пояса и срока действия события.
added, removed и changed. Для изменения смысла добавьте текстовое правило и владельца.Описанный gate проверяет только одну форму объекта и одного named consumer. Он не обнаружит неизвестных внешних клиентов, старые записи в очереди, кэшированные ответы, сгенерированные SDK, схемы в базе или трансформации промежуточного сервиса. Для этого нужен инвентарь потребителей и тесты на реальные границы системы.
\nПоложительный результат не является самостоятельным разрешением на deploy. Нужны проверка авторизации, размер payload, подпись, порядок событий, повторная доставка, таймауты и наблюдаемость. Эти свойства не следуют из JSON Schema и не выводятся из номера версии.
\nНаконец, не называйте изменение backwards-compatible, если вы проверили только новый reader на старой записи. Это другое направление. Если продукт требует оба направления, проведите две отдельные проверки и запишите их результаты в manifest.
\nПеред review другая команда должна без устного пояснения ответить на пять вопросов: какая форма является baseline, что изменилось в candidate, совпадает ли фактический diff с manifest, кто читает новую запись и какое правило дополнительных ключей действует у reader. Если хотя бы один ответ неизвестен, результат проверки — остановка и уточнение контракта.
\nТакой порядок не делает изменение автоматически безопасным. Он делает риск видимым: структурную ошибку можно поймать до выпуска, несовместимый parser — проверить на named consumer, а смену бизнес-смысла — вынести в отдельное решение. Именно эта граница превращает сообщение «мы добавили одно поле» в проверяемое инженерное изменение.
\ntype, required, enum и связанные ключевые слова. Статья использует её только для описания формы JSON, а не как универсальный compatibility policy.properties, optionalProperties и additionalProperties в JTD. Это отдельный формат, его правила нельзя молча переносить на JSON Schema.Внешний API начинает отвечать за 3 секунды вместо 200 миллисекунд. Ваш сервис не падает сразу: он повторяет запрос, пробует другую реплику и запускает fallback. Через минуту очередь растёт, рабочие потоки заняты ожиданием, а внутренние запросы получают таймауты. Ошибка внешней зависимости превращается в отказ собственного приложения.
\nЦена каскада состоит не только из лишних запросов. Команда теряет связь между исходным запросом и его повторами. Логи показывают несколько похожих ошибок. Метрики смешивают первичную работу и повторную. Пользователь получает задержку вместо ответа, а перегруженный сервис продолжает принимать новую работу. Если система не знает, где остановиться, каждая защитная мера увеличивает масштаб отказа.
\nГлавный тезис прост: retry, fallback и репликация должны подчиняться одному явному бюджету. Его нужно применять до расширения маршрута. У повторной попытки должен быть один владелец, у fan-out — целочисленный предел, у fallback — имя и конечный результат. Когда бюджет исчерпан, система должна выполнить terminal action. Она не должна незаметно создавать ещё один уровень попыток.
\nПредставим два логических запроса. Для каждого сервис делает первичный вызов и разрешает одну дополнительную попытку. Если каждый слой владеет retry, число физических вызовов растёт быстрее, чем ожидает владелец верхнего слоя. Edge может повторить вызов адаптера, а адаптер — повторить тот же вызов внешнего API. В результате одна ошибка получает два независимых счётчика.
\nРепликация добавляет ширину. Правило «попробовать все доступные реплики» не ограничивает работу. При задержке или частичном отказе оно запускает несколько запросов до того, как первый результат станет понятен. Fallback добавляет ещё одну ветку. Если fallback сам умеет повторять вызов, его нельзя считать запасным результатом: это второй retry-контур.
\nОграниченный маршрут устроен иначе. Edge владеет двумя попытками. Каждая попытка выбирает одну реплику. Fallback получает управление только после именованного исхода, например `optional-result-unavailable`, и не создаёт retry. После второй попытки система возвращает заранее определённый деградированный результат или явно отказывает. Такая схема не обещает восстановить полный ответ. Она ограничивает стоимость отказа и оставляет понятную трассу.
\nСначала назовите отказ. `temporary-timeout` отличается от ошибки контракта или отказа авторизации. Повтор допустим только для исходов, для которых владелец операции подтвердил безопасность и полезность повторения. HTTP 503 сообщает о временной неспособности обработать запрос, но сам по себе не доказывает, что конкретную операцию можно безопасно повторить. То же относится к заголовку `Retry-After`: он передаёт подсказку о времени, но не назначает владельца retry.
\nЗатем назовите владельца. В системе может быть несколько компонентов, которые технически способны повторять запрос. Это не значит, что каждый должен это делать. Зафиксируйте один слой, его `maxAttempts`, список повторяемых исходов и момент, когда он прекращает работу. Если два слоя имеют `enabled: true`, проверьте их совместно: верхний повтор может повторять уже повторённую работу.
\nПосле этого назовите предел маршрута. `maxFanout: 1` означает, что одна попытка выбирает одну реплику. Список из трёх реплик не даёт права обращаться ко всем трём одновременно. Если предел не задан, его нельзя вывести из количества имён в списке. Неявный предел не является защитой.
\nПоследним назовите terminal action. Это может быть деградированный ответ, ошибка с понятным кодом или сохранение результата частичной операции. Он зависит от предметной области. Для операции с финансовым побочным эффектом нельзя бездумно возвращать «неполный успех». Важен сам принцип: после terminal action нет скрытого retry и нового fallback.
\nНиже приведён самодостаточный JavaScript-пример. Он работает только с переданным объектом и не вызывает сеть. Числа показывают форму контракта, а не рекомендуемые значения для production. Перед переносом в сервис их нужно заменить правилами конкретной операции и подтвердить безопасность повтора.
\nconst route = {\n retry: {\n owner: 'edge',\n maxAttempts: 2,\n retryable: ['temporary-timeout'],\n },\n replicas: {\n names: ['primary-a', 'primary-b', 'primary-c'],\n maxFanout: 1,\n },\n fallback: {\n name: 'named-summary',\n trigger: 'optional-result-unavailable',\n addsRetry: false,\n output: 'degraded-summary',\n },\n limit: {\n point: 'before-route-expansion',\n onExhaustion: 'return-degraded-result',\n },\n};\n\nfunction nextStep(outcome, attempt) {\n if (route.retry.retryable.includes(outcome) &&\n attempt < route.retry.maxAttempts) {\n return { action: 'retry', owner: route.retry.owner };\n }\n\n if (outcome === route.fallback.trigger) {\n return { action: 'fallback', name: route.fallback.name };\n }\n\n return { action: route.limit.onExhaustion };\n}\n\nconsole.log(nextStep('temporary-timeout', 2));\n// { action: 'return-degraded-result' }\nФункция не измеряет задержку и не проверяет реальную доступность реплики. Она показывает важную границу: после второй попытки результат не переходит к новому retry. Для реальной реализации нужно дополнительно передать идентификатор логического запроса, сохранить причину остановки и связать события одной трассой. Не следует считать этот вывод доказательством устойчивости сервиса.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Число внешних вызовов выше числа пользовательских запросов | Повторяют несколько слоёв | Сопоставить owner и attempt в trace | Оставить одного владельца retry |
| При одном timeout растёт нагрузка на все реплики | Не задан fan-out на попытку | Посчитать выбранные реплики для одного logical request | Задать целочисленный maxFanout и проверить его до расширения маршрута |
| Fallback запускается после каждой ошибки | Не различены retryable и terminal outcomes | Проверить trigger и список повторяемых исходов | Назвать trigger и запретить fallback создавать retry |
| В trace появляются действия без владельца | Логика скрыта в библиотеке или промежуточном адаптере | Найти первый span, который создаёт новый вызов | Добавить owner и событие расхода бюджета |
| После исчерпания попыток запрос продолжает жить | Нет terminal action или отмены in-flight работы | Проверить последнюю запись trace и состояние очереди | Вернуть явный результат и прекратить дальнейшее расширение |
| Два сценария дают разные цифры, но считаются сопоставимыми | Различаются logical load или failure injection | Сверить baseline key, число запросов и исход отказа | Разделить сценарии и не усреднять результаты |
Ограничение считается рабочим только тогда, когда оно останавливает неправильные конфигурации. Включите retry у edge и adapter одновременно. Проверка должна вернуть статус о нескольких владельцах, а не выбрать один молча. Удалите имя fallback. Результатом должна стать остановка с причиной, а не переход к безымянному ответу. Замените `maxFanout: 1` на `null`. Проверка обязана остановить сценарий до выбора реплик.
\nУдалите limit point. Не подставляйте его из `maxAttempts`: это разные свойства. `maxAttempts` ограничивает конкретный счётчик попыток. Limit point отвечает за порядок: бюджет должен быть проверен до того, как начнётся новое расширение маршрута. Если сценарий использует семь логических запросов вместо двух или другой failure injection, его нельзя сравнивать с базовым примером. Сначала выровняйте входы, затем сравнивайте trace.
\nОтдельно проверьте постоянную ошибку контракта. Её нельзя автоматически считать временным timeout. Повтор не исправит несовместимую схему и может увеличить нагрузку. Для неё нужен другой путь: быстрый отказ, карантин сообщения или согласованная миграция. Универсальный retry по любому статусу — частая причина каскада.
\nПолезная трасса отвечает на четыре вопроса: какой логический запрос начал работу, какой исход получил каждый вызов, кто решил повторить и где система остановилась. Для ограниченного примера последовательность может выглядеть так: `logical-01 → primary-a → temporary-timeout`; `edge → retry budget 2 → 1`; `logical-01 → primary-b → complete`. Для второго запроса: `primary-c → optional-result-unavailable`; затем `named-summary → degraded-summary`.
\nТакой список не является метрикой производительности. Он нужен, чтобы восстановить решение. Если в нём есть вызов, которого нет в retry plan, fallback или replication policy, модель неполна. Если последний span заканчивается ошибкой, но очередь продолжает принимать работу того же класса, причина может находиться выше: лимит стоит слишком поздно, а не просто имеет неправильное число.
\nСвязывайте повтор с логическим идентификатором, но не записывайте в trace секреты и пользовательские данные. Внешний trace id помогает найти событие, но не является доказательством личности или разрешением на доступ. Наблюдаемость должна объяснять расход бюджета и границу остановки.
\nОписанный механизм не выбирает оптимальный timeout. Он не знает пропускную способность сервиса, размер очереди, стоимость подключения, deadline клиента и долю ошибок зависимости. Маленький `maxAttempts` может быть правильным для одного чтения и опасным для другой операции. Значение нужно выводить из контракта и capacity модели, а не копировать из примера.
\nМеханизм не подтверждает идемпотентность. Повтор чтения обычно отличается от повтора платежа, создания заказа или отправки письма. Если запрос мог изменить состояние, сначала определите ключ идемпотентности и границу подтверждения. При отсутствии такого контракта безопаснее остановиться, чем включить retry ради доступности.
\nМеханизм не заменяет отмену in-flight работы. Если верхний слой уже вернул terminal action, нижний вызов может продолжать занимать соединение. Нужны deadline, cancellation и проверка поведения клиента. Также отдельной проверки требуют circuit breaker, rate limit, очередь и политика деградации. Единый бюджет не устраняет эти компоненты, но не даёт им бесконтрольно складывать новые попытки.
\nПримеры в статье фиксируют значения только для объяснения механизма. Они не содержат production-трафик, реальные latency, измерения восстановления или обещания SLA. Нельзя писать в отчёте «сервис выдерживает отказ» только потому, что объект прошёл проверку. Доказательство требует воспроизводимого сценария в целевой системе и согласованного критерия результата.
\nДля одного класса операции есть заполненные failure injection, retry owner, `maxAttempts`, retryable outcomes, `maxFanout`, fallback и terminal action. Trace связывает каждый физический вызов с одним logical request. Неправильные конфигурации останавливаются с отдельными причинами: несколько retry owners, безымянный fallback, неограниченный fan-out, отсутствие limit point и несопоставимый сценарий.
\nПроверка считается завершённой, если команда может воспроизвести положительный и отрицательный пути на фиксированных входах, а затем повторить тот же сценарий в тестовой среде с разрешённым наблюдением. На последнем шаге видно, где прекратилось создание новой работы, сколько попыток получил запрос и какой terminal action вернулся. Если любой из этих фактов неизвестен, готовность не доказана.
\nВнешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.
\nНиже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.
\nСимптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.
\nПолезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, read-01 получает temporary-timeout от primary-a, один раз повторяется на primary-b и получает ok. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется primary-c, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.
Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает Retry-After как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.
Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.
\nДля проектирования разделите бюджет на четыре поля:
\nБюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.
\nRetry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.
\nОфициальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации maxAttempts задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.
У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом retryEnabled: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.
Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, PUT и DELETE, но допускает повтор POST только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.
Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.
\nУкажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте logical_id, attempt, owner, outcome, выбранное направление и причину остановки.
Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.
\nnode <<'NODE'\nconst policy = {\n maxAttempts: 2,\n maxFanout: 1,\n retryable: new Set(['temporary-timeout']),\n fallbackTrigger: 'optional-result-unavailable',\n onExhaustion: 'fail-fast',\n};\n\nfunction decide(outcome, attempt) {\n if (outcome === 'ok') return { action: 'complete' };\n if (outcome === policy.fallbackTrigger) {\n return { action: 'fallback', name: 'cached-summary' };\n }\n if (policy.retryable.has(outcome) &&\n attempt < policy.maxAttempts) {\n return { action: 'retry', owner: 'edge' };\n }\n return { action: policy.onExhaustion };\n}\n\nconst requests = [\n { logicalId: 'read-01', outcomes: ['temporary-timeout', 'ok'] },\n { logicalId: 'read-02', outcomes: ['optional-result-unavailable'] },\n { logicalId: 'read-03', outcomes: ['temporary-timeout', 'temporary-timeout'] },\n];\n\nfor (const request of requests) {\n for (let i = 0; i < request.outcomes.length; i += 1) {\n const attempt = i + 1;\n const outcome = request.outcomes[i];\n const decision = decide(outcome, attempt);\n console.log(JSON.stringify({\n logicalId: request.logicalId,\n attempt,\n outcome,\n ...decision,\n }));\n if (decision.action !== 'retry') break;\n }\n}\nNODE\nОжидаемый вывод — две попытки для read-01, одна ветка fallback для read-02 и две попытки с fail-fast для read-03. В этом примере maxFanout присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.
Trace — путь запроса через приложение. Для этой задачи он должен отвечать на пять вопросов: какой логический запрос начался, сколько было попыток, кто разрешил каждую, какой исход получен и где создан terminal action. OpenTelemetry разделяет traces, metrics и logs; не стоит подменять отсутствующую связь между попытками общей метрикой latency.
\nМинимальная полезная последовательность выглядит так: read-01 / attempt=1 / edge / primary-a / temporary-timeout; затем read-01 / attempt=2 / edge / primary-b / ok. Для read-03 последняя запись должна содержать attempt=2 и fail-fast. Если после неё виден вызов fallback или третьей реплики, ограничение стоит слишком поздно либо другой слой повторяет работу.
Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.
\n| Наблюдение | Проверяемая гипотеза | Evidence | Действие |
|---|---|---|---|
| Физических вызовов больше, чем пользовательских операций | Retry включён на нескольких слоях | Сверить owner и attempt в trace | Оставить одного владельца или ввести общую квоту |
| Один timeout обращается ко всем репликам | Fan-out не ограничен на попытку | Посчитать направления для одного logical_id | Задать целочисленный maxFanout и проверить его до выбора |
| Fallback вызывает ту же зависимость повторно | Запасная ветка содержит скрытый retry | Найти дочерние spans после fallback | Запретить повтор или включить вызовы в общий бюджет |
| Последний span закрыт, но очередь продолжает расти | Нет отмены in-flight работы или load shedding | Сопоставить deadline, cancellation и queue depth | Остановить позднюю работу и отклонять нагрузку раньше |
| Нельзя объяснить, почему повтор разрешён | Outcome классифицируется по общему 5xx | Сверить исход, метод и семантику операции | Разделить retryable и terminal outcomes |
| После исправления цифра лучше, но сценарий изменился | Сравниваются разные входы | Проверить failure injection и baseline key | Повторить оба запуска на одинаковой нагрузке |
maxAttempts, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.logical_id, attempt, owner, outcome, направление и причину остановки.Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.
\nРавным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. Retry-After следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.
Fallback не всегда безопаснее ошибки. Урезанный результат подходит для необязательного блока, но может скрыть устаревшие или неполные данные. Для критичной команды лучше вернуть явный отказ и сохранить её для повторной обработки с идемпотентным ключом. Политику деградации должен принять владелец продукта и операции, а не библиотека retry.
\nНаконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.
\nДля каждого класса операций есть заполненные owner, retryable outcomes, maxAttempts, deadline, backoff, maxFanout, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.
Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.
\n503 Service Unavailable и Retry-After. Документ описывает семантику протокола, но не решает за приложение вопрос о безопасном повторе.maxAttempts, backoff, jitter, transparent retry, throttling и метриках. Настройки специфичны для gRPC и требуют проверки версии клиента.Внешний сервис начинает отвечать медленно. На входе растёт очередь. Gateway повторяет запрос по timeout, адаптер повторяет его ещё раз, а выбор реплики отправляет работу на несколько узлов. Каждый механизм выглядит разумно отдельно. Вместе они увеличивают поток к уже перегруженной зависимости. Пользователь получает задержку или ошибку. Оператор видит несколько причин и не знает, где остановить цепочку. Цена ошибки — не один лишний запрос. Это занятые соединения, память под незавершённые операции и потеря мощности именно в момент отказа.
\nТезис статьи простой: устойчивость начинается с границы дополнительной работы. Сначала нужно определить логический запрос, одного владельца retry, максимальное число попыток, ширину выбора реплики и результат после исчерпания бюджета. Только после этого имеют смысл timeout, backoff и fallback. Если каждый слой может запустить следующую попытку, система не имеет одного механизма восстановления. Она имеет усилитель нагрузки.
\nНиже используется учебная fixed-модель. В ней два логических запроса, один именованный временный timeout, две попытки на запрос, fan-out равен одному и fallback имеет одну условную единицу работы. Модель не открывает сеть, не измеряет latency, не знает реальных реплик и не подтверждает production-устойчивость. Она проверяет только структуру решения и умеет остановиться, когда структура нарушена.
\nСчитать нужно не только HTTP-запросы. Единица анализа — логический запрос пользователя или вызывающего сервиса. Он может породить несколько исходящих действий. Для каждого действия запишите причину запуска и право запускать следующее действие. Это сразу разделяет последовательный retry и fan-out.
\nRetry запускает новый вызов после определённого исхода. Fan-out создаёт несколько направлений для одной попытки. Репликация задаёт множество доступных имён, но не обязана выбирать их все. Fallback меняет контракт результата. Он может вернуть неполный ответ, но не должен незаметно создавать собственную политику повторов. Limit point запрещает следующий переход. Если он срабатывает после fan-out, он уже не ограничивает первую волну работы.
\n| Величина | Учебное значение | Что проверяет | Ошибка при смешении |
|---|---|---|---|
| logical requests | 2 | единицу сравнения | сравнение разных объёмов работы |
| retry owner | edge | кто имеет право повторить вызов | два слоя запускают вложенные повторы |
| max attempts | 2 | конечный предел попыток | retry превращается в цикл |
| max fan-out | 1 | одно имя реплики за попытку | один запрос размножается по пулу |
| fallback work | 1 | стоимость деградированного результата | запасной путь считают бесплатным |
| limit point | до route expansion | момент запрета следующего действия | счётчик фиксирует проблему постфактум |
В bounded-cascade-v1 есть три возможных имени реплики, но на одну попытку выбирается только одно. Для двух логических запросов и максимум двух попыток верхняя граница учебной работы равна четырём route attempts. Это не QPS, не прогноз CPU и не оценка времени ответа. Она нужна, чтобы проверить, что новая защита не добавила скрытую ветвь.
\nПоложительный сценарий начинается с logical-01. Он обращается к primary-a и получает temporary-timeout. Единственный владелец retry, edge, списывает одну попытку. Следующий вызов идёт к primary-b. Второй логический запрос получает fixed-optional-result-unavailable; вместо нового поиска он возвращает named-summary с результатом fixed-degraded-summary. У fallback нет своего retry. После нулевого бюджета limit point возвращает фиксированный деградированный результат и не расширяет маршрут.
\nconst candidate = createFixedResilienceScenario('retry-amplification-v1');\nconst result = assessFixedResilienceScenario(candidate);\nconsole.log({ status: result.status, reason: result.reasons[0], handoff: result.handoff });\n// stop-retry-amplification\n// retry-must-have-one-owner-and-a-fixed-two-attempt-bound\nЭтот фрагмент показывает отрицательный путь. В retry-amplification-v1 включены два владельца retry: edge и adapter. Проверка не выбирает «лучший» слой и не моделирует задержку. Она отказывает сразу. Причина сильнее локальной настройки: у логического запроса должно быть одно право создавать следующую попытку и конечный предел. Такой отказ полезен в review. Следующее действие однозначно: убрать дублирование или уточнить границу ответственности. Нельзя компенсировать конфликт ещё одним timeout.
\nТот же fail-closed подход нужен для неизвестного сценария, неименованного fallback и неограниченного fan-out. Если вход нельзя сопоставить с fixed record, проверка не должна подставлять значения по умолчанию. Неизвестное поведение — это повод остановиться, а не разрешить самый широкий маршрут.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После timeout растёт число исходящих вызовов | retry есть на gateway и в адаптере | найти owner в каждой политике | оставить одного owner, остальные слои сделать pass-through |
| Одна операция видна на нескольких репликах | fan-out не ограничен на попытку | посчитать выбранные имена в trace | задать числовой maxFanout и проверять его до запуска |
| Fallback увеличивает задержку | запасной путь сам делает поиск или retry | развернуть его переходы и work units | назвать стоимость, убрать вложенный retry или выключить fallback |
| Лимит есть в конфигурации, но каскад уже расширился | limit point стоит после route expansion | сравнить порядок trace transitions | перенести ограничение перед созданием следующего маршрута |
| Результаты тестов нельзя сравнить | изменились нагрузка или failure injection | сверить logical load и ключ сценария | вернуться к тому же baseline или объявить сравнение недействительным |
Timeout отвечает на вопрос «сколько ждать этот вызов». Он не отвечает на вопрос «кто имеет право создать следующий». Короткий timeout может даже повысить нагрузку, если каждый слой интерпретирует его как разрешение на повтор. Статус 503 сообщает, что сервис временно не готов обработать запрос, но не выбирает retry owner и не доказывает идемпотентность действия. Retry-After задаёт подсказку для времени ожидания, а не общий бюджет каскада.
\nBackoff тоже не является лимитом. Он раздвигает попытки во времени, но оставляет их количество и владельца. Если несколько слоёв применяют независимый backoff, суммарное число переходов остаётся неясным. Поэтому сначала фиксируйте право и число попыток. Затем выбирайте расписание. Для операций с побочными эффектами отдельно проверяйте идемпотентность и компенсацию. В этой учебной модели таких эффектов нет.
\nМодель не знает о реальной ёмкости, очередях, дедлайнах, cancellation, сетевых сбоях, распределённом состоянии, правах доступа или пользовательском ущербе. Числа два и один выбраны для учебной проверки. Их нельзя переносить в конфигурацию сервиса. Положительный статус означает, что fixed-карточка удовлетворяет формальным ограничениям. Он не означает availability, recovery time, безопасный rollout или допустимую бизнес-деградацию.
\nОтдельно ограничен сам способ сравнения. Нельзя сопоставлять сценарий с двумя логическими запросами со сценарием с другой нагрузкой и делать вывод о причине. Нельзя менять тип failure injection и сохранять прежний baseline. Нельзя считать trace доказательством скорости: trace показывает порядок и факт переходов, а latency требует измерения. Если один из этих фактов неизвестен, правильный результат — остановка и новый вопрос, а не расширение предположений.
\nМеханизм готов к отдельному инженерному тесту, если для одного фиксированного сценария можно показать пять вещей: один retry owner; конечный maxAttempts; числовой maxFanout; fallback с именем, условием и запретом вложенного retry; limit point до расширения маршрута. Trace должен показывать расход бюджета и один terminal action после его исчерпания. Тест должен пройти положительный record и отклонить retry-amplification-v1 с причиной, которую можно прочитать без догадки.
\nЕсли хотя бы один пункт не выполняется, не добавляйте новую защиту. Сначала уточните владельца, границу или контракт результата. После этого можно отдельно планировать нагрузочный тест и проверку в среде с реальной телеметрией. Эта статья даёт механизм чтения каскада, а не обещание, что учебная схема переживёт отказ в production.
\nВнешний сервис начинает отвечать медленно, очередь на входе растёт, а собственное приложение расходует соединения на незавершённые запросы. Gateway повторяет вызов после timeout, адаптер делает ещё одну попытку, а балансировщик параллельно отправляет работу на несколько реплик. Каждый механизм по отдельности выглядит разумно. Вместе они могут превратить временный сбой в каскад: перегруженная зависимость получает новые запросы именно тогда, когда ей нужно уменьшить нагрузку.
\nГлавный вопрос здесь не «какое значение поставить для timeout». Сначала нужно определить, сколько дополнительной работы может породить один логический запрос, кто имеет право повторять вызов и где маршрут обязан остановиться. Только после этого выбирают задержку между попытками, реакцию на 503 и режим деградации. Иначе разные слои незаметно складывают свои политики: три попытки на клиенте и три на gateway дают до девяти вызовов одной зависимости ещё до учёта fan-out.
\nДалее используется маленькая фиксированная модель. Она считает только структуру маршрута: два логических запроса, максимум две попытки на каждый и один выбранный адрес за попытку. В ней нет сети, часов, очереди или измерения latency. Такой пример помогает увидеть границу и проверить отрицательный путь, но не подбирает значения для настоящего сервиса.
\nЕдиница расчёта — логический запрос пользователя или вызывающего сервиса. Один такой запрос может породить несколько исходящих вызовов. Попытка — один запуск зависимости; повтор добавляет следующую попытку после разрешённого исхода. Fan-out, или ширина разветвления, отвечает на другой вопрос: сколько направлений запускаются для одной попытки. Список из пяти реплик сам по себе не означает пять вызовов.
\nFallback также нельзя смешивать с retry. Он меняет результат: вместо полного ответа возвращает, например, короткую сводку из локального кеша. Если fallback сначала ищет данные в ещё трёх местах или запускает собственный retry, это уже новая работа, которую надо включить в расчёт. Точка остановки задаёт момент, после которого никакая ветвь не имеет права расширить маршрут.
\n| Величина | Значение в примере | Что она ограничивает | Что не следует из значения |
|---|---|---|---|
| Логические запросы | 2 | Объём сравниваемой нагрузки | Реальный QPS и размер очереди |
| Владелец retry | gateway | Единственное право создать повтор | Правильность выбора самого gateway |
| Максимум попыток | 2, включая исходную | Последовательное размножение вызовов | Безопасность повторения операции |
| Максимальный fan-out | 1 | Число адресов на одну попытку | Доступность и равномерность реплик |
| Fallback | cached-summary, 1 work unit | Цена сокращённого результата | Его полезность для продукта |
| Точка остановки | до route expansion | Запуск новых ветвей после нулевого бюджета | Отмену уже начатого сетевого вызова |
Для верхней границы в этой игрушечной схеме достаточно перемножить независимые множители: 2 логических запроса × 2 попытки × 1 направление = 4 route attempts. Это число не является прогнозом нагрузки. Оно отвечает на узкий вопрос: не появилась ли в конфигурации скрытая ветвь, которой нет в контракте.
\nПредставим один вызов с временным отказом. Gateway ждёт до своего deadline и создаёт второй вызов. Если адаптер внутри gateway тоже считает тот же отказ разрешением на повтор, один внешний retry превращается ещё в несколько внутренних. Если оба слоя выбирают две реплики, число запросов растёт дополнительно. При этом исходная причина — перегрузка или задержка — никуда не исчезает.
\nУ политики должен быть один владелец. Остальные слои могут передавать deadline, отмену и контекст попытки, но не должны тайно запускать собственный цикл. Если владельцев несколько по архитектурной необходимости, это надо считать произведением и ограничивать как отдельный контракт. Фраза «везде всего по две попытки» не описывает верхнюю границу, пока не указано, где заканчивается одна операция и начинается другая.
\nconst assert = require('node:assert/strict');\n\nconst policy = {\n logicalRequests: 2,\n maxAttempts: 2, // первая попытка уже входит в число\n maxFanout: 1,\n retryOwner: 'gateway',\n retryableStatuses: new Set([503]),\n fallback: { name: 'cached-summary', workUnits: 1 },\n};\n\nfunction assess(status, retryOwners) {\n if (retryOwners.length !== 1 || retryOwners[0] !== policy.retryOwner) {\n return { status: 'stop', reason: 'retry должен иметь одного владельца' };\n }\n\n const retryable = policy.retryableStatuses.has(status);\n const attempts = retryable ? policy.maxAttempts : 1;\n const routeAttempts =\n policy.logicalRequests * attempts * policy.maxFanout;\n\n return {\n status: 'ok',\n routeAttempts,\n terminal: retryable ? 'fallback:' + policy.fallback.name : 'response',\n };\n}\n\nassert.deepEqual(assess(503, ['gateway']), {\n status: 'ok',\n routeAttempts: 4,\n terminal: 'fallback:cached-summary',\n});\nassert.equal(assess(503, ['gateway', 'adapter']).status, 'stop');\nconsole.log(assess(503, ['gateway']));\nСохраните блок в файл cascade-check.cjs и запустите командой node cascade-check.cjs. В штатном пути он напечатает объект с четырьмя маршрутными попытками. Второй assert проверяет отрицательный путь: два владельца retry останавливают проверку до запуска какого-либо маршрута. Пример намеренно не вызывает HTTP-клиент; его задача — сделать правило видимым и воспроизводимым на чистом Node.js.
Timeout ограничивает время ожидания конкретного вызова. Он не сообщает, кто может повторить этот вызов и сколько дополнительной работы разрешено создать. Слишком короткое значение иногда увеличивает нагрузку: слой объявляет вызов неуспешным, пока зависимость продолжает обрабатывать его, а затем отправляет копию. Поэтому deadline должен распространяться по цепочке и учитывать отмену, а не превращаться в независимый таймер на каждом уровне.
\nHTTP 503 означает, что сервер временно не может обработать запрос; RFC 9110 допускает заголовок Retry-After как подсказку, сколько подождать перед следующим запросом. Это семантика ответа, а не готовое решение о повторе. Клиенту всё равно нужно сопоставить статус с идемпотентностью операции, остатком общего бюджета и допустимым результатом. Ошибку валидации или неверный запрос нельзя «лечить» повтором: одинаковый вход снова даст тот же постоянный исход.
\nBackoff снижает вероятность синхронной волны повторов, но не ограничивает их количество. Для повторов нужен конечный максимум, а для всего процесса — наблюдаемый budget. В качестве отдельных сигналов полезно видеть исходные вызовы, повторы, время ожидания, отмены и переход в fallback. Одна метрика ошибок без этих разрезов не показывает, что именно увеличивает нагрузку.
\nРежим деградации не означает «вернуть что-нибудь». Он меняет контракт для пользователя, поэтому его результат должен иметь имя, условие включения и понятную цену. В примере cached-summary возвращает неполную сводку после исчерпания попыток. Он не выбирает новые реплики и не запускает retry. Продуктовая команда отдельно решает, допустима ли такая сводка для конкретного экрана.
Проверяйте fallback как самостоятельный маршрут. Запишите его work units: чтение локального кеша, обращение к резервному хранилищу и сериализация ответа не обязательно стоят одинаково. Если резервный путь дороже основного или тоже зависит от перегруженного сервиса, его нельзя считать безопасной деградацией. Иногда правильный fallback — немедленный отказ с понятным кодом, а не ещё один поиск.
\nЛимит, проверяемый после выбора всех реплик, лишь сообщает о проблеме постфактум. К моменту проверки лишние вызовы уже заняли соединения. Сначала проверьте остаток бюджета, затем выберите следующий маршрут, а после ответа решите, нужен ли единственный разрешённый повтор. Когда бюджет равен нулю, результат должен быть terminal: ответ, fallback или ошибка. Новая попытка после terminal action — дефект контракта.
\nДля диагностики запишите переходы в trace: request → attempt-1 → temporary failure → budget-1 → attempt-2 → fallback. В опасном варианте будет видно другое: attempt-1 → replica-a + replica-b → adapter-retry → .... Такой trace помогает найти место размножения без спора о том, какой timeout «выглядит правильнее». Проверяйте порядок событий, а не только итоговый счётчик.
| Наблюдаемый симптом | Гипотеза | Воспроизводимая проверка | Ограниченное действие |
|---|---|---|---|
| После 504 исходящих вызовов становится больше | Повтор включён на двух слоях | Сопоставить retry owner в конфигурации и trace | Оставить один цикл, остальные слои сделать pass-through |
| Один request_id встречается на нескольких репликах сразу | Включён fan-out или hedging | Посчитать адреса до первого успешного ответа | Задать maxFanout и отдельно доказать безопасность копий |
| Fallback задерживает ответ | Он сам ищет данные и повторяет запрос | Развернуть его переходы и work units | Упростить до дешёвого результата или отключить режим |
| Лимит срабатывает, но нагрузка уже выросла | Проверка стоит после route expansion | Сравнить порядок trace transitions | Перенести точку остановки перед созданием ветвей |
| Два теста дают разные выводы | Различаются нагрузка или тип сбоя | Сверить logical load и failure injection | Сравнивать только одинаковые входы либо признать тесты несопоставимыми |
Формула из примера проверяет только число потенциальных маршрутных попыток. Она не учитывает время, параллелизм, уже выполняющиеся запросы, размер ответа, пропускную способность, rate limit, очередь, кэш-промахи и отмену на сервере. Четыре попытки в модели могут быть четырьмя последовательными вызовами или четырьмя одновременными — эти режимы имеют разный риск.
\nЧисла 2 и 1 выбраны для ручной проверки, а не для переноса в конфигурацию. В реальном сервисе предел зависит от стоимости операции, класса данных, SLO, ёмкости зависимости и допустимой потери результата. Для записи, платежа или другой операции с побочным эффектом одного timeout недостаточно, чтобы признать повтор безопасным. Нужны идемпотентный ключ, семантика сервера и проверка компенсации.
\nНе следует превращать статус ok из скрипта в обещание доступности. Он означает только, что фиксированная карточка удовлетворяет нескольким формальным условиям. Доказательство устойчивости требует измерения под нагрузкой, наблюдения за отменами и проверки поведения при частичном отказе. Если факт неизвестен, модель должна остановиться и назвать недостающую проверку.
Схема готова к инженерному испытанию, если можно без догадки ответить на пять вопросов: кто владеет retry; сколько попыток разрешено и как они считаются; сколько направлений может появиться на попытку; что именно возвращает fallback и сколько работы он добавляет; где прекращается создание новых ветвей. Для каждого ответа должен существовать trace или тестовый assert. Отдельно зафиксируйте, что в этом доказательстве не проверяется: реальная ёмкость, пользовательская ценность деградации и безопасность побочных эффектов.
\nПорядок важнее набора терминов. Сначала ограничьте дополнительную работу и сделайте её видимой. Затем настройте timeout, backoff и протокол ответа под измеренный сценарий. Такой маршрут не отменяет нагрузочные испытания, но не даёт локальной настройке retry скрыть каскад за красивым числом попыток.
\nmaxAttempts, списком повторяемых статусов, backoff и throttling. В документе максимум попыток включает исходный RPC; proposal не является общей политикой для HTTP и других стеков.Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос. Адаптер повторяет тот же вызов. Затем fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад.
\nСимптом виден в трёх местах: исходящих вызовов на один пользовательский запрос становится больше, очередь не сокращается после добавления реплик, а trace показывает несколько владельцев retry. Цена ошибки выше задержки. Ослабленный dependency получает дополнительную работу в момент, когда уже не справляется с прежней. Каскад может перегрузить соседние компоненты и превратить частичный отказ в общий.
\nТезис простой: устойчивость начинается с ограничения работы. Для каждой логической операции нужно назначить одного владельца retry, задать конечный fan-out, назвать fallback и определить точку, после которой новые вызовы запрещены. Если эти границы нельзя восстановить из trace, систему нельзя считать готовой к проверке отказа.
\nЛогическая операция — это один запрос пользователя или одно сообщение, которое система должна обработать. Внутри неё могут быть несколько сетевых вызовов. Поэтому успешные ответы не показывают полную стоимость. Считайте попытки, созданные после каждого временного исхода.
\nПредположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.
\nRetry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.
\nНиже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.
\nconst policy = {\n retryOwner: 'edge',\n maxAttempts: 2,\n retryable: ['temporary-timeout'],\n replicas: ['primary-a', 'primary-b'],\n maxFanoutPerAttempt: 1,\n fallback: {\n name: 'named-summary',\n trigger: 'optional-result-unavailable',\n addsRetry: false,\n },\n exhausted: 'return-degraded-result',\n};\n\nfunction nextAction(outcome, retryBudget) {\n if (retryBudget === 0) return policy.exhausted;\n if (outcome === 'temporary-timeout') return 'retry-on-one-named-replica';\n if (outcome === 'optional-result-unavailable') return policy.fallback.name;\n return 'complete';\n}\n\n// Учебная проверка: это не production-код и не нагрузочный тест.\nconsole.log(nextAction('temporary-timeout', 0));\n// return-degraded-result\nОтрицательный путь здесь важнее положительного. При нулевом бюджете функция не выбирает новую реплику и не входит в fallback. Она возвращает конечное состояние. В реальной системе такой degraded result подходит не каждой операции. Для платежа, записи или команды с побочным эффектом он может скрыть неопределённость. Тогда контракт должен вернуть явную ошибку или запустить безопасную компенсацию. Учебный результат нельзя переносить туда без отдельной проверки.
\nПоложительный путь тоже ограничен. При temporary-timeout край может выполнить одну разрешённую повторную попытку на одной реплике. Fallback не получает право повторить основной вызов. Его задача — вернуть заранее названный урезанный результат, если необязательная часть недоступна. Если fallback сам ищет реплику, он становится новым маршрутом и получает собственный лимит.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Вызовов больше, чем логических запросов | Retry включён на нескольких слоях | Посчитать попытки на один request id и найти их владельцев в trace | Оставить одного владельца, вложенный retry отключить |
| При timeout запускаются несколько реплик | Fan-out задан как «все здоровые» | Проверить список имён и максимум запусков на попытку | Задать число и одно имя на одну попытку |
| Fallback увеличивает нагрузку | Запасной путь скрывает сетевой вызов или retry | Развернуть trace fallback до terminal result | Сделать fallback режимом без повторов или выключить его |
| Лимит виден после новых вызовов | Ограничение стоит после расширения маршрута | Сравнить порядок: budget, маршрут, вызов | Перенести limit до retry, fan-out и fallback |
| Сравниваются разные сценарии | Различаются нагрузка или failure injection | Сверить logical requests, исход и baseline | Сначала выровнять сценарии, затем сравнивать изменения |
Trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции запишите request id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и terminal action. Если модель использует условные units, не называйте их миллисекундами. Единица измерения должна быть ясна.
\nМинимальная последовательность ограниченного сценария может выглядеть так: logical-01 → primary-a → temporary-timeout, затем edge retry budget: 2 → 1, затем logical-01 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появляться скрытого выбора третьей реплики.
Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.
\nСхема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.
\nВременный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.
\nКод 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.
\nСценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.
\nЕсли хотя бы один ответ требует догадки, сценарий не готов. Сначала назовите скрытый переход и назначьте его владельца. После этого отдельный тест с реальной сетью, данными, правами и наблюдением должен подтвердить поведение уже production-подобного пути. Результат учебной модели такой тест не предсказывает.
\nСервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос, адаптер повторяет тот же вызов, а fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад: один пользовательский запрос порождает несколько обращений к уже перегруженной зависимости.
\nСимптом можно увидеть без догадок: на один request_id приходится больше исходящих попыток, очередь не сокращается после добавления реплик, а trace показывает несколько независимых владельцев retry. Цена ошибки — не только лишние миллисекунды. Ослабленная зависимость получает новую работу в момент, когда не справляется с прежней, а частичный отказ распространяется на соседние компоненты.
В этой статье «один бюджет» означает один бюджет попыток для одной логической операции и одного владельца retry. Это не глобальный лимит сервиса и не разрешение повторять всё подряд. Для защиты от общей перегрузки нужен отдельный сервисный лимит или circuit breaker, но он не должен превращаться в ещё один слой автоматических повторов.
\nЛогическая операция — это запрос пользователя или сообщение, которое система должна обработать. Внутри неё могут быть вызов API, чтение из базы и запрос к необязательному сервису. Считать только успешные ответы опасно: стоимость отказа состоит из всех попыток, включая те, что завершились timeout или были отменены по дедлайну.
\nЗапишите границу операции и единицу счёта. Например: checkout-481 — одна команда оформления, а вызовы pricing и inventory — её дочерние действия. Если верхний слой разрешает две попытки, это обычно означает одну исходную попытку и одну повторную, а не две дополнительные попытки на каждом нижнем сервисе.
При независимых retry на нескольких слоях верхняя граница растёт мультипликативно. Если три слоя делают по четыре попытки, один запрос может создать до 64 вызовов нижней зависимости. Это верхняя оценка для такого дерева, а не обещание, что каждый вызов будет выполнен: отмена, дедлайн и ошибки могут остановить ветку раньше. Но уже сама оценка показывает, почему локальные настройки нельзя складывать без общей модели.
\nУ retry должен быть один владелец на границе, где видны дедлайн всей операции и её итог. Остальные слои возвращают ему исход вызова: temporary-timeout, overloaded, постоянную ошибку или успех. Они не запускают собственные циклы, если это не отдельный, явно описанный контракт.
Бюджет нужно проверять до создания следующего вызова. Полезная последовательность такая: прочитать остаток бюджета, проверить тип ошибки, выбрать ровно одну реплику, создать попытку, уменьшить бюджет и записать результат. Если лимит проверяется после выбора нескольких реплик или после запуска параллельных запросов, это уже не ограничитель каскада, а журнал случившегося.
\nСлучайный экспоненциальный backoff с jitter снижает синхронный всплеск повторов, но не заменяет предел попыток. Backoff отвечает на вопрос «когда повторить», бюджет — «можно ли создавать ещё работу». Постоянную ошибку запроса или неверный вход повторять нельзя: задержка не сделает такой ответ успешным.
\nRetry — повтор той же логической операции после временного исхода. Fan-out — запуск нескольких ветвей для одного шага. Fallback — заранее названный результат с меньшей полнотой или явная ошибка, если необязательная часть недоступна. Смешивание этих понятий и создаёт скрытый рост нагрузки.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Вызовов больше, чем операций | Retry включён на нескольких слоях | Сгруппировать попытки по request_id и записать владельца каждой | Оставить один цикл, остальные слои сделать pass-through |
| Timeout запускает несколько реплик | Fan-out задан как «все здоровые» | Посчитать запуски до получения первого результата | Задать число и имя реплики на каждую попытку |
| Fallback увеличивает нагрузку | Запасной путь скрывает сетевой вызов или retry | Развернуть trace fallback до terminal result | Сделать путь локальным и без повторов либо вернуть ошибку |
| После лимита видны новые spans | Бюджет проверяется после расширения маршрута | Сверить порядок budget → route → call | Перенести проверку перед выбором и запуском ветки |
| Повтор дублирует запись | Timeout не говорит, дошла ли команда до сервера | Проверить идемпотентность и ключ операции | Не повторять без идемпотентного контракта или компенсации |
Ниже — самостоятельный пример для Node.js 18+. Он не открывает сеть и не имитирует реальную производительность. Его задача — сделать видимыми три решения: две попытки означают «исходная плюс одна повторная», на одну попытку выбирается одна реплика, а fallback после успешного основного вызова не запускает новый retry.
\nconst policy = {\n maxAttempts: 2, // всего: исходная попытка + один retry\n retryable: new Set(['temporary-timeout', 'overloaded']),\n replicas: ['primary-a', 'primary-b'],\n fallback: 'named-summary',\n};\n\nfunction execute(mainOutcomes, optionalOutcome = 'available') {\n const trace = [];\n\n for (let i = 0; i < policy.maxAttempts; i += 1) {\n const outcome = mainOutcomes[i] ?? 'temporary-timeout';\n const replica = policy.replicas[i];\n trace.push(`attempt=${i + 1} replica=${replica} outcome=${outcome}`);\n\n if (outcome === 'complete') {\n if (optionalOutcome === 'optional-result-unavailable') {\n trace.push(`fallback=${policy.fallback} retry=false`);\n return { result: 'degraded', trace };\n }\n return { result: 'complete', trace };\n }\n\n if (!policy.retryable.has(outcome)) {\n return { result: 'error', trace };\n }\n }\n\n trace.push('terminal=no-new-call');\n return { result: 'error', trace };\n}\n\nconsole.log(execute(['temporary-timeout', 'complete']));\nconsole.log(execute(['temporary-timeout', 'temporary-timeout']));\nconsole.log(execute(['complete'], 'optional-result-unavailable'));\nСохраните блок как resilience-demo.mjs и выполните node resilience-demo.mjs. В первом результате будут две попытки и успех. Во втором — две попытки и строка terminal=no-new-call; третьей реплики или вложенного fallback нет. В третьем — один основной вызов и fallback=named-summary retry=false. Так проверяется именно контракт переходов, а не доступность конкретного сервиса.
Имена реплик, набор retryable-исходов и смысл degraded result здесь проектные. В рабочей системе их нужно заменить на значения своего клиента и доказать отдельными тестами: с timeout, с перегрузкой, с постоянной ошибкой и с отменой по дедлайну.
\nОдин trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции записывайте request_id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и итоговое действие. Не называйте условные units миллисекундами, если это не измерение времени.
Минимальная последовательность может выглядеть так: logical-01 → edge attempt=1 → primary-a → temporary-timeout, затем edge attempt=2 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появиться нового обращения к основной зависимости.
Полезны четыре счётчика: количество логических операций, количество всех попыток, количество повторов и количество terminal actions по исчерпанию бюджета. Сравнивайте их на одном сценарии и одинаковой нагрузке. Если растёт только число повторов, зависимость может оставаться здоровой; если растут одновременно повторы, timeout и очередь, ищите положительную обратную связь. Метрика сама по себе не доказывает причинность, поэтому связывайте её с trace.
\nДля HTTP-сервиса ответ 503 Service Unavailable может сообщать временную недоступность. Заголовок Retry-After указывает, сколько ждать до следующего запроса; RFC 9110 допускает задержку в секундах или HTTP-date. Это полезный сигнал клиенту, но он не назначает владельца retry и не ограничивает fan-out.
Клиент всё равно должен применить бюджет всей логической операции, дедлайн и список действительно временных исходов. Нельзя превращать любой 5xx или любой timeout в бесконечный повтор. Если сервер отвечает 503 с Retry-After, верхний слой может учесть указание, но обязан остановиться при исчерпании бюджета. Если операция меняет состояние, проверьте идемпотентность до включения автоматического retry.
Эта схема ограничивает работу, но не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, балансировку, отмену, распределённые транзакции, согласованность реплик и стоимость запуска процесса. Для этих свойств нужны нагрузочные и отказоустойчивые испытания на своей архитектуре.
\nFallback подходит только там, где неполный результат честно описывает состояние для пользователя. Для платежа, записи, команды или операции с неизвестным исходом безопаснее вернуть явную ошибку и запустить согласованную компенсацию. Degraded result не должен маскировать факт, что побочный эффект мог выполниться.
\nДаже ограниченный retry может продублировать действие: запрос мог дойти до сервера, а timeout возник при чтении ответа. Поэтому для записи нужен идемпотентный ключ, дедупликация на стороне обработчика или другой явно проверенный контракт. Без него снижение числа попыток уменьшает риск, но не устраняет его.
\nСценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбрана на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых логических операций и одинакового отказа число запусков не превышает установленный предел.
\nЕсли хотя бы один ответ требует догадки, причина каскада не доказана. Сначала назовите скрытый переход и назначьте его владельца. Затем подтвердите поведение на production-подобном пути с реальной сетью, правами, дедлайном и данными. Учебный скрипт проверяет модель бюджета, но не заменяет такой тест.
\nПосле небольшого изменения API клиент начинает показывать пустой экран. Платформенная команда добавила поле в JSON, оставила старые поля и подняла версию с 1.2.0 до 1.3.0. Один ручной запрос вернул правильный ответ. Через час другой клиент получает новый статус, не находит запись и повторяет запрос.
Цена ошибки выше, чем неудачный запрос. Клиент может сохранить неверное состояние, повторить команду или показать пользователю, что объект исчез. Команда платформы тратит время на спор о слове «совместимо», хотя не записала, что именно клиент обязан прочитать и какие ошибки должен различать.
\nТезис статьи короткий: совместимость принадлежит паре «контракт — конкретный потребитель». Номер версии помогает назвать поверхность изменения, но не заменяет проверку. Схема OpenAPI описывает форму HTTP API, а не закрытый парсер клиента, порядок обработки полей или смысл ошибки. Поэтому перед изменением нужно зафиксировать семью контракта, минимальные поля, разрешённые ошибки и отдельные исключения.
\nВозьмём учебный API чтения каталожной записи. Он принимает recordId и возвращает обязательные поля id и state. Поле label необязательно. Ошибка fixed-not-found означает только отсутствие записи. Она не означает ошибку сети, отказ в доступе или невалидный запрос.
GET /records/42 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n \"id\": \"42\",\n \"state\": \"active\",\n \"label\": \"Учебная запись\"\n}\nЭтот фрагмент показывает одну representation — передаваемую форму ресурса. Он не обещает порядок ключей, время ответа, сохранность записи или наличие поля в следующей версии. Даже наличие label в примере не делает его обязательным. Эти свойства нужно вынести в контракт явно. Иначе наблюдение быстро превращается в неофициальную гарантию.
Потребитель должен быть описан так же точно. Например, fixed-tolerant-reader-v1 читает только id и state, принимает версию 1.3.0 и знает ошибку fixed-not-found. Другой клиент требует legacyMode. Для него тот же ответ неполон. Третий адаптер отправляет команды, а не читает записи. Его нельзя сравнивать с read API только из-за одинакового JSON.
Не начинайте с перечня всех команд и репозиториев. Запишите минимальное решение, которое клиент принимает по ответу. В карточке нужны четыре поля: contractFamily, поддерживаемая версия, обязательные поля и известные ошибки. Если клиент зависит от порядка, повторов или специального заголовка, это тоже явное требование. Если требование неизвестно, статус должен остаться неизвестным.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Клиент не видит новое поле | Поле добавили, но клиент использует закрытую десериализацию | Сверить required fields и обработку unknown fields | Сохранить старый ответ или выпустить отдельный контракт |
| 404 стал «нет записи» для всех ошибок | Клиент смешал прикладную ошибку с сетевой | Воспроизвести 404, 401, 403, 422 и timeout раздельно | Оставить fallback только для документированной ошибки |
| Похожий endpoint объявили несовместимым | Сравнили разные contract family | Проверить operation и family до сравнения полей | Вернуть stop-incomparable-consumer |
| После minor-версии изменился смысл статуса | Новый символ или переход не вошёл в гарантию | Сверить список значений и переходы состояний | Оформить изменение как новый контракт или сохранить семантику |
| Ручной запрос успешен, релиз сломан | Проверили один пример вместо named consumer | Запустить проверку на карточке конкретного клиента | Не передавать общий verdict без причин и next action |
Contract family — это вид операции и её смысловая граница. Read API, командный адаптер и webhook могут иметь поля id и state, но описывают разные действия. Сначала сравните fixed-catalog-read-v1 с тем, что объявил consumer. Если consumer относится к fixed-catalog-command-v1, проверка не должна доходить до полей.
Такой результат не равен incompatibility. Команды пока не доказали, что объекты сопоставимы. Если назвать его просто «несовместимо», следующая команда начнёт ненужную миграцию. Точный статус сохраняет границу: нужен отдельный review для command family.
\nСледующий код ограничен учебными объектами в памяти. Он не вызывает API, не читает production-трассы и не доказывает поведение реального клиента. Его задача — показать порядок решения: family проверяется раньше полей.
\nconst api = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n response: ['id', 'state'],\n errors: ['fixed-not-found']\n};\n\nconst consumer = {\n id: 'fixed-command-adapter-v1',\n family: 'fixed-catalog-command-v1',\n required: ['id', 'state']\n};\n\nfunction review(api, consumer) {\n if (api.family !== consumer.family) {\n return {\n status: 'stop-incomparable-consumer',\n nextAction: 'separate-contract-review-by-family'\n };\n }\n\n const missing = consumer.required.filter(\n (field) => !api.response.includes(field)\n );\n return missing.length\n ? { status: 'incompatible-consumer', missing }\n : { status: 'compatible-for-this-check' };\n}\n\nconsole.log(review(api, consumer));\n// { status: 'stop-incomparable-consumer',\n// nextAction: 'separate-contract-review-by-family' }\nПоложительный статус здесь тоже узкий. Он означает только, что зафиксированная пара прошла перечисленные проверки. Он не означает, что rollout безопасен, SLA выполнен или в системе нет неизвестных клиентов. В production-коде такой verdict должен сопровождаться именем consumer, версией и перечнем проверенных гарантий.
\nСлово optional обычно относится к конкретному валидатору или схеме. Оно не отвечает на вопрос, что сделает consumer, если поле отсутствует или появилось неожиданное поле. Один reader игнорирует расширение. Другой использует строгую модель. Третий считает отсутствие поля признаком старого режима.
Предположим, что в ответ добавили legacyMode. Если tolerant reader его не использует, добавление может быть безопасным для этой пары. Но клиент, который требует поле, уже нельзя пометить compatible. Нельзя выводить обратное поведение из названия поля или из того, что ручной запрос всё ещё проходит.
То же относится к значениям перечисления. Старый клиент может принимать active и archived, но падать на новом paused. В схеме поле осталось строкой, а смысл ответа изменился. Значит, проверка должна сравнивать не только наличие поля, но и допустимые значения и переходы, которые видит клиент.
Список ошибок — часть поведения consumer. Если fallback разрешён только для fixed-not-found, клиент не должен подставлять пустое состояние после 401, 403, 422 или таймаута. Иначе временный сбой превращается в потерю данных на экране, а повтор может отправить команду дважды.
Иногда нужен специальный путь. Например, один внутренний инструмент получает расширенное представление. Это допустимо только как отдельный, названный и документированный контракт. Он должен описать получателя, версию, входные условия и то, чего не гарантирует. Секретный query-параметр вроде ?debug=1 не является исключением. Его обнаружит следующий consumer, но не обнаружит общий review.
Если команда добавляет гарантию о стабильном порядке, времени ответа или доступности, её нельзя прятать рядом с полем. Это новая публичная обязанность. Её нужно назвать, проверить на соответствующем уровне и привязать к consumer. Один успешный trace не доказывает ни одну из этих гарантий.
\nSemVer полезен после того, как команда определила public API. Он помогает назвать совместимые добавления и несовместимые изменения. Но строка 1.3.0 сама не отвечает, является ли новый статус допустимым, игнорирует ли клиент неизвестные поля и относится ли consumer к той же семье.
OpenAPI снижает догадки о форме HTTP-интерфейса: путях, параметрах, запросах, ответах и схемах. Это необходимый слой описания. Но документ не знает скрытую ветку клиентского кода. RFC 9110 также не превращает representation в гарантию прикладной совместимости. Поэтому стандарты дают словарь и границы, а итог принимает проверка конкретной пары.
\nstop-incomparable-consumer и откройте отдельный review.Такой review не обнаруживает неизвестные интеграции сам по себе. Он не заменяет контрактные тесты, нагрузочные проверки, security review, миграцию данных, SLA или план отката. Учебный код выше работает на фиксированных литералах. Он не сообщает production-результат и не подтверждает, что реальный клиент действительно описал все свои зависимости.
\nПроверку можно считать готовой только для явно ограниченной пары. В отчёте есть одна contract family, одна версия, один named consumer, список required fields, допустимые ошибки, заявленные гарантии и итоговый status. Для compatible-for-this-check нет неописанного claim и не осталось неизвестного обязательства. Для любого stop указаны причина и следующий шаг. Если хотя бы одного элемента нет, слово «совместимо» преждевременно.
Платформенная команда добавила в ответ новое поле и подняла версию с 1.2.0 до 1.3.0. Ручной запрос по-прежнему возвращает 200 OK, поэтому изменение называют обратно совместимым. Но один клиент читает ответ строгим декодером, второй ждёт старый набор значений, а третий обращается к endpoint команды, хотя сравнивает его с read API. Через час после rollout один экран показывает пустое состояние, другой повторяет запрос, а участники review спорят о том, что означает слово «совместимо».
Цена ошибки — не только сломанный экран. Клиент может записать неверное состояние, повторить необратимую операцию или превратить отказ в «объект не найден». Платформа затем получает временный флаг, скрытый обход и ещё одного потребителя, которого никто не внес в список. Поэтому вопрос перед изменением звучит точнее: совместим ли конкретный контракт с конкретным потребителем, для конкретного набора входов, ответов и ошибок?
\nСовместимость появляется не у версии самой по себе. Её проверяют на паре: именованный контракт и именованный потребитель. Контракт задаёт операцию, семейство, версию, формат запроса, формат ответа, ошибки и гарантии. Потребитель задаёт поддерживаемые версии, обязательные поля, допустимые значения и правила обработки отказов.
\nВ этой статье используется синтетическая пара fixed-catalog-read-v1 и fixed-tolerant-reader-v1. Она нужна для воспроизводимого рассуждения, а не для заявления о реальном сервисе. Реальный review дополнительно потребует инвентарь потребителей, контрактные тесты, права на проверку окружения и доказательства поведения конкретной версии клиента.
Пример ответа показывает одну representation — передаваемое представление ресурса. Он не обещает порядок ключей, время ответа, кэширование, сохранность записи или поведение при неизвестном поле. Такие свойства становятся контрактом только тогда, когда их явно описали и связали с потребителем.
\nGET /records/r-17 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n \"id\": \"r-17\",\n \"state\": \"ready\",\n \"label\": \"Демо-запись\"\n}\nДля учебного read-контракта минимальная гарантия такова: id и state обязательны, label необязателен, а state принимает только ready или blocked. Отсутствие записи обозначается ошибкой fixed-not-found. Ответ с 401, 403, 422 или сетевым таймаутом не является этой ошибкой. Такая граница нужна, чтобы fallback не стирал различие между отсутствием данных и проблемой доступа.
OpenAPI помогает записать HTTP-поверхность в форме, пригодной для людей и инструментов: operation, параметры, responses и schemas. Но схема не знает закрытую ветку кода клиента. Строгий декодер, зависимость от порядка массива и особый query-параметр могут существовать вне OpenAPI. Документ снижает неопределённость формы, но не заменяет проверку потребителя.
\nСначала сравните contractFamily, затем версию и поверхность. Read API fixed-catalog-read-v1 и командный адаптер fixed-catalog-command-v1 могут иметь одинаковые поля id и state, но отвечают на разные действия. Один читает состояние, второй запускает изменение. Называть их несовместимыми — значит уже предположить, что сравнение допустимо. Правильный результат здесь — «несопоставимо», а не отрицательный вердикт по полям.
Это короткая, но важная остановка. Если её пропустить, команда начнёт чинить не тот контракт: добавит в read API поля для команды или объявит общую версию, которая скрывает разные риски повторения и идемпотентности. Семейство должно быть именованным, а не выводиться по похожему URL или совпавшим ключам.
\nconst api = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n requiredResponse: ['id', 'state'],\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-command-adapter-v1',\n family: 'fixed-catalog-command-v1',\n requiredResponse: ['id', 'state'],\n};\n\nfunction compareFamily(contract, client) {\n if (contract.family !== client.family) {\n return {\n status: 'stop-incomparable',\n reason: 'contract-family-mismatch',\n nextAction: 'open-separate-command-review',\n };\n }\n\n return { status: 'family-matches' };\n}\n\nconsole.log(compareFamily(api, consumer));\n// stop-incomparable: сравнение полей ещё не началось\nКод использует только объекты в памяти. Он не обращается к сети, не проверяет реального клиента и не выдаёт разрешение на выпуск. Его полезность в том, что отрицательный путь нельзя случайно превратить в «почти совместимо»: при другой family функция останавливается до анализа полей.
\nФраза «у нас есть несколько клиентов» слишком расплывчата для решения. Карточка должна отвечать на пять вопросов: кто потребитель, к какому семейству относится, какую версию поддерживает, что ему обязательно прочитать и какие ошибки он различает. Если клиент зависит от неизвестных полей, порядка элементов, заголовка или задержки, зависимость нужно записать отдельно, даже если она пока не считается допустимой.
\n| Поле | Учебное значение | Что доказывает | Чего не доказывает |
|---|---|---|---|
| consumer id | fixed-tolerant-reader-v1 | какую пару проверяем | что неизвестных клиентов нет |
| family | fixed-catalog-read-v1 | операции сопоставимы | что поля совпадают |
| supported version | 1.3.0 | какую поверхность клиент заявляет | что реализация действительно её принимает |
| required fields | id, state | минимум чтения | что новое значение enum обработано |
| known errors | fixed-not-found | какой fallback разрешён | что timeout можно считать отсутствием |
| hidden dependency | не зафиксирована | остаётся вопрос для проверки | что можно объявить compatible |
Пустая ячейка — это не нулевой риск. Если версия или required fields неизвестны, потребитель нельзя включать в успешный список. Это отдельный статус stop-unknown-consumer с действием «получить карточку». Инвентарь не должен награждать отсутствие сведений положительным verdict.
Добавление необязательного поля часто безопаснее удаления обязательного, но слово «часто» не является результатом проверки. Нужно знать, как клиент обращается с неизвестными ключами. У строгого валидатора расширение может стать ошибкой. У tolerant reader оно может быть проигнорировано. У третьего клиента отсутствие нового поля может включить устаревший режим.
\nОсобенно опасно изменение перечисления. Старый клиент принимает ready и blocked. Если сервер добавляет paused, JSON остаётся корректным, а смысл для клиента — нет. Поэтому compatibility check должен сравнить допустимые значения и переходы, которые видит потребитель. Схема с типом string не доказывает, что любое строковое значение безопасно.
То же относится к массивам. Если контракт не обещает порядок, клиент не вправе выбирать первый элемент как «главный». Если порядок нужен, его надо назвать гарантией, протестировать и связать с версией. Текущий порядок, который виден в одном ответе, — наблюдение, а не обещание.
\nИногда потребителю действительно нужна расширенная форма. Это не повод открыть внутренний объект под флагом ?debug=1. Скрытый маршрут быстро становится вторым API: его начинают вызывать из скриптов, а затем требуют сохранить навсегда. У исключения должны быть имя, версия, получатель, входные условия, гарантии и отрицательная граница.
const escapeHatch = {\n name: 'raw-envelope-v1',\n consumer: 'fixed-exporter-v1',\n guarantees: ['id', 'state'],\n doesNotGuarantee: [\n 'field-order',\n 'future-fields',\n 'availability',\n 'filtering',\n ],\n};\n\nif (!escapeHatch.name || !escapeHatch.consumer) {\n throw new Error('stop-undocumented-exception');\n}\nТакая запись не делает escape hatch безопасным автоматически. Она лишь делает обещание видимым и ограниченным. После неё всё равно нужны тесты клиента, политика доступа, наблюдение и решение о сроке жизни. Если команда не может назвать владельца и отрицательные гарантии, временный обход не должен проходить compatibility review.
\nSemVer 2.0.0 различает patch, minor и major изменения публичного API. Это полезная дисциплина, когда public API уже определён. Но строка 1.3.0 не создаёт список потребителей и не отвечает, принимает ли клиент новый enum. Номер версии — адрес набора правил, а не доказательство того, что все участники прочитали этот набор одинаково.
HTTP Semantics задаёт значения методов, статус-кодов, сообщений и representations. Из этого не следует application-level совместимость: HTTP 200 не гарантирует, что клиент понял бизнес-состояние. 404 тоже не означает автоматически «можно показать пустой список» — это решение конкретного контракта и его потребителя.
Наконец, OpenAPI описывает поверхность HTTP API и позволяет генерировать документацию, клиентов и тесты. Однако specification не исследует исходный код каждого consumer. Поэтому три слоя дополняют друг друга: стандарт описывает vocabulary, контракт фиксирует обещания, а карточка потребителя показывает, что именно нужно проверить.
\nНиже — полностью локальная проверка на Node.js 18 или новее. Она не требует пакетов, сети и доступа к production. Сохраните фрагмент в любой временный файл или вставьте в Node REPL: он проверяет family, обязательные поля и допустимые ошибки на фиксированных данных. В рабочем репозитории тот же порядок следует перенести в contract test, где fixtures принадлежат контракту и потребителю.
\nconst contract = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n response: { id: 'r-17', state: 'ready' },\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-tolerant-reader-v1',\n family: 'fixed-catalog-read-v1',\n supportedVersions: ['1.3.0'],\n requiredFields: ['id', 'state'],\n handledErrors: ['fixed-not-found'],\n};\n\nfunction assertCompatible(api, client) {\n if (api.family !== client.family) return 'stop-incomparable';\n if (!client.supportedVersions.includes(api.version)) return 'stop-version';\n\n const missing = client.requiredFields.filter(\n (field) => !(field in api.response),\n );\n if (missing.length) return `stop-missing:${missing.join(',')}`;\n\n const unknownErrors = api.errors.filter(\n (error) => !client.handledErrors.includes(error),\n );\n if (unknownErrors.length) return `stop-error:${unknownErrors.join(',')}`;\n\n return 'compatible-for-this-fixture';\n}\n\nconsole.log(assertCompatible(contract, consumer));\n// compatible-for-this-fixture\nЗапуск возвращает только compatible-for-this-fixture. Это намеренно узкий результат: фикстура не знает о TLS, авторизации, нагрузке, реальном сериализаторе, rollout, миграции данных и неизвестных клиентах. Для отрицательного теста удалите state из contract.response: результат станет stop-missing:state. Замените family у consumer: получите stop-incomparable. Так проверяется не красивый happy path, а причина остановки.
Одно слово compatible плохо переносится между командами. Минимальный hand-off должен содержать идентификатор контракта, версию, consumer, проверенные гарантии, ограничения и следующий шаг. Для отрицательного результата причина обязательна: missing field, неизвестное значение, неподдерживаемая версия, mismatch family или undocumented exception.
| Статус | Когда выдавать | Следующее действие |
|---|---|---|
compatible-for-this-fixture | одна family, версия, поля и ошибки прошли указанную фикстуру | запустить реальные contract tests и review rollout |
stop-incomparable | семейства контрактов различаются | открыть отдельный review для другой операции |
stop-missing | нет обязательного поля или значения | сохранить поле, адаптировать consumer или выпустить новую поверхность |
stop-version | consumer не заявляет версию API | получить supported versions и тест на переход |
stop-unknown-consumer | нет карточки потребителя | установить владельца, family и минимальное чтение |
stop-undocumented-exception | обход не имеет имени или отрицательной границы | оформить versioned exception либо удалить обход |
Важно не смешивать эти исходы в один процент «готовности». Процент скрывает, что часть клиентов нельзя сопоставить, часть требует поля, а часть неизвестна. Список причин дольше, зато он подсказывает конкретную работу и не превращает неизвестность в разрешение на rollout.
\nЭтот алгоритм не обнаруживает неизвестные интеграции сам. Неполный инвентарь остаётся риском. Он также не заменяет security review, тестирование прав, нагрузочную проверку, SLO, миграцию данных, проверку идемпотентности команд и план отката. Для асинхронного API нужно дополнительно проверять порядок событий, повторную доставку и версию схемы сообщения. Для публичного API понадобятся правила deprecation и коммуникация с внешними клиентами.
\nФикстура из статьи не доказывает поведение реального сервиса. Она проверяет только заранее введённые literals и может пропустить ошибку сериализатора, конфигурации или среды. Node.js 18+ нужен лишь для воспроизведения примера; production-совместимость не зависит от одной версии Node и должна проверяться в поддерживаемых окружениях.
\nReview можно считать завершённым только для явно ограниченной пары, если в записи есть family, версия, consumer, required fields, допустимые ошибки, проверенные гарантии, список отрицательных тестов и итоговый status. Положительный status должен содержать слово «для этой фикстуры» или эквивалентную границу. Если нет карточки потребителя или неизвестно поведение обязательного поля, честный результат — stop, а не «вероятно совместимо».
\n