From f1a0228aeff6007af77ee30b05e1b7d1c0d8411e Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 14:16:16 +0300 Subject: [PATCH] editorial: revise articles 065-070 to 10/10 --- editorial/agent-rewrites/065.json | 2 +- editorial/agent-rewrites/066.json | 2 +- editorial/agent-rewrites/067.json | 6 +++--- editorial/agent-rewrites/068.json | 4 ++-- editorial/agent-rewrites/069.json | 4 ++-- editorial/agent-rewrites/070.json | 8 +------- 6 files changed, 10 insertions(+), 16 deletions(-) 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 или встречает поле другого типа. Ошибка проявляется уже на границе сервисов. Цена — отклонённые сообщения, неверные значения по умолчанию, ручная миграция и спор о том, что именно обещала версия.

\n

Тезис простой: совместимость нельзя вычислять по номеру версии, пересечению имён или удачному примеру сериализации. Сначала нужно назвать направление, две точки схемы и конкретного consumer. Затем отдельно проверить обязательную поверхность, объявленные additions и capability reader. Если хотя бы одна часть неизвестна, gate должен остановиться. Такой отказ полезнее зелёного статуса без объяснения.

\n

Что именно сравнивает gate

\n

Назовём baseline старой схемой и candidate новой схемой. В выбранном направлении фиксированный producer создаёт candidate, а фиксированный consumer читает эту форму, опираясь на baseline как на точку отсчёта. Это не единственное возможное направление. Новый reader может читать старые данные, но это уже другой вопрос и другая карточка сравнения.

\n

Минимальная запись отношения содержит пять значений: direction, family, baselineVersion, candidateVersion и consumerId. family не даёт сравнить случайные JSON-объекты только потому, что у них совпали ключи. Версии закрепляют обе точки. consumerId не позволяет заменить проверяемого reader абстрактным «клиентом». Пустое или изменённое значение даёт stop-implicit-comparison.

\n
\"Цикл
Gate проверяет отношение, форму данных, намерение producer и способность named consumer принять новую поверхность. Красная ветка сохраняет конкретную причину остановки.
\n

Три независимые проверки

\n

Первая проверка смотрит на обязательную поверхность baseline. Если required-поле исчезло из candidate или сменило тип, старый reader больше не получает обещанную форму. Например, замена state на phase может казаться переименованием с тем же смыслом. Gate не угадывает смысл имён. Для reader поле state отсутствует, поэтому результат — stop-backward-incompatible-schema.

\n

Вторая проверка смотрит на новые поля. Candidate может сохранить id и state, но добавить priority. Это не разрушает обязательную поверхность. Однако producer должен явно назвать addition в manifest. Скрытое routingHint, появившееся в candidate без записи в manifest, даёт stop-undocumented-schema-field. Gate сначала требует объяснить новую поверхность, а потом спрашивает, принимает ли её reader.

\n

Третья проверка смотрит на capability consumer. Tolerant reader может разрешать declared additions. Strict reader может отклонять неизвестные поля. Слово optional в схеме producer не меняет policy reader автоматически. Если strict consumer не принимает priority, результат — stop-incompatible-consumer. Gate не удаляет поле на лету и не придумывает adapter. Команда отдельно выбирает изменение reader, разделение формы, задержку candidate или миграцию.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Для двух схем написано compatible, но не указано направлениеОдин boolean склеил разные отношения producer и readerПроверить direction, family, baseline, candidate и consumerIdОстановить как stop-implicit-comparison и оформить relation
В candidate нет обязательного stateRequired-поле 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 вместо процента
\n

Учебный пример с фиксированными схемами

\n

Ниже — учебный JavaScript-подобный пример. Он не подключается к registry, сети, файловой системе, CI или production data. fixedCase возвращает заранее известный объект, а review выполняет только описанные проверки. Пример показывает форму решения, но не доказывает совместимость реального формата.

\n
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.

\n

Положительный учебный случай тоже ограничен. Если candidate сохраняет id и state, добавляет объявленный priority, а named reader допускает declared additions, gate может вернуть synthetic hand-off. Это означает только то, что фиксированная проверка закончилась положительно. Это не означает, что parser, registry, права, нагрузка и выпуск в реальной системе готовы.

\n

Почему порядок проверок имеет значение

\n

Если сначала спросить reader, принимает ли он неизвестные поля, tolerant policy может скрыть неописанное изменение producer. Поэтому gate сначала устанавливает отношение, затем проверяет required surface, потом сверяет manifest и только после этого проверяет capability.

\n

Так распределяется ответственность. Producer называет новую поверхность. Сравнение проверяет буквальную форму. Manifest связывает diff с намерением. Consumer описывает границу принятия. Ни один слой не подменяет другой. Если переставить шаги, зелёный результат может появиться раньше, чем команда поймёт, что именно она выпускает.

\n

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

\n
  1. Выберите одну contract family и зафиксируйте baseline и candidate.
  2. Назовите направление: какой producer пишет, какой reader читает и относительно какой точки.
  3. Запишите consumerId и остановите сравнение при неизвестной или неполной связи.
  4. Постройте карты полей и проверьте исчезновение required-полей и смену их типов.
  5. Составьте manifest всех новых полей candidate; не выводите намерение из одного sample.
  6. Сверьте фактические additions с manifest и остановите скрытые поля.
  7. Проверьте capability именно named consumer: required fields, допустимую версию и policy дополнительных полей.
  8. Верните один status, reason и next action; положительный результат назовите только synthetic hand-off.
  9. Для обратного отношения заведите отдельное сравнение, а не расширяйте текущий boolean.
\n

Отрицательный путь

\n

Нельзя считать 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.

\n

Каждый отказ должен сохранять следующий шаг. Неизвестное отношение требует уточнить карточку. Удалённое required-поле требует вернуть поверхность или назвать миграцию. Скрытый addition требует обновить описание изменения или убрать поле. Несовместимый reader требует решения владельца consumer. Общий статус «не прошёл» не даёт команде достаточного действия.

\n

Ограничения механизма

\n

Этот gate проверяет узкое отношение между фиксированными описаниями. Он не извлекает схемы из registry, не знает все deployment-версии, не проверяет реальные payloads и не подтверждает, что consumer честно описал свои потребности. Он также не решает семантическое изменение: строка state=active может сохранить тип и имя, но начать означать другой бизнес-статус.

\n

Он не заменяет contract tests, миграцию данных, нагрузочную проверку, security review, SLA и план отката. JSON Schema, JTD и Avro дают полезные понятия для формы и чтения, но не определяют статусы этого gate. Поэтому результат нужно читать узко: механизм сделал одно сравнение явным и остановил неизвестность. Он не управляет релизом.

\n

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

\n

Для выбранной пары есть заполненные family, direction, baseline, candidate и consumerId. Gate возвращает отдельные результаты для неизвестного отношения, разрушенной required surface, скрытого addition и несовместимого reader. Есть один положительный учебный случай и отрицательные случаи для каждой остановки. Каждый report содержит reason и next action. Положительный report прямо говорит synthetic hand-off и не выдаёт право на deploy.

\n

Если команда не может воспроизвести эти статусы на фиксированных входах или не знает, какой reader проверяется, критерий не выполнен. Номер версии и зелёный процент не заменяют evidence. Готовность здесь означает, что вопрос о совместимости имеет направление, named участников, отдельную причину и проверяемый следующий шаг.

\n

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

" + "contentHtml": "

Сбой на границе сервисов часто начинается с безобидного изменения: producer выпускает JSON с новым полем, а consumer продолжает читать его как прежний. Один reader игнорирует неизвестный ключ, другой отклоняет объект целиком. Если при этом поле state заменили на похожее phase, ошибка становится ещё дороже: данные формально похожи, но старый код больше не видит обязательное значение.

\n

Номер версии не отвечает на вопрос о совместимости. Нужно знать, кто пишет, кто читает, относительно какой схемы сравнивается изменение и какие правила действуют у reader. В этой статье разберём узкий compatibility gate: он сравнивает две зафиксированные формы, проверяет направление backward и останавливается, если поле, намерение producer или способность consumer неизвестны.

\n

Что означает совместимость

\n

В статье используются четыре роли. Producer формирует новую запись. Consumer получает её. Writer schema описывает форму, с которой записали данные, а reader schema — форму, которую ожидает читатель. Для простого JSON API эти понятия могут находиться в одном OpenAPI-документе, в коде валидатора и в договорённости команды, но логика вопроса остаётся той же.

\n

Назовём старую форму baseline, новую форму candidate, а направление backward определим так: reader, рассчитанный на baseline, должен принять запись candidate. Это локальное определение политики, а не универсальный смысл слова для всех платформ. Для проверки обратного сценария — новый reader читает старые данные — нужна отдельная relation. Совместимость не является симметричным boolean.

\n
Как читать направление изменения
ОтношениеЧто проверяемТипичный рискМинимальное решение
backwardСтарый reader принимает новую записьУдалено required-поле или reader отвергает новый ключСохранить обязательную поверхность и проверить policy reader
forwardНовый reader принимает старую записьНовый код ожидает поле, которого нет в старых данныхЗадать default, миграцию или период двойного чтения
fullОба отношения проходятОдно направление проверили, второе подразумевалиЗапустить две отдельные проверки и хранить их результаты
НеизвестноНельзя определить baseline, candidate или consumerЗелёный статус скрывает невыбранное отношениеОстановить сравнение и запросить недостающую связь
\n

Схема не равна смыслу

\n

JSON Schema действительно проверяет экземпляр по ограничениям вроде type, required и enum. Это полезный слой: валидатор может показать, что строка не стала числом или что обязательный ключ пропал. Но сама спецификация описывает валидность документа относительно схемы, а не направление миграции, список реальных consumer и значение слова active в конкретном бизнес-процессе.

\n

Есть и менее очевидная граница. В JSON Schema 2020-12 ключ format может быть аннотацией; обязательное assertion-поведение зависит от заявленного vocabulary и реализации. Поэтому format: date-time нельзя молча считать проверкой существования даты, часового пояса или корректности бизнес-операции. Эти свойства следует закрепить отдельным правилом приложения и проверять тем же reader, который будет использовать значение.

\n

В бинарных форматах правила могут быть другими. Avro сопоставляет writer schema и reader schema по своим правилам schema resolution. В Protocol Buffers номер поля участвует в wire format, поэтому удалённые номера нельзя бездумно переиспользовать. Эти документы полезны как официальные модели эволюции, но их ограничения нельзя переносить на произвольный JSON endpoint без проверки конкретного сериализатора.

\n
\"Схема
Проверка идёт от зафиксированного отношения к форме, описанию добавлений и возможностям конкретного consumer. Красная ветка означает, что неизвестность сохраняется как причина остановки.
\n

Четыре проверки перед положительным verdict

\n

Сначала зафиксируйте relation. В записи должны быть family, direction, baselineVersion, candidateVersion, producer и consumerId. Строка «совместимо с v2» недостаточна: неизвестно, с чьей точки зрения и для какого reader. Если любой идентификатор пуст, результат — остановка, а не допущение.

\n

Затем сохраните required surface. В выбранной политике backward каждое required-поле baseline должно остаться в candidate с совместимым типом. Переименование state в phase рассматривайте как удаление и добавление, пока старый consumer не адаптирован. Совпадение смысла в обсуждении не меняет фактического имени ключа.

\n

После этого сверяйте additions. Если candidate добавил priority, producer должен объявить его в manifest изменения. Нельзя выводить намерение из одного удачно прочитанного sample: фактический diff мог также содержать routingHint. Скрытое поле сначала нужно объяснить или убрать.

\n

Последним проверяйте capability reader. Tolerant reader пропускает дополнительные ключи, strict reader запрещает их. То, что поле optional у producer, не меняет настройки consumer автоматически. Один прошедший сервис не доказывает совместимость остальных readers; verdict нужно хранить для каждой названной пары.

\n

Воспроизводимый пример без внешних зависимостей

\n

Ниже — минимальный Node.js-скрипт. Он не обращается к registry, сети, базе или реальным сообщениям. Для воспроизводимости сохраните блок во временный файл compatibility-check.mjs и запустите node compatibility-check.mjs. Функция проверяет только требуемые поля, типы, объявленные additions и способность reader принять дополнительные ключи.

\n
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 явно допускает дополнительные поля. Положительный результат поэтому означает только прохождение перечисленных структурных правил, а не разрешение на выпуск.

\n

Отрицательные случаи важнее зелёного примера

\n

Проверка считается полезной, когда она воспроизводимо останавливает опасные варианты. Для первого запуска удалите state из candidate.required. Ожидаемый результат: reason: 'required-surface-changed'. Скрипт не объявляет phase заменой, потому что имена и семантика не угадываются.

\n

Верните state, добавьте в candidate.properties поле routingHint: 'string', но не внесите его в declaredAdded. Ожидается addition-is-not-declared. Это проверяет разницу между фактической формой и намерением изменения.

\n

Наконец, оставьте только priority и замените acceptsAdditional: true на false. Ожидается reader-rejects-additional-fields. Так видно, почему capability относится к named consumer, а не к номеру версии producer. Для неполной relation удалите consumerId и ожидайте relation-is-incomplete.

\n

Порядок работы команды

\n
  1. Выберите одну contract family и сохраните baseline с обязательными полями, типами, enum, единицами измерения и примером.
  2. Опишите candidate как отдельную версию; разделите сохранённые, добавленные, удалённые и изменившие тип поля.
  3. Назовите направление и каждого затронутого consumer. Не заменяйте список readers словом «клиенты».
  4. Составьте manifest additions и сравните его с фактическим diff схем, DTO или OpenAPI-документов.
  5. Запустите положительный и отрицательные примеры: удалённое required-поле, новый ключ без manifest, strict reader и неизвестное направление.
  6. Проверьте отложенные данные: очередь, retry, кэш, архив и отключённый клиент могут доставить старую форму после публикации candidate.
  7. Сохраните для каждой пары status, reason и nextAction. Общий итог вычисляйте только после адресных результатов.
\n

Границы применимости

\n

Такой gate проверяет ограниченную структурную поверхность. Он не находит скрытых consumers, не доказывает, что владелец честно описал свои требования, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку, задержки, размер payload или откат. Для этих вопросов нужны contract tests, интеграционные тесты, наблюдаемость и план перехода.

\n

Он также не ловит семантическую несовместимость. Строка state=active может сохранить имя и тип, но начать означать «активна подписка» вместо «активен заказ». Аналогичный риск есть у времени, денег, идентификаторов и nullable-полей: нужно явно закрепить часовой пояс, валюту, масштаб, источник и правило округления. Формально валидный JSON не делает эти значения правильными.

\n

Нельзя переносить этот результат между технологиями без адаптации. Для Avro нужно учитывать schema resolution, для protobuf — номера и reserved-поля, для JSON — поведение конкретного parser и настройку дополнительных ключей. Если схема, reader policy или направление неизвестны, единственно честное решение — STOP с запросом к владельцу контракта.

\n

Критерий готовности

\n

Пара готова к следующему этапу, когда зафиксированы baseline, candidate, direction и named consumer; обязательная поверхность не разрушена; все additions объявлены; capability reader подтверждена; пройдены положительный и отрицательные случаи; накопленные старые данные учтены. Report должен объяснять причину и следующее действие, а не сводить разные ситуации к проценту «совместимости».

\n

Если проверка не различает backward и forward, принимает пустой consumerId или пропускает новое значение enum только потому, что тип остался строковым, она отвечает не на тот вопрос. Номер 1.1 может помочь найти две точки сравнения, но не заменяет их содержимое и policy чтения.

\n

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

\n" } diff --git a/editorial/agent-rewrites/066.json b/editorial/agent-rewrites/066.json index ab54ab9..335b97c 100644 --- a/editorial/agent-rewrites/066.json +++ b/editorial/agent-rewrites/066.json @@ -3,5 +3,5 @@ "slug": "editorial-2026-03-practice-data-contracts", "title": "Изменение схемы без устных договорённостей: как проверить контракт данных", "excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.", - "contentHtml": "

Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки складывается из простоя, ручного восстановления данных и времени на поиск настоящего контракта.

\n

Проблема начинается не с синтаксиса схемы. Она начинается с неявного обещания: «новое поле необязательное, значит всё совместимо». Это утверждение неполно. Нужно назвать исходную форму, новую форму, направление чтения, producer и конкретного consumer. Только после этого можно решить, additive change это или несовместимое изменение.

\n

Тезис: версия не заменяет проверку

\n

Номер v1.1 связывает две точки во времени, но не отвечает на главный вопрос. Может ли reader, рассчитанный на baseline, принять candidate? Ответ зависит от обязательных полей, типов, дополнительных ключей и правил самого reader. Один consumer игнорирует незнакомые поля. Другой отвергает их. Одинаковый JSON для них имеет разный результат.

\n

Контракт данных — это не только схема. Это схема вместе с владельцем записи, ожидаемым reader, направлением совместимости и правилом изменения. Для практической проверки достаточно начать с одной пары: producer создаёт candidate, named consumer читает его как продолжение baseline. Остальные потребители требуют отдельных проверок.

\n

Механизм: сравнить пару, а не два файла

\n

Сначала зафиксируйте baseline — форму, которую уже читает потребитель. Затем опишите candidate — форму после изменения. В manifest перечислите добавленные, удалённые и изменённые по типу поля. Направление backward в этом материале означает: старый reader получает новую запись. Это не означает, что новый reader обязательно прочитает старую запись.

\n

Проверка должна идти в том же порядке. Сначала она убеждается, что обязательная поверхность baseline не исчезла. Затем проверяет, что каждое новое поле названо в manifest. После этого она спрашивает capability конкретного consumer: принимает ли он дополнительные ключи. Если входные данные не называют направление или consumer, проверка останавливается. Пустое сравнение нельзя считать зелёным результатом.

\n
\"Схема
Учебная иллюстрация показывает, почему additive change зависит не только от candidate, но и от правил reader. Красная ветка означает остановку до передачи изменения дальше.
\n

Минимальный пример

\n

Ниже — учебная функция. Она не подключается к registry, не читает реальные схемы и не доказывает совместимость сервиса. Её задача — сделать порядок решений видимым.

\n
const 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, миграцию базы или успешную обработку реального сообщения.

\n

Теперь измените candidate: замените state на phase. Функция вернёт stop-backward-incompatible-schema. Имена похожи, но старый consumer всё ещё ищет обязательное поле state. Не пытайтесь исправить этот результат добавлением номера версии. Здесь нужен отдельный план миграции или сохранение старого поля на период перехода.

\n

Третий случай — strict consumer. Оставьте additive candidate, но передайте acceptsAdditional: false. Результат станет stop-incompatible-consumer. Поле может быть корректным для одного reader и запрещённым для другого. Поэтому слово «optional» должно описывать не только schema declaration, но и поведение потребителя.

\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Старый 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Дать новое имя или подготовить явную миграцию значения
\n

Почему schema validation недостаточно

\n

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 без указания стороны, которая пишет, и стороны, которая читает.

\n

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

\n
  1. Зафиксируйте baseline. Укажите идентификатор и версию формы. Выпишите обязательные поля и их типы.
  2. Опишите candidate. Покажите полную новую форму, а не только короткий diff. Не меняйте baseline задним числом.
  3. Составьте manifest. Перечислите added, removed и changed. Любое поле вне списка считается неоформленным.
  4. Назовите участников. Запишите producer, конкретного consumer, family контракта и направление: backward или другое явно определённое отношение.
  5. Проверьте обязательную поверхность. Убедитесь, что candidate сохраняет required-поля baseline и их типы.
  6. Проверьте новые поля. Сверьте фактический diff с manifest. Затем проверьте parser policy named consumer.
  7. Разберите отрицательный путь. Запустите тест на удаление обязательного поля, скрытый новый ключ и strict reader. Для каждой ветки сохраните отдельную причину остановки.
  8. Передайте результат с границей. Положительный synthetic verdict передаёт change на независимый review. Он не разрешает deploy без интеграционных проверок и наблюдаемого rollout.
\n

Ограничения

\n

Учебный gate не видит неизвестных внешних клиентов. Он не проверяет кеши, очереди, сохранённые payload, SDK, базы и семантику бизнес-значений автоматически. Он также не определяет срок поддержки старой формы. Для этих вопросов нужны реальные инвентари потребителей, contract tests и план удаления.

\n

Добавление необязательного поля часто безопаснее удаления обязательного, но это не универсальное правило. Строгий parser, подпись payload или downstream-система с закрытым набором ключей превращают additive change в остановку. Не называйте поле безопасным только потому, что оно не помечено как required.

\n

Отдельный риск — изменение смысла без изменения типа. Строка amount может перейти с рублей на копейки. Timestamp может сменить timezone. Enum может получить другой смысл при том же наборе строк. Structural diff этого не докажет. Нужны доменное описание, тесты значений и проверка consumer.

\n

Критерий готовности

\n

Изменение готово к следующему review, если другая инженерная команда без устного пояснения может открыть одну карточку и ответить на пять вопросов: какая форма была baseline, какая стала candidate, что изменилось по manifest, кто читает результат и в каком направлении выполнялась проверка. Для additive change дополнительно нужен положительный тест tolerant consumer и отрицательный тест strict consumer. Для breaking change нужен отдельный migration или versioning decision.

\n

Если хотя бы один ответ неизвестен, итогом должен быть stop, а не зелёный комментарий. Такая остановка дешевле аварийного отката: она превращает неясное обещание в конкретный вопрос, который можно проверить.

\n

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

\n" + "contentHtml": "

Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки — не только ошибка парсинга: в очереди остаются сообщения, повторная доставка увеличивает нагрузку, а поиск владельца контракта съедает время.

\n

Чтобы не обсуждать совместимость на уровне догадок, нужно проверить одну конкретную пару: какая форма была исходной, какая стала новой, кто пишет данные и какой reader их читает. В этой статье разберём маршрут для JSON-подобного объекта. Он не заменяет интеграционные тесты и не объявляет изменение безопасным для неизвестных клиентов.

\n

Главный вопрос: кто читает новую запись

\n

Версия v1.1 сама по себе ничего не гарантирует. Старый reader может игнорировать незнакомые поля, а может использовать строгую проверку и отклонять их. Один и тот же candidate поэтому совместим с одним consumer и несовместим с другим.

\n

Дальше под backward compatibility будем понимать одно проверяемое направление: старый reader получает запись, созданную новой схемой. Это определение относится только к выбранной паре. Оно не доказывает обратное направление, совместимость SDK, сохранённых сообщений или других потребителей.

\n

Сначала зафиксируйте четыре артефакта

\n

Baseline — форма, которую уже принимает named consumer. Candidate — полная форма после изменения. Manifest — явный список добавленных, удалённых и изменённых полей. Наконец, поведение consumer — правила его парсера: допускает ли он дополнительные ключи и как обрабатывает неизвестные значения.

\n

Нельзя подменять baseline коротким описанием вроде «добавили priority». Если одновременно изменились тип amount, enum state или единицы измерения, короткая заметка скроет breaking change. Полный candidate и diff должны быть доступны тому, кто будет читать запись после выпуска.

\n
\"Схема
Проверяется не только новая схема, но и её отношение к известному reader. Неописанное поле routingHint останавливает поток до передачи изменения в review.
\n

Минимальный воспроизводимый gate

\n

Ниже — самостоятельный пример на Node.js без сторонних пакетов. Сохраните код в файл contract-gate.mjs и запустите командой node contract-gate.mjs. Внутренняя модель намеренно мала: она проверяет имена, типы, обязательность, manifest и способность consumer принимать дополнительные ключи.

\n
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.

\n

Пример можно проверить на чистой машине с Node.js 18 или новее: node --version покажет установленную версию, а node contract-gate.mjs выведет три объекта в консоль. Скрипт не обращается к сети, registry или вашему сервису, поэтому его положительный результат относится только к указанным данным.

\n

Как читать результат проверки

\n
РезультатЧто установленоСледующий шагГраница вывода
compatible-for-named-readerСхемы и manifest совпали, reader допускает additionЗапустить contract/integration tests и проверить rolloutНеизвестные consumer и семантика значений не проверены
stop-required-field-removedВ candidate исчезло обязательное поле baselineСохранить поле на период миграции или версионировать контрактПереименование не исправляется номером версии
stop-consumer-rejects-additional-fieldsReader не принимает добавленные ключиИзменить reader, изолировать новый контракт или дождаться миграцииСвойство optional в схеме не меняет parser policy
stop-manifest-mismatchФактический diff шире заявленногоИсправить candidate или manifest и повторить проверкуGate не выясняет смысл незаявленного поля
stop-field-definition-changedИзменился тип или признак обязательностиСделать отдельный migration plan и тесты значенийДаже тот же JSON-тип может скрывать смену единиц
\n

Почему одной проверки схемы мало

\n

JSON Schema отвечает на вопрос о валидности экземпляра относительно набора ограничений. В спецификации 2020-12 есть структурные ключевые слова type, required и additionalProperties. Они помогают описать форму объекта и правила дополнительных ключей, но не знают, какой сервис владеет полем и какой reader будет обрабатывать запись.

\n

Это видно и по JSON Type Definition (JTD). RFC 8927 разделяет properties и optionalProperties, а режим дополнительных свойств задаётся отдельно. Такой словарь дисциплинирует схему, но не выбирает срок поддержки старой формы и не проверяет ваш parser.

\n

В Apache Avro терминология writer schema и reader schema встроена в механизм разрешения схем. Спецификация описывает, как reader сопоставляет поля, что происходит с отсутствующим полем и когда возникает ошибка. Для обычного JSON API правила Avro автоматически не применяются, но сам способ постановки вопроса полезен: всегда указывайте обе стороны чтения и записи.

\n

Структурное изменение и изменение смысла

\n

Добавление необязательного поля часто проще, чем удаление обязательного. Но это не универсальное правило. Строгий parser, подписанный payload, whitelist ключей или downstream-система с фиксированным форматом могут отклонить additive change.

\n

Опаснее всего изменение смысла без изменения типа. amount мог быть суммой в рублях, а стал суммой в копейках. Строковый timestamp мог перейти из локального времени в UTC. Enum state=ready мог означать «готов к отправке», а после изменения — «готов к оплате». Structural diff такие изменения не докажет. В manifest нужны единицы, timezone, допустимые значения и ссылка на владельца доменного смысла.

\n

Если контракт использует JSON Schema, структурную валидацию можно выполнять отдельно, например через выбранный валидатор в CI. Его настройки должны быть зафиксированы: разные библиотеки могут по-разному трактовать форматные аннотации. Проверка format: date-time не заменяет проверку бизнес-часового пояса и срока действия события.

\n

Порядок действий перед изменением

\n
  1. Назовите пару. Запишите producer, конкретного consumer и направление проверки. Слова «все клиенты» недостаточно.
  2. Снимите baseline. Сохраните идентификатор схемы, обязательные поля, типы, enum, единицы и правила дополнительных ключей.
  3. Опишите candidate. Покажите полную новую форму. Не редактируйте baseline задним числом, иначе diff потеряет исходную точку.
  4. Составьте manifest. Перечислите added, removed и changed. Для изменения смысла добавьте текстовое правило и владельца.
  5. Проверьте структуру. Сверьте required-поля и типы. Отдельно проверьте зависимости полей, enum и дополнительные ключи.
  6. Запустите отрицательные тесты. Удалите обязательное поле, добавьте незаявленный ключ, включите strict consumer и измените тип. Каждый сценарий должен остановиться с понятной причиной.
  7. Проверьте реальный reader. Выполните contract test на версии consumer, которая будет читать запись после rollout. Учебный gate не заменяет такой тест.
  8. Согласуйте удаление. Для breaking change укажите период dual-read/dual-write, миграцию сохранённых сообщений и условие, при котором старое поле можно убрать.
  9. Наблюдайте выпуск. После deploy сравните ошибки валидации, долю отвергнутых сообщений и lag очереди с baseline. При росте ошибок остановите rollout.
\n

Ограничения применимости

\n

Описанный gate проверяет только одну форму объекта и одного named consumer. Он не обнаружит неизвестных внешних клиентов, старые записи в очереди, кэшированные ответы, сгенерированные SDK, схемы в базе или трансформации промежуточного сервиса. Для этого нужен инвентарь потребителей и тесты на реальные границы системы.

\n

Положительный результат не является самостоятельным разрешением на deploy. Нужны проверка авторизации, размер payload, подпись, порядок событий, повторная доставка, таймауты и наблюдаемость. Эти свойства не следуют из JSON Schema и не выводятся из номера версии.

\n

Наконец, не называйте изменение backwards-compatible, если вы проверили только новый reader на старой записи. Это другое направление. Если продукт требует оба направления, проведите две отдельные проверки и запишите их результаты в manifest.

\n

Критерий готовности

\n

Перед review другая команда должна без устного пояснения ответить на пять вопросов: какая форма является baseline, что изменилось в candidate, совпадает ли фактический diff с manifest, кто читает новую запись и какое правило дополнительных ключей действует у reader. Если хотя бы один ответ неизвестен, результат проверки — остановка и уточнение контракта.

\n

Такой порядок не делает изменение автоматически безопасным. Он делает риск видимым: структурную ошибку можно поймать до выпуска, несовместимый parser — проверить на named consumer, а смену бизнес-смысла — вынести в отдельное решение. Именно эта граница превращает сообщение «мы добавили одно поле» в проверяемое инженерное изменение.

\n

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

" } diff --git a/editorial/agent-rewrites/067.json b/editorial/agent-rewrites/067.json index 916193f..5095994 100644 --- a/editorial/agent-rewrites/067.json +++ b/editorial/agent-rewrites/067.json @@ -1,7 +1,7 @@ { "index": 67, "slug": "editorial-2026-02-field-resilience", - "title": "Когда retry усиливает отказ: как ограничить каскад запросов", - "excerpt": "Медленный внешний API превращает повторы, fallback и репликацию в новый источник нагрузки. Разбираем, где поставить единую границу, как проверить трассу и когда остановиться.", - "contentHtml": "

Внешний API начинает отвечать за 3 секунды вместо 200 миллисекунд. Ваш сервис не падает сразу: он повторяет запрос, пробует другую реплику и запускает fallback. Через минуту очередь растёт, рабочие потоки заняты ожиданием, а внутренние запросы получают таймауты. Ошибка внешней зависимости превращается в отказ собственного приложения.

\n

Цена каскада состоит не только из лишних запросов. Команда теряет связь между исходным запросом и его повторами. Логи показывают несколько похожих ошибок. Метрики смешивают первичную работу и повторную. Пользователь получает задержку вместо ответа, а перегруженный сервис продолжает принимать новую работу. Если система не знает, где остановиться, каждая защитная мера увеличивает масштаб отказа.

\n

Главный тезис прост: retry, fallback и репликация должны подчиняться одному явному бюджету. Его нужно применять до расширения маршрута. У повторной попытки должен быть один владелец, у fan-out — целочисленный предел, у fallback — имя и конечный результат. Когда бюджет исчерпан, система должна выполнить terminal action. Она не должна незаметно создавать ещё один уровень попыток.

\n
\"Каскад
Единая граница отделяет ограниченное восстановление от нового витка нагрузки. Красная ветка заканчивается окончательным действием, а не очередной попыткой.
\n

Как возникает усиление отказа

\n

Представим два логических запроса. Для каждого сервис делает первичный вызов и разрешает одну дополнительную попытку. Если каждый слой владеет retry, число физических вызовов растёт быстрее, чем ожидает владелец верхнего слоя. Edge может повторить вызов адаптера, а адаптер — повторить тот же вызов внешнего API. В результате одна ошибка получает два независимых счётчика.

\n

Репликация добавляет ширину. Правило «попробовать все доступные реплики» не ограничивает работу. При задержке или частичном отказе оно запускает несколько запросов до того, как первый результат станет понятен. Fallback добавляет ещё одну ветку. Если fallback сам умеет повторять вызов, его нельзя считать запасным результатом: это второй retry-контур.

\n

Ограниченный маршрут устроен иначе. Edge владеет двумя попытками. Каждая попытка выбирает одну реплику. Fallback получает управление только после именованного исхода, например `optional-result-unavailable`, и не создаёт retry. После второй попытки система возвращает заранее определённый деградированный результат или явно отказывает. Такая схема не обещает восстановить полный ответ. Она ограничивает стоимость отказа и оставляет понятную трассу.

\n

Что должно быть названо в контракте

\n

Сначала назовите отказ. `temporary-timeout` отличается от ошибки контракта или отказа авторизации. Повтор допустим только для исходов, для которых владелец операции подтвердил безопасность и полезность повторения. HTTP 503 сообщает о временной неспособности обработать запрос, но сам по себе не доказывает, что конкретную операцию можно безопасно повторить. То же относится к заголовку `Retry-After`: он передаёт подсказку о времени, но не назначает владельца retry.

\n

Затем назовите владельца. В системе может быть несколько компонентов, которые технически способны повторять запрос. Это не значит, что каждый должен это делать. Зафиксируйте один слой, его `maxAttempts`, список повторяемых исходов и момент, когда он прекращает работу. Если два слоя имеют `enabled: true`, проверьте их совместно: верхний повтор может повторять уже повторённую работу.

\n

После этого назовите предел маршрута. `maxFanout: 1` означает, что одна попытка выбирает одну реплику. Список из трёх реплик не даёт права обращаться ко всем трём одновременно. Если предел не задан, его нельзя вывести из количества имён в списке. Неявный предел не является защитой.

\n

Последним назовите terminal action. Это может быть деградированный ответ, ошибка с понятным кодом или сохранение результата частичной операции. Он зависит от предметной области. Для операции с финансовым побочным эффектом нельзя бездумно возвращать «неполный успех». Важен сам принцип: после terminal action нет скрытого retry и нового fallback.

\n

Пример ограниченного маршрута

\n

Ниже приведён самодостаточный JavaScript-пример. Он работает только с переданным объектом и не вызывает сеть. Числа показывают форму контракта, а не рекомендуемые значения для production. Перед переносом в сервис их нужно заменить правилами конкретной операции и подтвердить безопасность повтора.

\n
const 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

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

\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, число запросов и исход отказаРазделить сценарии и не усреднять результаты
\n

Порядок проверки

\n
  1. Зафиксируйте один логический запрос и его ожидаемый результат. Отделите его от физических вызовов к зависимостям.
  2. Назовите failure injection: цель, исход и область действия. Не заменяйте конкретный timeout общим словом «сбой».
  3. Найдите всех владельцев retry. Оставьте один слой, если операция не требует другой схемы с доказанным бюджетом.
  4. Задайте `maxAttempts` и список `retryable` outcomes. Для каждого исхода проверьте идемпотентность и смысл повтора.
  5. Опишите реплики и `maxFanout`. Убедитесь, что одна попытка выбирает не больше разрешённого числа направлений.
  6. Опишите fallback: имя, trigger, результат и отсутствие собственного retry. Если ветка делает несколько вызовов, вынесите её в отдельный ограниченный контракт.
  7. Поставьте limit point до расширения маршрута. Запишите, что происходит при исчерпании бюджета.
  8. Соберите trace из событий одного logical request. Каждый новый вызов должен иметь причину, владельца и номер попытки.
  9. Повторите отрицательные сценарии: второй retry owner, пустой fallback, `maxFanout: null`, отсутствующий limit и несовпадающий baseline.
  10. Сверьте результат с тем же сигналом, который обнаружил проблему. Не заменяйте проверку заявлением о будущей эффективности.
\n

Отрицательный путь важнее зелёного примера

\n

Ограничение считается рабочим только тогда, когда оно останавливает неправильные конфигурации. Включите retry у edge и adapter одновременно. Проверка должна вернуть статус о нескольких владельцах, а не выбрать один молча. Удалите имя fallback. Результатом должна стать остановка с причиной, а не переход к безымянному ответу. Замените `maxFanout: 1` на `null`. Проверка обязана остановить сценарий до выбора реплик.

\n

Удалите limit point. Не подставляйте его из `maxAttempts`: это разные свойства. `maxAttempts` ограничивает конкретный счётчик попыток. Limit point отвечает за порядок: бюджет должен быть проверен до того, как начнётся новое расширение маршрута. Если сценарий использует семь логических запросов вместо двух или другой failure injection, его нельзя сравнивать с базовым примером. Сначала выровняйте входы, затем сравнивайте trace.

\n

Отдельно проверьте постоянную ошибку контракта. Её нельзя автоматически считать временным timeout. Повтор не исправит несовместимую схему и может увеличить нагрузку. Для неё нужен другой путь: быстрый отказ, карантин сообщения или согласованная миграция. Универсальный retry по любому статусу — частая причина каскада.

\n

Что покажет трасса

\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

Ограничения применимости

\n

Описанный механизм не выбирает оптимальный timeout. Он не знает пропускную способность сервиса, размер очереди, стоимость подключения, deadline клиента и долю ошибок зависимости. Маленький `maxAttempts` может быть правильным для одного чтения и опасным для другой операции. Значение нужно выводить из контракта и capacity модели, а не копировать из примера.

\n

Механизм не подтверждает идемпотентность. Повтор чтения обычно отличается от повтора платежа, создания заказа или отправки письма. Если запрос мог изменить состояние, сначала определите ключ идемпотентности и границу подтверждения. При отсутствии такого контракта безопаснее остановиться, чем включить retry ради доступности.

\n

Механизм не заменяет отмену in-flight работы. Если верхний слой уже вернул terminal action, нижний вызов может продолжать занимать соединение. Нужны deadline, cancellation и проверка поведения клиента. Также отдельной проверки требуют circuit breaker, rate limit, очередь и политика деградации. Единый бюджет не устраняет эти компоненты, но не даёт им бесконтрольно складывать новые попытки.

\n

Примеры в статье фиксируют значения только для объяснения механизма. Они не содержат production-трафик, реальные latency, измерения восстановления или обещания SLA. Нельзя писать в отчёте «сервис выдерживает отказ» только потому, что объект прошёл проверку. Доказательство требует воспроизводимого сценария в целевой системе и согласованного критерия результата.

\n

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

\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

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

" + "title": "Retry под нагрузкой: как остановить каскад запросов", + "excerpt": "Медленная зависимость сама по себе не валит сервис: его валят неограниченные повторы, fan-out и поздний fallback. Разбираем бюджет попыток, проверку трассы и безопасную деградацию.", + "contentHtml": "

Внешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.

\n

Ниже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.

\n
\"Один
Одна логическая операция должна иметь видимую границу: повтор, выбор направления, fallback и окончательное действие не могут бесконечно порождать новые вызовы.
\n

Сначала отделим симптом от механизма

\n

Симптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.

\n

Полезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, read-01 получает temporary-timeout от primary-a, один раз повторяется на primary-b и получает ok. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется primary-c, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.

\n

Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает Retry-After как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.

\n

Считаем физическую работу

\n

Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.

\n

Для проектирования разделите бюджет на четыре поля:

\n\n

Бюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.

\n

Retry, fallback и hedging — разные операции

\n

Retry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.

\n

Официальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации maxAttempts задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.

\n

У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом retryEnabled: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.

\n

Контракт: исход, deadline и идемпотентность

\n

Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, PUT и DELETE, но допускает повтор POST только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.

\n

Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.

\n

Укажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте logical_id, attempt, owner, outcome, выбранное направление и причину остановки.

\n

Воспроизводимый пример без сети

\n

Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.

\n
node <<'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 присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.

\n

Как читать трассу

\n

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

\n

Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.

\n

Матрица симптомов и действий

\n
Диагностика каскада по одному logical request
НаблюдениеПроверяемая гипотеза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Повторить оба запуска на одинаковой нагрузке
\n

Порядок безопасной проверки

\n
  1. Опишите один логический запрос и допустимый результат. Не смешивайте его с количеством сетевых вызовов.
  2. Назовите failure injection: зависимость, исход, длительность и область действия.
  3. Найдите всех владельцев retry в клиенте, SDK, адаптере, gateway и очереди.
  4. Для каждого retryable исхода проверьте идемпотентность или ключ, который защищает повтор.
  5. Задайте общий deadline, maxAttempts, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.
  6. Ограничьте fan-out на одну попытку. Для hedging отдельно проверьте отмену проигравших вызовов.
  7. Опишите fallback и terminal action. После terminal action не должно быть скрытого retry.
  8. Добавьте в trace logical_id, attempt, owner, outcome, направление и причину остановки.
  9. Выполните положительный и отрицательный сценарии: восстановление, fallback, исчерпание бюджета, второй retry owner и неограниченный fan-out.
  10. Сравните результат с тем же сигналом, который обнаружил проблему: количество физических вызовов, очередь, latency и долю ошибок.
\n

Что нельзя обещать по этому примеру

\n

Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.

\n

Равным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. Retry-After следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.

\n

Fallback не всегда безопаснее ошибки. Урезанный результат подходит для необязательного блока, но может скрыть устаревшие или неполные данные. Для критичной команды лучше вернуть явный отказ и сохранить её для повторной обработки с идемпотентным ключом. Политику деградации должен принять владелец продукта и операции, а не библиотека retry.

\n

Наконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.

\n

Критерий готовности

\n

Для каждого класса операций есть заполненные owner, retryable outcomes, maxAttempts, deadline, backoff, maxFanout, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.

\n

Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.

\n

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

" } diff --git a/editorial/agent-rewrites/068.json b/editorial/agent-rewrites/068.json index 08e52e1..a67a73b 100644 --- a/editorial/agent-rewrites/068.json +++ b/editorial/agent-rewrites/068.json @@ -2,6 +2,6 @@ "index": 68, "slug": "editorial-2026-02-mechanism-resilience", "title": "Механика устойчивости: сначала ограничьте каскад, потом настраивайте retry", - "excerpt": "Timeout не ограничивает объём работы. Устойчивый маршрут задаёт одного владельца retry, конечный fan-out, именованный fallback и точку остановки до расширения каскада.", - "contentHtml": "

Внешний сервис начинает отвечать медленно. На входе растёт очередь. Gateway повторяет запрос по timeout, адаптер повторяет его ещё раз, а выбор реплики отправляет работу на несколько узлов. Каждый механизм выглядит разумно отдельно. Вместе они увеличивают поток к уже перегруженной зависимости. Пользователь получает задержку или ошибку. Оператор видит несколько причин и не знает, где остановить цепочку. Цена ошибки — не один лишний запрос. Это занятые соединения, память под незавершённые операции и потеря мощности именно в момент отказа.

\n

Тезис статьи простой: устойчивость начинается с границы дополнительной работы. Сначала нужно определить логический запрос, одного владельца retry, максимальное число попыток, ширину выбора реплики и результат после исчерпания бюджета. Только после этого имеют смысл timeout, backoff и fallback. Если каждый слой может запустить следующую попытку, система не имеет одного механизма восстановления. Она имеет усилитель нагрузки.

\n

Ниже используется учебная fixed-модель. В ней два логических запроса, один именованный временный timeout, две попытки на запрос, fan-out равен одному и fallback имеет одну условную единицу работы. Модель не открывает сеть, не измеряет latency, не знает реальных реплик и не подтверждает production-устойчивость. Она проверяет только структуру решения и умеет остановиться, когда структура нарушена.

\n
\"Матрица
Учебная матрица разделяет исход, разрешённое действие, добавленную работу и точку остановки. Она не описывает топологию настоящего сервиса.
\n

Механизм: считать логическую работу

\n

Считать нужно не только HTTP-запросы. Единица анализа — логический запрос пользователя или вызывающего сервиса. Он может породить несколько исходящих действий. Для каждого действия запишите причину запуска и право запускать следующее действие. Это сразу разделяет последовательный retry и fan-out.

\n

Retry запускает новый вызов после определённого исхода. Fan-out создаёт несколько направлений для одной попытки. Репликация задаёт множество доступных имён, но не обязана выбирать их все. Fallback меняет контракт результата. Он может вернуть неполный ответ, но не должен незаметно создавать собственную политику повторов. Limit point запрещает следующий переход. Если он срабатывает после fan-out, он уже не ограничивает первую волну работы.

\n
Величины, которые должны иметь отдельную границу
ВеличинаУчебное значениеЧто проверяетОшибка при смешении
logical requests2единицу сравнениясравнение разных объёмов работы
retry owneredgeкто имеет право повторить вызовдва слоя запускают вложенные повторы
max attempts2конечный предел попытокretry превращается в цикл
max fan-out1одно имя реплики за попыткуодин запрос размножается по пулу
fallback work1стоимость деградированного результатазапасной путь считают бесплатным
limit pointдо route expansionмомент запрета следующего действиясчётчик фиксирует проблему постфактум
\n

В bounded-cascade-v1 есть три возможных имени реплики, но на одну попытку выбирается только одно. Для двух логических запросов и максимум двух попыток верхняя граница учебной работы равна четырём route attempts. Это не QPS, не прогноз CPU и не оценка времени ответа. Она нужна, чтобы проверить, что новая защита не добавила скрытую ветвь.

\n

Пример и отрицательный путь

\n

Положительный сценарий начинается с logical-01. Он обращается к primary-a и получает temporary-timeout. Единственный владелец retry, edge, списывает одну попытку. Следующий вызов идёт к primary-b. Второй логический запрос получает fixed-optional-result-unavailable; вместо нового поиска он возвращает named-summary с результатом fixed-degraded-summary. У fallback нет своего retry. После нулевого бюджета limit point возвращает фиксированный деградированный результат и не расширяет маршрут.

\n
const 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

Симптомы и проверка

\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 или объявить сравнение недействительным
\n

Почему timeout и статус ошибки не решают задачу

\n

Timeout отвечает на вопрос «сколько ждать этот вызов». Он не отвечает на вопрос «кто имеет право создать следующий». Короткий timeout может даже повысить нагрузку, если каждый слой интерпретирует его как разрешение на повтор. Статус 503 сообщает, что сервис временно не готов обработать запрос, но не выбирает retry owner и не доказывает идемпотентность действия. Retry-After задаёт подсказку для времени ожидания, а не общий бюджет каскада.

\n

Backoff тоже не является лимитом. Он раздвигает попытки во времени, но оставляет их количество и владельца. Если несколько слоёв применяют независимый backoff, суммарное число переходов остаётся неясным. Поэтому сначала фиксируйте право и число попыток. Затем выбирайте расписание. Для операций с побочными эффектами отдельно проверяйте идемпотентность и компенсацию. В этой учебной модели таких эффектов нет.

\n

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

\n
  1. Назовите логический запрос и перечислите все исходящие действия, которые он может породить.
  2. Назначьте одного владельца retry. Запретите остальным слоям запускать второй цикл.
  3. Назовите retryable outcomes и задайте конечное число попыток.
  4. Запишите доступные реплики, но ограничьте число выбранных имён на одну попытку.
  5. Опишите fallback как отдельный результат с именем, условием и стоимостью.
  6. Поставьте limit point до retry, fallback и route expansion, если эти переходы создают новую работу.
  7. Добавьте trace transition для расхода бюджета и terminal action после нуля.
  8. Прогоните положительный и отрицательный fixed-сценарии на одинаковой логической нагрузке.
  9. Только после этого перенесите контракт в настоящий тест с безопасными данными, отменой и наблюдением.
\n

Ограничения модели

\n

Модель не знает о реальной ёмкости, очередях, дедлайнах, cancellation, сетевых сбоях, распределённом состоянии, правах доступа или пользовательском ущербе. Числа два и один выбраны для учебной проверки. Их нельзя переносить в конфигурацию сервиса. Положительный статус означает, что fixed-карточка удовлетворяет формальным ограничениям. Он не означает availability, recovery time, безопасный rollout или допустимую бизнес-деградацию.

\n

Отдельно ограничен сам способ сравнения. Нельзя сопоставлять сценарий с двумя логическими запросами со сценарием с другой нагрузкой и делать вывод о причине. Нельзя менять тип failure injection и сохранять прежний baseline. Нельзя считать trace доказательством скорости: trace показывает порядок и факт переходов, а latency требует измерения. Если один из этих фактов неизвестен, правильный результат — остановка и новый вопрос, а не расширение предположений.

\n

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

\n

Механизм готов к отдельному инженерному тесту, если для одного фиксированного сценария можно показать пять вещей: один retry owner; конечный maxAttempts; числовой maxFanout; fallback с именем, условием и запретом вложенного retry; limit point до расширения маршрута. Trace должен показывать расход бюджета и один terminal action после его исчерпания. Тест должен пройти положительный record и отклонить retry-amplification-v1 с причиной, которую можно прочитать без догадки.

\n

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

\n

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

\n" + "excerpt": "Timeout ограничивает ожидание одного вызова, но не объём дополнительной работы. Разбираем, как связать retry, fan-out, fallback и точку остановки в одну проверяемую модель.", + "contentHtml": "

Внешний сервис начинает отвечать медленно, очередь на входе растёт, а собственное приложение расходует соединения на незавершённые запросы. Gateway повторяет вызов после timeout, адаптер делает ещё одну попытку, а балансировщик параллельно отправляет работу на несколько реплик. Каждый механизм по отдельности выглядит разумно. Вместе они могут превратить временный сбой в каскад: перегруженная зависимость получает новые запросы именно тогда, когда ей нужно уменьшить нагрузку.

\n

Главный вопрос здесь не «какое значение поставить для timeout». Сначала нужно определить, сколько дополнительной работы может породить один логический запрос, кто имеет право повторять вызов и где маршрут обязан остановиться. Только после этого выбирают задержку между попытками, реакцию на 503 и режим деградации. Иначе разные слои незаметно складывают свои политики: три попытки на клиенте и три на gateway дают до девяти вызовов одной зависимости ещё до учёта fan-out.

\n

Далее используется маленькая фиксированная модель. Она считает только структуру маршрута: два логических запроса, максимум две попытки на каждый и один выбранный адрес за попытку. В ней нет сети, часов, очереди или измерения latency. Такой пример помогает увидеть границу и проверить отрицательный путь, но не подбирает значения для настоящего сервиса.

\n
\"Матрица
Схема показывает порядок принятия решения: сначала ограничивается новая работа, затем выбирается допустимый результат. Это логическая модель, а не карта конкретной инфраструктуры.
\n

Логический запрос, попытка и fan-out — разные величины

\n

Единица расчёта — логический запрос пользователя или вызывающего сервиса. Один такой запрос может породить несколько исходящих вызовов. Попытка — один запуск зависимости; повтор добавляет следующую попытку после разрешённого исхода. Fan-out, или ширина разветвления, отвечает на другой вопрос: сколько направлений запускаются для одной попытки. Список из пяти реплик сам по себе не означает пять вызовов.

\n

Fallback также нельзя смешивать с retry. Он меняет результат: вместо полного ответа возвращает, например, короткую сводку из локального кеша. Если fallback сначала ищет данные в ещё трёх местах или запускает собственный retry, это уже новая работа, которую надо включить в расчёт. Точка остановки задаёт момент, после которого никакая ветвь не имеет права расширить маршрут.

\n
Минимальный контракт фиксированного сценария
ВеличинаЗначение в примереЧто она ограничиваетЧто не следует из значения
Логические запросы2Объём сравниваемой нагрузкиРеальный QPS и размер очереди
Владелец retrygatewayЕдинственное право создать повторПравильность выбора самого gateway
Максимум попыток2, включая исходнуюПоследовательное размножение вызововБезопасность повторения операции
Максимальный fan-out1Число адресов на одну попыткуДоступность и равномерность реплик
Fallbackcached-summary, 1 work unitЦена сокращённого результатаЕго полезность для продукта
Точка остановкидо route expansionЗапуск новых ветвей после нулевого бюджетаОтмену уже начатого сетевого вызова
\n

Для верхней границы в этой игрушечной схеме достаточно перемножить независимые множители: 2 логических запроса × 2 попытки × 1 направление = 4 route attempts. Это число не является прогнозом нагрузки. Оно отвечает на узкий вопрос: не появилась ли в конфигурации скрытая ветвь, которой нет в контракте.

\n

Где возникает усиление нагрузки

\n

Представим один вызов с временным отказом. Gateway ждёт до своего deadline и создаёт второй вызов. Если адаптер внутри gateway тоже считает тот же отказ разрешением на повтор, один внешний retry превращается ещё в несколько внутренних. Если оба слоя выбирают две реплики, число запросов растёт дополнительно. При этом исходная причина — перегрузка или задержка — никуда не исчезает.

\n

У политики должен быть один владелец. Остальные слои могут передавать deadline, отмену и контекст попытки, но не должны тайно запускать собственный цикл. Если владельцев несколько по архитектурной необходимости, это надо считать произведением и ограничивать как отдельный контракт. Фраза «везде всего по две попытки» не описывает верхнюю границу, пока не указано, где заканчивается одна операция и начинается другая.

\n
const 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.

\n

Timeout, 503 и Retry-After не задают всю политику

\n

Timeout ограничивает время ожидания конкретного вызова. Он не сообщает, кто может повторить этот вызов и сколько дополнительной работы разрешено создать. Слишком короткое значение иногда увеличивает нагрузку: слой объявляет вызов неуспешным, пока зависимость продолжает обрабатывать его, а затем отправляет копию. Поэтому deadline должен распространяться по цепочке и учитывать отмену, а не превращаться в независимый таймер на каждом уровне.

\n

HTTP 503 означает, что сервер временно не может обработать запрос; RFC 9110 допускает заголовок Retry-After как подсказку, сколько подождать перед следующим запросом. Это семантика ответа, а не готовое решение о повторе. Клиенту всё равно нужно сопоставить статус с идемпотентностью операции, остатком общего бюджета и допустимым результатом. Ошибку валидации или неверный запрос нельзя «лечить» повтором: одинаковый вход снова даст тот же постоянный исход.

\n

Backoff снижает вероятность синхронной волны повторов, но не ограничивает их количество. Для повторов нужен конечный максимум, а для всего процесса — наблюдаемый budget. В качестве отдельных сигналов полезно видеть исходные вызовы, повторы, время ожидания, отмены и переход в fallback. Одна метрика ошибок без этих разрезов не показывает, что именно увеличивает нагрузку.

\n

Fallback должен быть дешёвым и именованным

\n

Режим деградации не означает «вернуть что-нибудь». Он меняет контракт для пользователя, поэтому его результат должен иметь имя, условие включения и понятную цену. В примере cached-summary возвращает неполную сводку после исчерпания попыток. Он не выбирает новые реплики и не запускает retry. Продуктовая команда отдельно решает, допустима ли такая сводка для конкретного экрана.

\n

Проверяйте fallback как самостоятельный маршрут. Запишите его work units: чтение локального кеша, обращение к резервному хранилищу и сериализация ответа не обязательно стоят одинаково. Если резервный путь дороже основного или тоже зависит от перегруженного сервиса, его нельзя считать безопасной деградацией. Иногда правильный fallback — немедленный отказ с понятным кодом, а не ещё один поиск.

\n

Точка остановки должна стоять до расширения

\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 «выглядит правильнее». Проверяйте порядок событий, а не только итоговый счётчик.

\n

Симптомы, проверка и действие

\n
Как отличить неисправную границу от нормальной деградации
Наблюдаемый симптомГипотезаВоспроизводимая проверкаОграниченное действие
После 504 исходящих вызовов становится большеПовтор включён на двух слояхСопоставить retry owner в конфигурации и traceОставить один цикл, остальные слои сделать pass-through
Один request_id встречается на нескольких репликах сразуВключён fan-out или hedgingПосчитать адреса до первого успешного ответаЗадать maxFanout и отдельно доказать безопасность копий
Fallback задерживает ответОн сам ищет данные и повторяет запросРазвернуть его переходы и work unitsУпростить до дешёвого результата или отключить режим
Лимит срабатывает, но нагрузка уже вырослаПроверка стоит после route expansionСравнить порядок trace transitionsПеренести точку остановки перед созданием ветвей
Два теста дают разные выводыРазличаются нагрузка или тип сбояСверить logical load и failure injectionСравнивать только одинаковые входы либо признать тесты несопоставимыми
\n

Как перенести модель в настоящий сервис

\n
  1. Назовите границу логического запроса: где он начинается и какой слой возвращает окончательный результат.
  2. Найдите все места, где код или библиотека может повторить вызов, и назначьте одного владельца политики.
  3. Перечислите retryable исходы. Для каждого проверьте идемпотентность, deadline и влияние уже начатой работы.
  4. Задайте конечный максимум попыток, причём явно укажите, входит ли исходный вызов в это число.
  5. Отдельно ограничьте fan-out и hedging. Не считайте число адресов в пуле разрешённым числом одновременных вызовов.
  6. Опишите fallback как контракт результата с именем, условием, стоимостью и отсутствием скрытого retry.
  7. Поставьте limit point до выбора новой ветви и добавьте terminal action после исчерпания бюджета.
  8. Проверьте положительный и отрицательный сценарий с одинаковой логической нагрузкой и одинаковым типом отказа.
  9. Запустите нагрузочный и отказоустойчивый тест в среде, где разрешены безопасные данные и есть метрики очереди, соединений, отмен и задержек.
\n

Ограничения и границы применимости

\n

Формула из примера проверяет только число потенциальных маршрутных попыток. Она не учитывает время, параллелизм, уже выполняющиеся запросы, размер ответа, пропускную способность, rate limit, очередь, кэш-промахи и отмену на сервере. Четыре попытки в модели могут быть четырьмя последовательными вызовами или четырьмя одновременными — эти режимы имеют разный риск.

\n

Числа 2 и 1 выбраны для ручной проверки, а не для переноса в конфигурацию. В реальном сервисе предел зависит от стоимости операции, класса данных, SLO, ёмкости зависимости и допустимой потери результата. Для записи, платежа или другой операции с побочным эффектом одного timeout недостаточно, чтобы признать повтор безопасным. Нужны идемпотентный ключ, семантика сервера и проверка компенсации.

\n

Не следует превращать статус ok из скрипта в обещание доступности. Он означает только, что фиксированная карточка удовлетворяет нескольким формальным условиям. Доказательство устойчивости требует измерения под нагрузкой, наблюдения за отменами и проверки поведения при частичном отказе. Если факт неизвестен, модель должна остановиться и назвать недостающую проверку.

\n

Критерий готовности решения

\n

Схема готова к инженерному испытанию, если можно без догадки ответить на пять вопросов: кто владеет retry; сколько попыток разрешено и как они считаются; сколько направлений может появиться на попытку; что именно возвращает fallback и сколько работы он добавляет; где прекращается создание новых ветвей. Для каждого ответа должен существовать trace или тестовый assert. Отдельно зафиксируйте, что в этом доказательстве не проверяется: реальная ёмкость, пользовательская ценность деградации и безопасность побочных эффектов.

\n

Порядок важнее набора терминов. Сначала ограничьте дополнительную работу и сделайте её видимой. Затем настройте timeout, backoff и протокол ответа под измеренный сценарий. Такой маршрут не отменяет нагрузочные испытания, но не даёт локальной настройке retry скрыть каскад за красивым числом попыток.

\n

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

\n" } diff --git a/editorial/agent-rewrites/069.json b/editorial/agent-rewrites/069.json index 76b29f3..3f5869a 100644 --- a/editorial/agent-rewrites/069.json +++ b/editorial/agent-rewrites/069.json @@ -2,6 +2,6 @@ "index": 69, "slug": "editorial-2026-02-practice-resilience", "title": "Как остановить каскадный отказ: один бюджет для retry и fallback", - "excerpt": "Медленный dependency превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.", - "contentHtml": "

Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос. Адаптер повторяет тот же вызов. Затем fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад.

\n

Симптом виден в трёх местах: исходящих вызовов на один пользовательский запрос становится больше, очередь не сокращается после добавления реплик, а trace показывает несколько владельцев retry. Цена ошибки выше задержки. Ослабленный dependency получает дополнительную работу в момент, когда уже не справляется с прежней. Каскад может перегрузить соседние компоненты и превратить частичный отказ в общий.

\n

Тезис простой: устойчивость начинается с ограничения работы. Для каждой логической операции нужно назначить одного владельца retry, задать конечный fan-out, назвать fallback и определить точку, после которой новые вызовы запрещены. Если эти границы нельзя восстановить из trace, систему нельзя считать готовой к проверке отказа.

\n

Механизм: считать логическую операцию

\n

Логическая операция — это один запрос пользователя или одно сообщение, которое система должна обработать. Внутри неё могут быть несколько сетевых вызовов. Поэтому успешные ответы не показывают полную стоимость. Считайте попытки, созданные после каждого временного исхода.

\n

Предположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.

\n

Retry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.

\n
\"Схема
Учебная схема показывает порядок контроля: лимит срабатывает до расширения маршрута, поэтому fallback не становится вторым слоем повторов.
\n

Учебный пример с явной границей

\n

Ниже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.

\n
const 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

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

\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Сначала выровнять сценарии, затем сравнивать изменения
\n

Что фиксировать в trace

\n

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. В ней не должно появляться скрытого выбора третьей реплики.

\n

Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.

\n

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

\n
  1. Назовите логическую операцию и отделите её от исходящих вызовов.
  2. Перечислите все места, где отказ может создать дополнительную работу.
  3. Выберите одного владельца retry и задайте конечное число попыток.
  4. Опишите fan-out числом и именами реплик; запретите неограниченный поиск.
  5. Назовите fallback, его входной исход, стоимость и terminal result.
  6. Поставьте limit до расширения маршрута и укажите действие при нулевом бюджете.
  7. Проверьте положительный и отрицательный trace на одинаковой логической нагрузке.
  8. Отдельно решите, допустим ли degraded result для операции с побочным эффектом.
\n

Ограничения

\n

Схема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.

\n

Временный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.

\n

Код 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.

\n

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

\n

Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.

\n

Если хотя бы один ответ требует догадки, сценарий не готов. Сначала назовите скрытый переход и назначьте его владельца. После этого отдельный тест с реальной сетью, данными, правами и наблюдением должен подтвердить поведение уже production-подобного пути. Результат учебной модели такой тест не предсказывает.

\n

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

\n" + "excerpt": "Медленная зависимость превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.", + "contentHtml": "

Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос, адаптер повторяет тот же вызов, а fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад: один пользовательский запрос порождает несколько обращений к уже перегруженной зависимости.

\n

Симптом можно увидеть без догадок: на один request_id приходится больше исходящих попыток, очередь не сокращается после добавления реплик, а trace показывает несколько независимых владельцев retry. Цена ошибки — не только лишние миллисекунды. Ослабленная зависимость получает новую работу в момент, когда не справляется с прежней, а частичный отказ распространяется на соседние компоненты.

\n

В этой статье «один бюджет» означает один бюджет попыток для одной логической операции и одного владельца retry. Это не глобальный лимит сервиса и не разрешение повторять всё подряд. Для защиты от общей перегрузки нужен отдельный сервисный лимит или circuit breaker, но он не должен превращаться в ещё один слой автоматических повторов.

\n

Сначала определите логическую операцию

\n

Логическая операция — это запрос пользователя или сообщение, которое система должна обработать. Внутри неё могут быть вызов API, чтение из базы и запрос к необязательному сервису. Считать только успешные ответы опасно: стоимость отказа состоит из всех попыток, включая те, что завершились timeout или были отменены по дедлайну.

\n

Запишите границу операции и единицу счёта. Например: checkout-481 — одна команда оформления, а вызовы pricing и inventory — её дочерние действия. Если верхний слой разрешает две попытки, это обычно означает одну исходную попытку и одну повторную, а не две дополнительные попытки на каждом нижнем сервисе.

\n

При независимых retry на нескольких слоях верхняя граница растёт мультипликативно. Если три слоя делают по четыре попытки, один запрос может создать до 64 вызовов нижней зависимости. Это верхняя оценка для такого дерева, а не обещание, что каждый вызов будет выполнен: отмена, дедлайн и ошибки могут остановить ветку раньше. Но уже сама оценка показывает, почему локальные настройки нельзя складывать без общей модели.

\n
\"Матрица
Матрица разделяет временный отказ, необязательную часть, неограниченный fan-out и исчерпанный бюджет. Число попыток в схеме — условное значение модели; рабочий контракт приведён и проверяется в примере ниже.
\n

Назначьте владельца retry

\n

У retry должен быть один владелец на границе, где видны дедлайн всей операции и её итог. Остальные слои возвращают ему исход вызова: temporary-timeout, overloaded, постоянную ошибку или успех. Они не запускают собственные циклы, если это не отдельный, явно описанный контракт.

\n

Бюджет нужно проверять до создания следующего вызова. Полезная последовательность такая: прочитать остаток бюджета, проверить тип ошибки, выбрать ровно одну реплику, создать попытку, уменьшить бюджет и записать результат. Если лимит проверяется после выбора нескольких реплик или после запуска параллельных запросов, это уже не ограничитель каскада, а журнал случившегося.

\n

Случайный экспоненциальный backoff с jitter снижает синхронный всплеск повторов, но не заменяет предел попыток. Backoff отвечает на вопрос «когда повторить», бюджет — «можно ли создавать ещё работу». Постоянную ошибку запроса или неверный вход повторять нельзя: задержка не сделает такой ответ успешным.

\n

Разделите retry, fan-out и fallback

\n

Retry — повтор той же логической операции после временного исхода. Fan-out — запуск нескольких ветвей для одного шага. Fallback — заранее названный результат с меньшей полнотой или явная ошибка, если необязательная часть недоступна. Смешивание этих понятий и создаёт скрытый рост нагрузки.

\n
Симптом, проверка и действие для одной логической операции
СимптомВероятная причинаПроверкаДействие
Вызовов больше, чем операцийRetry включён на нескольких слояхСгруппировать попытки по request_id и записать владельца каждойОставить один цикл, остальные слои сделать pass-through
Timeout запускает несколько репликFan-out задан как «все здоровые»Посчитать запуски до получения первого результатаЗадать число и имя реплики на каждую попытку
Fallback увеличивает нагрузкуЗапасной путь скрывает сетевой вызов или retryРазвернуть trace fallback до terminal resultСделать путь локальным и без повторов либо вернуть ошибку
После лимита видны новые spansБюджет проверяется после расширения маршрутаСверить порядок budget → route → callПеренести проверку перед выбором и запуском ветки
Повтор дублирует записьTimeout не говорит, дошла ли команда до сервераПроверить идемпотентность и ключ операцииНе повторять без идемпотентного контракта или компенсации
\n

Воспроизводимая модель бюджета

\n

Ниже — самостоятельный пример для Node.js 18+. Он не открывает сеть и не имитирует реальную производительность. Его задача — сделать видимыми три решения: две попытки означают «исходная плюс одна повторная», на одну попытку выбирается одна реплика, а fallback после успешного основного вызова не запускает новый retry.

\n
const 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. Так проверяется именно контракт переходов, а не доступность конкретного сервиса.

\n

Имена реплик, набор retryable-исходов и смысл degraded result здесь проектные. В рабочей системе их нужно заменить на значения своего клиента и доказать отдельными тестами: с timeout, с перегрузкой, с постоянной ошибкой и с отменой по дедлайну.

\n

Что записывать в trace и метрики

\n

Один trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции записывайте request_id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и итоговое действие. Не называйте условные units миллисекундами, если это не измерение времени.

\n

Минимальная последовательность может выглядеть так: logical-01 → edge attempt=1 → primary-a → temporary-timeout, затем edge attempt=2 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появиться нового обращения к основной зависимости.

\n

Полезны четыре счётчика: количество логических операций, количество всех попыток, количество повторов и количество terminal actions по исчерпанию бюджета. Сравнивайте их на одном сценарии и одинаковой нагрузке. Если растёт только число повторов, зависимость может оставаться здоровой; если растут одновременно повторы, timeout и очередь, ищите положительную обратную связь. Метрика сама по себе не доказывает причинность, поэтому связывайте её с trace.

\n

HTTP-граница: 503 и Retry-After

\n

Для HTTP-сервиса ответ 503 Service Unavailable может сообщать временную недоступность. Заголовок Retry-After указывает, сколько ждать до следующего запроса; RFC 9110 допускает задержку в секундах или HTTP-date. Это полезный сигнал клиенту, но он не назначает владельца retry и не ограничивает fan-out.

\n

Клиент всё равно должен применить бюджет всей логической операции, дедлайн и список действительно временных исходов. Нельзя превращать любой 5xx или любой timeout в бесконечный повтор. Если сервер отвечает 503 с Retry-After, верхний слой может учесть указание, но обязан остановиться при исчерпании бюджета. Если операция меняет состояние, проверьте идемпотентность до включения автоматического retry.

\n

Порядок проверки в проекте

\n
  1. Назовите одну логическую операцию и отделите её от исходящих вызовов.
  2. Соберите все места, где timeout, 5xx, отмена или пустой ответ создают дополнительную работу.
  3. Выберите одного владельца retry и зафиксируйте, означает ли лимит общее число попыток или число повторов.
  4. Разрешите только явно временные исходы; постоянные ошибки должны завершать ветку.
  5. Опишите fan-out числом и именами реплик, а также запретите неограниченный поиск «любой доступной» реплики.
  6. Назовите fallback, его входной исход, стоимость, полноту результата и terminal action.
  7. Поставьте проверку бюджета до маршрута и вызова; добавьте backoff с jitter, если повтор действительно разрешён.
  8. Прогоните четыре сценария: успех с первой попытки, временный отказ и успех, исчерпание бюджета, недоступность необязательной части.
  9. Сравните trace, число попыток и очередь при одинаковом failure injection. Отдельно проверьте операцию с побочным эффектом.
\n

Ограничения применимости

\n

Эта схема ограничивает работу, но не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, балансировку, отмену, распределённые транзакции, согласованность реплик и стоимость запуска процесса. Для этих свойств нужны нагрузочные и отказоустойчивые испытания на своей архитектуре.

\n

Fallback подходит только там, где неполный результат честно описывает состояние для пользователя. Для платежа, записи, команды или операции с неизвестным исходом безопаснее вернуть явную ошибку и запустить согласованную компенсацию. Degraded result не должен маскировать факт, что побочный эффект мог выполниться.

\n

Даже ограниченный retry может продублировать действие: запрос мог дойти до сервера, а timeout возник при чтении ответа. Поэтому для записи нужен идемпотентный ключ, дедупликация на стороне обработчика или другой явно проверенный контракт. Без него снижение числа попыток уменьшает риск, но не устраняет его.

\n

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

\n

Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбрана на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых логических операций и одинакового отказа число запусков не превышает установленный предел.

\n

Если хотя бы один ответ требует догадки, причина каскада не доказана. Сначала назовите скрытый переход и назначьте его владельца. Затем подтвердите поведение на production-подобном пути с реальной сетью, правами, дедлайном и данными. Учебный скрипт проверяет модель бюджета, но не заменяет такой тест.

\n

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

\n" } diff --git a/editorial/agent-rewrites/070.json b/editorial/agent-rewrites/070.json index e58d882..89b46d7 100644 --- a/editorial/agent-rewrites/070.json +++ b/editorial/agent-rewrites/070.json @@ -1,7 +1 @@ -{ - "index": 70, - "slug": "editorial-2026-01-field-platform-api", - "title": "Как проверить совместимость платформенного API до изменения контракта", - "excerpt": "Новое поле и номер версии не доказывают совместимость. Разбираем, как сопоставить контракт с конкретным потребителем, остановить несопоставимый путь и передать проверяемый результат.", - "contentHtml": "

После небольшого изменения API клиент начинает показывать пустой экран. Платформенная команда добавила поле в JSON, оставила старые поля и подняла версию с 1.2.0 до 1.3.0. Один ручной запрос вернул правильный ответ. Через час другой клиент получает новый статус, не находит запись и повторяет запрос.

\n

Цена ошибки выше, чем неудачный запрос. Клиент может сохранить неверное состояние, повторить команду или показать пользователю, что объект исчез. Команда платформы тратит время на спор о слове «совместимо», хотя не записала, что именно клиент обязан прочитать и какие ошибки должен различать.

\n

Тезис статьи короткий: совместимость принадлежит паре «контракт — конкретный потребитель». Номер версии помогает назвать поверхность изменения, но не заменяет проверку. Схема OpenAPI описывает форму HTTP API, а не закрытый парсер клиента, порядок обработки полей или смысл ошибки. Поэтому перед изменением нужно зафиксировать семью контракта, минимальные поля, разрешённые ошибки и отдельные исключения.

\n

Сначала отделите форму ответа от его смысла

\n

Возьмём учебный API чтения каталожной записи. Он принимает recordId и возвращает обязательные поля id и state. Поле label необязательно. Ошибка fixed-not-found означает только отсутствие записи. Она не означает ошибку сети, отказ в доступе или невалидный запрос.

\n
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 в примере не делает его обязательным. Эти свойства нужно вынести в контракт явно. Иначе наблюдение быстро превращается в неофициальную гарантию.

\n

Потребитель должен быть описан так же точно. Например, fixed-tolerant-reader-v1 читает только id и state, принимает версию 1.3.0 и знает ошибку fixed-not-found. Другой клиент требует legacyMode. Для него тот же ответ неполон. Третий адаптер отправляет команды, а не читает записи. Его нельзя сравнивать с read API только из-за одинакового JSON.

\n

Минимальная карточка потребителя

\n

Не начинайте с перечня всех команд и репозиториев. Запишите минимальное решение, которое клиент принимает по ответу. В карточке нужны четыре поля: contractFamily, поддерживаемая версия, обязательные поля и известные ошибки. Если клиент зависит от порядка, повторов или специального заголовка, это тоже явное требование. Если требование неизвестно, статус должен остаться неизвестным.

\n
\"Цикл
Проверка сначала устанавливает сопоставимость, затем сравнивает поверхность и гарантии. Она не выпускает версию и не меняет API.
\n
Диагностика перед изменением контракта
СимптомПричинаПроверкаДействие
Клиент не видит новое полеПоле добавили, но клиент использует закрытую десериализациюСверить 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
\n

Проверяйте family до полей

\n

Contract family — это вид операции и её смысловая граница. Read API, командный адаптер и webhook могут иметь поля id и state, но описывают разные действия. Сначала сравните fixed-catalog-read-v1 с тем, что объявил consumer. Если consumer относится к fixed-catalog-command-v1, проверка не должна доходить до полей.

\n

Такой результат не равен incompatibility. Команды пока не доказали, что объекты сопоставимы. Если назвать его просто «несовместимо», следующая команда начнёт ненужную миграцию. Точный статус сохраняет границу: нужен отдельный review для command family.

\n

Синтетический пример отрицательного пути

\n

Следующий код ограничен учебными объектами в памяти. Он не вызывает API, не читает production-трассы и не доказывает поведение реального клиента. Его задача — показать порядок решения: family проверяется раньше полей.

\n
const 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 с совместимостью

\n

Слово optional обычно относится к конкретному валидатору или схеме. Оно не отвечает на вопрос, что сделает consumer, если поле отсутствует или появилось неожиданное поле. Один reader игнорирует расширение. Другой использует строгую модель. Третий считает отсутствие поля признаком старого режима.

\n

Предположим, что в ответ добавили legacyMode. Если tolerant reader его не использует, добавление может быть безопасным для этой пары. Но клиент, который требует поле, уже нельзя пометить compatible. Нельзя выводить обратное поведение из названия поля или из того, что ручной запрос всё ещё проходит.

\n

То же относится к значениям перечисления. Старый клиент может принимать active и archived, но падать на новом paused. В схеме поле осталось строкой, а смысл ответа изменился. Значит, проверка должна сравнивать не только наличие поля, но и допустимые значения и переходы, которые видит клиент.

\n

Отдельно фиксируйте ошибки и исключения

\n

Список ошибок — часть поведения consumer. Если fallback разрешён только для fixed-not-found, клиент не должен подставлять пустое состояние после 401, 403, 422 или таймаута. Иначе временный сбой превращается в потерю данных на экране, а повтор может отправить команду дважды.

\n

Иногда нужен специальный путь. Например, один внутренний инструмент получает расширенное представление. Это допустимо только как отдельный, названный и документированный контракт. Он должен описать получателя, версию, входные условия и то, чего не гарантирует. Секретный query-параметр вроде ?debug=1 не является исключением. Его обнаружит следующий consumer, но не обнаружит общий review.

\n

Если команда добавляет гарантию о стабильном порядке, времени ответа или доступности, её нельзя прятать рядом с полем. Это новая публичная обязанность. Её нужно назвать, проверить на соответствующем уровне и привязать к consumer. Один успешный trace не доказывает ни одну из этих гарантий.

\n

Что даёт номер версии

\n

SemVer полезен после того, как команда определила public API. Он помогает назвать совместимые добавления и несовместимые изменения. Но строка 1.3.0 сама не отвечает, является ли новый статус допустимым, игнорирует ли клиент неизвестные поля и относится ли consumer к той же семье.

\n

OpenAPI снижает догадки о форме HTTP-интерфейса: путях, параметрах, запросах, ответах и схемах. Это необходимый слой описания. Но документ не знает скрытую ветку клиентского кода. RFC 9110 также не превращает representation в гарантию прикладной совместимости. Поэтому стандарты дают словарь и границы, а итог принимает проверка конкретной пары.

\n

Порядок действий перед изменением

\n
  1. Назовите операцию. Запишите один endpoint, действие, contract family и версию. Не смешивайте чтение, команду и webhook.
  2. Назовите consumer. Укажите систему или модуль, владельца проверки и supported versions. Не используйте «все клиенты» как идентификатор.
  3. Опишите минимальное чтение. Перечислите обязательные поля, допустимые значения, ошибки и требования к неизвестным полям.
  4. Отсечьте другую family. При различии семей верните stop-incomparable-consumer и откройте отдельный review.
  5. Сопоставьте поверхность. Найдите отсутствующие поля, новые значения и изменившиеся ошибки. Каждый пробел оставьте причиной, а не спрячьте под номером версии.
  6. Проверьте исключения. Для отдельного пути потребуйте имя, версию, получателя и отрицательную границу гарантий.
  7. Сформируйте hand-off. Передайте status, reasons и next action. Положительный статус не запускает rollout и не заменяет тесты потребителя.
\n

Ограничения и критерий готовности

\n

Такой review не обнаруживает неизвестные интеграции сам по себе. Он не заменяет контрактные тесты, нагрузочные проверки, security review, миграцию данных, SLA или план отката. Учебный код выше работает на фиксированных литералах. Он не сообщает production-результат и не подтверждает, что реальный клиент действительно описал все свои зависимости.

\n

Проверку можно считать готовой только для явно ограниченной пары. В отчёте есть одна contract family, одна версия, один named consumer, список required fields, допустимые ошибки, заявленные гарантии и итоговый status. Для compatible-for-this-check нет неописанного claim и не осталось неизвестного обязательства. Для любого stop указаны причина и следующий шаг. Если хотя бы одного элемента нет, слово «совместимо» преждевременно.

\n

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

" -} +{"index":70,"slug":"editorial-2026-01-field-platform-api","title":"Совместимость платформенного API: проверка до изменения контракта","excerpt":"Как проверить изменение API на паре «контракт — потребитель»: отрезать несопоставимые операции, найти скрытые ожидания и передать результат с явными причинами и ограничениями.","contentHtml":"

Платформенная команда добавила в ответ новое поле и подняла версию с 1.2.0 до 1.3.0. Ручной запрос по-прежнему возвращает 200 OK, поэтому изменение называют обратно совместимым. Но один клиент читает ответ строгим декодером, второй ждёт старый набор значений, а третий обращается к endpoint команды, хотя сравнивает его с read API. Через час после rollout один экран показывает пустое состояние, другой повторяет запрос, а участники review спорят о том, что означает слово «совместимо».

\n

Цена ошибки — не только сломанный экран. Клиент может записать неверное состояние, повторить необратимую операцию или превратить отказ в «объект не найден». Платформа затем получает временный флаг, скрытый обход и ещё одного потребителя, которого никто не внес в список. Поэтому вопрос перед изменением звучит точнее: совместим ли конкретный контракт с конкретным потребителем, для конкретного набора входов, ответов и ошибок?

\n

Сначала зафиксируйте предмет проверки

\n

Совместимость появляется не у версии самой по себе. Её проверяют на паре: именованный контракт и именованный потребитель. Контракт задаёт операцию, семейство, версию, формат запроса, формат ответа, ошибки и гарантии. Потребитель задаёт поддерживаемые версии, обязательные поля, допустимые значения и правила обработки отказов.

\n

В этой статье используется синтетическая пара fixed-catalog-read-v1 и fixed-tolerant-reader-v1. Она нужна для воспроизводимого рассуждения, а не для заявления о реальном сервисе. Реальный review дополнительно потребует инвентарь потребителей, контрактные тесты, права на проверку окружения и доказательства поведения конкретной версии клиента.

\n
\"Цикл
Проверка идёт от сопоставимости к полям и гарантиям. Красная ветка возвращает причину в карточку потребителя; зелёная передаёт только результат этого review и следующий шаг.
\n

Контракт — это больше, чем JSON-пример

\n

Пример ответа показывает одну representation — передаваемое представление ресурса. Он не обещает порядок ключей, время ответа, кэширование, сохранность записи или поведение при неизвестном поле. Такие свойства становятся контрактом только тогда, когда их явно описали и связали с потребителем.

\n
GET /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 не стирал различие между отсутствием данных и проблемой доступа.

\n

OpenAPI помогает записать HTTP-поверхность в форме, пригодной для людей и инструментов: operation, параметры, responses и schemas. Но схема не знает закрытую ветку кода клиента. Строгий декодер, зависимость от порядка массива и особый query-параметр могут существовать вне OpenAPI. Документ снижает неопределённость формы, но не заменяет проверку потребителя.

\n

Проверьте семейство до сравнения полей

\n

Сначала сравните contractFamily, затем версию и поверхность. Read API fixed-catalog-read-v1 и командный адаптер fixed-catalog-command-v1 могут иметь одинаковые поля id и state, но отвечают на разные действия. Один читает состояние, второй запускает изменение. Называть их несовместимыми — значит уже предположить, что сравнение допустимо. Правильный результат здесь — «несопоставимо», а не отрицательный вердикт по полям.

\n

Это короткая, но важная остановка. Если её пропустить, команда начнёт чинить не тот контракт: добавит в read API поля для команды или объявит общую версию, которая скрывает разные риски повторения и идемпотентности. Семейство должно быть именованным, а не выводиться по похожему URL или совпавшим ключам.

\n
const 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

Фраза «у нас есть несколько клиентов» слишком расплывчата для решения. Карточка должна отвечать на пять вопросов: кто потребитель, к какому семейству относится, какую версию поддерживает, что ему обязательно прочитать и какие ошибки он различает. Если клиент зависит от неизвестных полей, порядка элементов, заголовка или задержки, зависимость нужно записать отдельно, даже если она пока не считается допустимой.

\n
Минимальная карточка перед изменением read API
ПолеУчебное значениеЧто доказываетЧего не доказывает
consumer idfixed-tolerant-reader-v1какую пару проверяемчто неизвестных клиентов нет
familyfixed-catalog-read-v1операции сопоставимычто поля совпадают
supported version1.3.0какую поверхность клиент заявляетчто реализация действительно её принимает
required fieldsid, stateминимум чтениячто новое значение enum обработано
known errorsfixed-not-foundкакой fallback разрешёнчто timeout можно считать отсутствием
hidden dependencyне зафиксированаостаётся вопрос для проверкичто можно объявить compatible
\n

Пустая ячейка — это не нулевой риск. Если версия или required fields неизвестны, потребитель нельзя включать в успешный список. Это отдельный статус stop-unknown-consumer с действием «получить карточку». Инвентарь не должен награждать отсутствие сведений положительным verdict.

\n

Сравнивайте не только наличие ключей

\n

Добавление необязательного поля часто безопаснее удаления обязательного, но слово «часто» не является результатом проверки. Нужно знать, как клиент обращается с неизвестными ключами. У строгого валидатора расширение может стать ошибкой. У tolerant reader оно может быть проигнорировано. У третьего клиента отсутствие нового поля может включить устаревший режим.

\n

Особенно опасно изменение перечисления. Старый клиент принимает ready и blocked. Если сервер добавляет paused, JSON остаётся корректным, а смысл для клиента — нет. Поэтому compatibility check должен сравнить допустимые значения и переходы, которые видит потребитель. Схема с типом string не доказывает, что любое строковое значение безопасно.

\n

То же относится к массивам. Если контракт не обещает порядок, клиент не вправе выбирать первый элемент как «главный». Если порядок нужен, его надо назвать гарантией, протестировать и связать с версией. Текущий порядок, который виден в одном ответе, — наблюдение, а не обещание.

\n

Отдельно оформляйте исключения

\n

Иногда потребителю действительно нужна расширенная форма. Это не повод открыть внутренний объект под флагом ?debug=1. Скрытый маршрут быстро становится вторым API: его начинают вызывать из скриптов, а затем требуют сохранить навсегда. У исключения должны быть имя, версия, получатель, входные условия, гарантии и отрицательная граница.

\n
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.

\n

Версия помогает найти правила, но не заменяет их

\n

SemVer 2.0.0 различает patch, minor и major изменения публичного API. Это полезная дисциплина, когда public API уже определён. Но строка 1.3.0 не создаёт список потребителей и не отвечает, принимает ли клиент новый enum. Номер версии — адрес набора правил, а не доказательство того, что все участники прочитали этот набор одинаково.

\n

HTTP Semantics задаёт значения методов, статус-кодов, сообщений и representations. Из этого не следует application-level совместимость: HTTP 200 не гарантирует, что клиент понял бизнес-состояние. 404 тоже не означает автоматически «можно показать пустой список» — это решение конкретного контракта и его потребителя.

\n

Наконец, OpenAPI описывает поверхность HTTP API и позволяет генерировать документацию, клиентов и тесты. Однако specification не исследует исходный код каждого consumer. Поэтому три слоя дополняют друг друга: стандарт описывает vocabulary, контракт фиксирует обещания, а карточка потребителя показывает, что именно нужно проверить.

\n

Воспроизводимый локальный gate

\n

Ниже — полностью локальная проверка на Node.js 18 или новее. Она не требует пакетов, сети и доступа к production. Сохраните фрагмент в любой временный файл или вставьте в Node REPL: он проверяет family, обязательные поля и допустимые ошибки на фиксированных данных. В рабочем репозитории тот же порядок следует перенести в contract test, где fixtures принадлежат контракту и потребителю.

\n
const 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, а причина остановки.

\n

Передавайте результат с причиной

\n

Одно слово compatible плохо переносится между командами. Минимальный hand-off должен содержать идентификатор контракта, версию, consumer, проверенные гарантии, ограничения и следующий шаг. Для отрицательного результата причина обязательна: missing field, неизвестное значение, неподдерживаемая версия, mismatch family или undocumented exception.

\n
Статусы compatibility review и действия
СтатусКогда выдаватьСледующее действие
compatible-for-this-fixtureодна family, версия, поля и ошибки прошли указанную фикстурузапустить реальные contract tests и review rollout
stop-incomparableсемейства контрактов различаютсяоткрыть отдельный review для другой операции
stop-missingнет обязательного поля или значениясохранить поле, адаптировать consumer или выпустить новую поверхность
stop-versionconsumer не заявляет версию APIполучить supported versions и тест на переход
stop-unknown-consumerнет карточки потребителяустановить владельца, family и минимальное чтение
stop-undocumented-exceptionобход не имеет имени или отрицательной границыоформить versioned exception либо удалить обход
\n

Важно не смешивать эти исходы в один процент «готовности». Процент скрывает, что часть клиентов нельзя сопоставить, часть требует поля, а часть неизвестна. Список причин дольше, зато он подсказывает конкретную работу и не превращает неизвестность в разрешение на rollout.

\n

Пошаговый порядок перед изменением API

\n
  1. Назовите одну операцию. Укажите метод, путь, contract family и версию. Не объединяйте read, command и webhook под общим словом API.
  2. Назовите одного потребителя. Зафиксируйте его идентификатор, владельца, поддерживаемые версии и минимальное решение, которое он принимает по ответу.
  3. Опишите поверхность. Разделите обязательные и необязательные поля, допустимые значения, ошибки, заголовки и гарантии порядка или времени.
  4. Сравните family. При mismatch остановитесь до сравнения полей. Не выводите совместимость из похожего URL, названия или набора ключей.
  5. Проверьте отрицательные ветки. Удалите обязательное поле, добавьте новое значение enum, переставьте элементы без гарантии порядка, верните неизвестную ошибку.
  6. Найдите исключения. Для каждого особого пути проверьте имя, версию, получателя, входные условия и negative boundary.
  7. Сформируйте ограниченный hand-off. Передайте status, reasons и next action. Не объявляйте rollout безопасным на основании локальной фикстуры.
\n

Границы применимости и критерий готовности

\n

Этот алгоритм не обнаруживает неизвестные интеграции сам. Неполный инвентарь остаётся риском. Он также не заменяет security review, тестирование прав, нагрузочную проверку, SLO, миграцию данных, проверку идемпотентности команд и план отката. Для асинхронного API нужно дополнительно проверять порядок событий, повторную доставку и версию схемы сообщения. Для публичного API понадобятся правила deprecation и коммуникация с внешними клиентами.

\n

Фикстура из статьи не доказывает поведение реального сервиса. Она проверяет только заранее введённые literals и может пропустить ошибку сериализатора, конфигурации или среды. Node.js 18+ нужен лишь для воспроизведения примера; production-совместимость не зависит от одной версии Node и должна проверяться в поддерживаемых окружениях.

\n

Review можно считать завершённым только для явно ограниченной пары, если в записи есть family, версия, consumer, required fields, допустимые ошибки, проверенные гарантии, список отрицательных тестов и итоговый status. Положительный status должен содержать слово «для этой фикстуры» или эквивалентную границу. Если нет карточки потребителя или неизвестно поведение обязательного поля, честный результат — stop, а не «вероятно совместимо».

\n

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

","readingMinutes":14}