diff --git a/editorial/agent-rewrites/001.json b/editorial/agent-rewrites/001.json index e4aef68..48abfbe 100644 --- a/editorial/agent-rewrites/001.json +++ b/editorial/agent-rewrites/001.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-12-field-author-manifesto", "title": "Эксплуатационная инструкция: от симптома к проверенному откату", "excerpt": "Как описать опасную операцию так, чтобы оператор видел область сбоя, условия запуска, одно обратимое действие, сигнал остановки и критерий восстановления.", - "contentHtml": "
Проблема эксплуатационной инструкции видна в первый сбой: сервис отвечает ошибками, а оператор не понимает, с какого шага начать и как ограничить воздействие. Цена неточного текста — несколько людей одновременно меняют состояние системы, исходные метрики теряются, а откат объявляют успешным только потому, что команда завершилась без ошибки.
Типичный симптом нужно описать наблюдаемыми признаками: например, «5xx выше 5% на POST /payments в одном регионе», а не «сервис нездоров». Если инструкция не задаёт область, условие запуска и проверку результата, читатель может отключить здоровый трафик, выполнить команду на другой версии или вернуть конфигурацию без восстановления данных.
Хороший runbook не перечисляет команды ради полноты. Он связывает симптом с областью, предварительным условием, одним изменением, обратным действием и наблюдаемым результатом. Каждый шаг отвечает на четыре вопроса: что должно быть истинно до команды, что изменится, какой вывод ожидается и когда нужно остановиться.
Такой порядок отделяет факт от гипотезы. Сначала оператор сохраняет сигнал и ограничивает scope. Затем меняет один рычаг. После этого ждёт заданное окно и сравнивает метрику с критерием. Если критерий не выполнен или появился новый риск, оператор запускает rollback по заранее описанному условию. Окончание команды не равно восстановлению сервиса.
Symptom фиксирует метрику, endpoint и время. Scope показывает, кого затронула проблема: регион, релиз, долю трафика или конкретный tenant. Precondition подтверждает доступ, версию, наличие backup или сохранённого dashboard. Action меняет одно состояние. Rollback возвращает его при указанном условии. Verification задаёт метрику и окно наблюдения.
Эти поля защищают от разных ошибок. Без scope оператор расширит локальный сбой до всей системы. Без precondition он применит команду к неправильной версии. Несвязанные команды усложнят причинность: после них нельзя понять, что помогло. Без verification текст заканчивается слишком рано — на синтаксически успешном вызове, а не на подтверждённом результате.
Модальность тоже должна быть явной. Слова «проверьте», «убедитесь» и «при необходимости» ничего не задают, пока рядом нет объекта и критерия. В терминах RFC 2119 обязательное условие можно пометить как MUST, допустимое исключение — как SHOULD, необязательную диагностику — как MAY. В русской инструкции достаточно написать «обязательно», «рекомендуется» или «можно», если правило остаётся однозначным.
Перед изменением состояния сохраните наблюдаемый факт. Нужен минимальный набор, по которому можно сравнить до и после: scope, версия, временное окно и исходная метрика. Для опасной команды укажите право доступа, namespace и способ увидеть diff. Не вставляйте секреты, настоящие hostname и локальные alias, которых читатель не сможет проверить.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| 5xx выше порога на одном endpoint | Новая версия или флаг затрагивает ограниченный scope | Сравнить release, регион, долю трафика и error rate за одинаковое окно | Ограничить воздействие флагом; при ухудшении вернуть его |
| Timeout растёт, 5xx не меняется | Зависимость отвечает медленно или исчерпан ресурс | Сопоставить p95/p99, trace и лимиты пула с baseline | Не повторять запросы вслепую; остановить изменение и проверить зависимость |
| 409 при повторной операции | Состояние уже изменено или нарушена идемпотентность | Проверить operation id, запись состояния и время первого вызова | Не выполнять повторно; выбрать безопасный путь чтения или ручного разбора |
| После отката ошибка остаётся | Откатил один рычаг, но причина вне его scope | Сравнить метрики до, после action и после rollback | Остановиться, зафиксировать результат и передать расследование владельцу |
| Команда завершилась успешно | Изменился только control plane, data plane ещё не восстановлен | Проверить пользовательский сигнал и заданное окно наблюдения | Не закрывать инцидент до прохождения verification |
Ниже — учебная проверка карточки runbook. Функция принимает шесть полей и отклоняет объект без содержательного rollback или наблюдаемой verification. Она не проверяет права, shell, облако и реальную исполнимость команды. Её задача — поймать структурный пробел до публикации инструкции.
import { validateRunbookCard } from './upgrade-2027-12.mjs'; const card = validateRunbookCard({ symptom: '5xx выше 5 процентов на POST /payments', scope: 'region eu-west, release 42, 10 percent traffic', precondition: 'есть доступ к flag и сохранён dashboard за 15 минут', action: 'отключить flag payments-v2 для 10 процентов трафика', rollback: 'вернуть flag payments-v2 после проверки результата', verification: 'проверить error rate и p95 в течение 10 минут' }); console.log(card.ok, card.order.join(' -> ')); // true symptom -> scope -> precondition -> action -> rollback -> verificationВ примере строка с результатом показывает порядок полей, а не разрешение выполнить операцию. В настоящем runbook команда должна ссылаться на локальный механизм изменения и на конкретный сигнал. Если эти сведения нельзя проверить, их нужно оставить как явно обозначенные placeholders, а не заполнять вымышленными значениями.
Структурная проверка не знает, кому разрешён доступ, как устроены shell и облако, есть ли backup, lock и реальные пороги. Она не выполняет команду и не доказывает, что rollback действительно возвращает исходное состояние. Поэтому runbook нельзя считать разрешением менять production: approval, окно операции и владельца изменения задаёт локальный процесс.
NIST SP 800-61 задаёт общий цикл работы с инцидентом: подготовку, обнаружение, анализ, containment, восстановление и действия после инцидента. Документ не знает ваших сервисных зависимостей, команд и порогов остановки. RFC 2119 помогает различить обязательное и рекомендуемое действие, но не тестирует исполнимость конкретной команды.
Не добавляйте в текст вымышленные замеры или производственные результаты ради гладкого примера. Учебная карточка должна оставаться учебной и не содержать секретов, настоящих адресов и необратимых команд. Если значение неизвестно, лучше назвать, где его проверить, чем подменить неизвестное числом.
Инструкция готова, когда читатель может по первым абзацам определить симптом и scope, до команды проверить precondition, выполнить одно ограниченное действие, увидеть ожидаемый output, запустить rollback по явному условию и подтвердить восстановление метрикой за заданное окно. Если для любого шага нельзя назвать наблюдаемый результат, текст ещё не готов: вернитесь к условию, границе или проверке.
Проблема эксплуатационной инструкции видна в первый сбой: сервис отвечает ошибками, а оператор не понимает, с какого шага начать и как ограничить воздействие. Цена неточного текста — несколько людей одновременно меняют состояние системы, исходные метрики теряются, а откат объявляют успешным только потому, что команда завершилась без ошибки.
Типичный симптом нужно описать наблюдаемыми признаками: например, «5xx выше 5% на POST /payments в одном регионе», а не «сервис нездоров». Если инструкция не задаёт область, условие запуска и проверку результата, читатель может отключить здоровый трафик, выполнить команду на другой версии или вернуть конфигурацию без восстановления данных.
Хороший runbook не перечисляет команды ради полноты. Он связывает симптом с областью, предварительным условием, одним изменением, обратным действием и наблюдаемым результатом. Каждый шаг отвечает на четыре вопроса: что должно быть истинно до команды, что изменится, какой вывод ожидается и когда нужно остановиться.
Такой порядок отделяет факт от гипотезы. Сначала оператор сохраняет сигнал и ограничивает scope. Затем меняет один рычаг. После этого ждёт заданное окно и сравнивает метрику с критерием. Если критерий не выполнен или появился новый риск, оператор запускает rollback по заранее описанному условию. Окончание команды не равно восстановлению сервиса.
Symptom фиксирует метрику, endpoint и время. Scope показывает, кого затронула проблема: регион, релиз, долю трафика или конкретный tenant. Precondition подтверждает доступ, версию, наличие backup или сохранённого dashboard. Action меняет одно состояние. Rollback возвращает его при указанном условии. Verification задаёт метрику и окно наблюдения.
Эти поля защищают от разных ошибок. Без scope оператор расширит локальный сбой до всей системы. Без precondition он применит команду к неправильной версии. Несвязанные команды усложнят причинность: после них нельзя понять, что помогло. Без verification текст заканчивается слишком рано — на синтаксически успешном вызове, а не на подтверждённом результате.
Модальность тоже должна быть явной. Слова «проверьте», «убедитесь» и «при необходимости» ничего не задают, пока рядом нет объекта и критерия. В терминах RFC 2119 обязательное условие можно пометить как MUST, допустимое исключение — как SHOULD, необязательную диагностику — как MAY. В русской инструкции достаточно написать «обязательно», «рекомендуется» или «можно», если правило остаётся однозначным.
Перед изменением состояния сохраните наблюдаемый факт. Нужен минимальный набор, по которому можно сравнить до и после: scope, версия, временное окно и исходная метрика. Для опасной команды укажите право доступа, namespace и способ увидеть diff. Не вставляйте секреты, настоящие hostname и локальные alias, которых читатель не сможет проверить.
Я выбираю самый узкий обратимый рычаг, который действительно покрывает scope. Это не универсальное правило: если ошибка уже записала несовместимые данные, одного feature flag недостаточно. Матрица ниже помогает назвать цену выбора до команды, а не после неудачного отката.
| Рычаг | Когда подходит | Цена и риск | Что проверить до запуска |
|---|---|---|---|
| Feature flag | Проблема включается отдельным путём и данные совместимы | Малый blast radius; нужна рабочая ветка выключения и владелец флага | Scope флага, текущая версия и время распространения |
| Версионируемая конфигурация | Ошибка в параметре, а код менять не нужно | Откат обычно короткий, но конфигурация может приходить с задержкой | Diff, порядок публикации и фактическое значение на инстансе |
| Предыдущий артефакт | Причина в коде и быстрый локальный рычаг не покрывает сбой | Радиус больше и операция дольше; возможна несовместимость с миграцией данных | Совместимость схемы, артефакт и критерий остановки |
Сигнал остановки должен быть отдельной строкой: например, «при росте 5xx в незатронутом scope или при появлении ошибок записи прекратить rollout и вернуть flag». Он не заменяет критерий восстановления. Остановка говорит, когда нельзя продолжать, а verification — когда допустимо завершить операцию.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| 5xx выше порога на одном endpoint | Новая версия или флаг затрагивает ограниченный scope | Сравнить release, регион, долю трафика и error rate за одинаковое окно | Ограничить воздействие флагом; при ухудшении вернуть его |
| Timeout растёт, 5xx не меняется | Зависимость отвечает медленно или исчерпан ресурс | Сопоставить p95/p99, trace и лимиты пула с baseline | Не повторять запросы вслепую; остановить изменение и проверить зависимость |
| 409 при повторной операции | Состояние уже изменено или нарушена идемпотентность | Проверить operation id, запись состояния и время первого вызова | Не выполнять повторно; выбрать безопасный путь чтения или ручного разбора |
| После отката ошибка остаётся | Откатил один рычаг, но причина вне его scope | Сравнить метрики до, после action и после rollback | Остановиться, зафиксировать результат и передать расследование владельцу |
| Команда завершилась успешно | Изменился только control plane, data plane ещё не восстановлен | Проверить пользовательский сигнал и заданное окно наблюдения | Не закрывать инцидент до прохождения verification |
Ниже — учебная проверка карточки runbook. Функция принимает шесть полей и отклоняет объект без содержательного rollback или наблюдаемой verification. Она не проверяет права, shell, облако и реальную исполнимость команды. Её задача — поймать структурный пробел до публикации инструкции.
function validateRunbookCard(card) {\n const required = ['symptom', 'scope', 'precondition', 'action', 'rollback', 'verification'];\n if (!card || typeof card !== 'object') return { ok: false, reason: 'runbook-must-be-object' };\n const missing = required.filter((key) => typeof card[key] !== 'string' || card[key].trim().length < 10);\n if (missing.length) return { ok: false, reason: 'runbook-fields-missing', missing };\n if (!/rollback|откат|вернуть/i.test(card.rollback)) return { ok: false, reason: 'rollback-must-be-explicit' };\n if (!/провер|verify|метрик|threshold/i.test(card.verification)) return { ok: false, reason: 'verification-must-be-observable' };\n return { ok: true, order: required };\n}\n\nconst card = validateRunbookCard({\n symptom: '5xx выше 5 процентов на POST /payments',\n scope: 'region eu-west, release 42, 10 percent traffic',\n precondition: 'есть доступ к flag и сохранён dashboard за 15 минут',\n action: 'отключить flag payments-v2 для 10 процентов трафика',\n rollback: 'вернуть flag payments-v2 после проверки результата',\n verification: 'проверить error rate и p95 в течение 10 минут',\n});\nconsole.log(card.ok, card.order.join(' → '));\n// true symptom → scope → precondition → action → rollback → verification\n\nconst incomplete = { ...card };\ndelete incomplete.rollback;\nconsole.log(validateRunbookCard(incomplete).reason);\n// runbook-fields-missingВ примере первая строка показывает, что карточка прошла структурную проверку, а вторая — что пропущенное поле обнаруживается до публикации. Этот код не разрешает выполнять операцию: он не знает права, shell, облако, реальную команду и корректность порога. В настоящем runbook команда должна ссылаться на локальный механизм изменения и на конкретный сигнал. Если эти сведения нельзя проверить, их нужно оставить как явно обозначенные placeholders, а не заполнять вымышленными значениями.
Структурная проверка не знает, кому разрешён доступ, как устроены shell и облако, есть ли backup, lock и реальные пороги. Она не выполняет команду и не доказывает, что rollback действительно возвращает исходное состояние. Поэтому runbook нельзя считать разрешением менять production: approval, окно операции и владельца изменения задаёт локальный процесс.
Архивная NIST SP 800-61 Revision 2 (2012) показывает цикл подготовки, обнаружения и анализа, containment, восстановления и действий после инцидента. NIST пометил Rev. 2 как withdrawn и заменил её Rev. 3 (2025), поэтому этот цикл здесь приведён как историческая модель, а не как текущая нормативная схема. Актуальная Rev. 3 связывает incident response с CSF 2.0, но по-прежнему не знает ваших сервисных зависимостей, команд и порогов остановки. RFC 2119 помогает различить обязательное и рекомендуемое действие, но не тестирует исполнимость конкретной команды.
Не добавляйте в текст вымышленные замеры или производственные результаты ради гладкого примера. Учебная карточка должна оставаться учебной и не содержать секретов, настоящих адресов и необратимых команд. Если значение неизвестно, лучше назвать, где его проверить, чем подменить неизвестное числом.
Инструкция готова, когда читатель может по первым абзацам определить симптом и scope, до команды проверить precondition, выполнить одно ограниченное действие, увидеть ожидаемый output, запустить rollback по явному условию и подтвердить восстановление метрикой за заданное окно. Если для любого шага нельзя назвать наблюдаемый результат, текст ещё не готов: вернитесь к условию, границе или проверке.
Проблема начинается с отчёта «страница медленная». Такой симптом не говорит, что именно увидел пользователь: крупнейший блок появился поздно, клик обработался с задержкой или контент прыгнул под пальцем. Цена ошибки — чинить не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить загрузку, оставив интерфейс заблокированным длинной задачей.
\nПричина — свести LCP, INP и CLS к одному среднему score. Эти метрики отвечают на разные вопросы и требуют разных разрезов данных. LCP описывает появление крупнейшего видимого элемента, INP — задержку взаимодействия, CLS — неожиданные сдвиги layout. Сначала нужно определить тип симптома и границу измерения, потом выбирать изменение в коде.
\nBudget — это не красивое число в дашборде. Это набор порогов, сегментов, периода и действий владельца. Для каждого показателя нужно знать, какую часть опыта он описывает, где искать причину и что считать регрессией. Рекомендованные границы Core Web Vitals — LCP до 2500 мс, INP до 200 мс и CLS до 0,1. Они помогают классифицировать опыт, но не доказывают причину.
\nДля полевых данных используйте percentile, а не только среднее. Например, p75 показывает значение, ниже которого находится 75 процентов наблюдений в выбранном сегменте. Смешивать в одной строке мобильные и десктопные устройства, разные версии и разные периоды нельзя: итог потеряет смысл. В budget явно запишите URL, устройство, соединение, release и окно наблюдения.
\nLCP фиксирует момент, когда крупнейший контентный элемент стал видимым в пределах загрузки. На результат влияют TTFB, критический CSS, изображение, шрифт, сеть и работа браузера. Поэтому плохой LCP — сигнал разобрать цепочку, а не команда сразу добавить preload или сжать картинку.
\nINP оценивает задержку после взаимодействий пользователя. Ищите конкретное interaction, обработчик и long task на main thread. Причиной может быть тяжёлый обработчик, сторонний скрипт, лишняя синхронная работа или слабое устройство. Быстрый LCP не означает, что интерфейс быстро отвечает после ввода.
\nCLS суммирует неожиданные сдвиги layout. Типовые источники — изображение без зарезервированных размеров, поздняя реклама, вставленный сверху контент или изменение шрифта. Для проверки нужен shifted element и момент сдвига. Удаление одного долгого скрипта не исправит CLS, если браузер по-прежнему не знает размеры блока.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| LCP выше 2500 мс | TTFB, ресурс, CSS или шрифт | element timing, TTFB, waterfall | Ускорить критический путь и повторить замер |
| INP выше 200 мс | long task или тяжёлый handler | interaction trace и main thread | Разбить работу или перенести её после ответа |
| CLS выше 0,1 | Нет места под изображение или вставку | shifted element и layout trace | Зарезервировать размер и стабилизировать layout |
| Среднее хорошее, p75 плохой | Длинный хвост или смешанные сегменты | p75 по URL, устройству и release | Разделить сегменты и назначить владельца |
| Lab и field расходятся | Разная среда и состав трафика | Сопоставить условия запуска | Не подменять один тип данных другим |
Классификатор ниже принимает уже собранные значения и возвращает статус каждой метрики. Он показывает механику budget, но не собирает браузерные данные и не считает percentile. Реальные значения нужно получать через подходящие API наблюдения, сохранять с контекстом и агрегировать по сегментам.
\nfunction classifyWebVitals({ lcpMs, inpMs, cls }) {\n if (![lcpMs, inpMs, cls].every((value) => Number.isFinite(value) && value >= 0)) {\n return { ok: false, reason: 'vital-input-invalid' };\n }\n\n const lcp = lcpMs <= 2500 ? 'good' : lcpMs <= 4000 ? 'needs-improvement' : 'poor';\n const inp = inpMs <= 200 ? 'good' : inpMs <= 500 ? 'needs-improvement' : 'poor';\n const layout = cls <= 0.1 ? 'good' : cls <= 0.25 ? 'needs-improvement' : 'poor';\n const statuses = [lcp, inp, layout];\n const overall = statuses.includes('poor') ? 'poor'\n : statuses.includes('needs-improvement') ? 'needs-improvement'\n : 'good';\n\n return { ok: true, lcp, inp, layout, overall };\n}\n\nconsole.log(classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }));\n// { ok: true, lcp: 'good', inp: 'needs-improvement', layout: 'good', overall: 'needs-improvement' }\nПорог в функции нужен для triage. Он не отвечает на вопрос «какой код виноват». Даже улучшение LCP после preload не доказывает, что preload был единственной причиной: могли измениться сервер, кэш или состав трафика.
\nЛокальный запуск классификатора не является field evidence. W3C LCP и Performance Timeline описывают API и объекты измерения, но не проверяют вашу агрегацию, sampling или дашборд. Порог web.dev — практическая рекомендация, а не гарантия UX и не причинная модель. Внешние скрипты, кэш, браузер, сеть и состав аудитории могут изменить результат.
\nОптимизация одной метрики может ухудшить другую. Сжатие изображения уменьшает передаваемый объём, но может добавить CPU-декодирование или снизить качество. Разделение JavaScript способно помочь INP, но добавить запросы и повлиять на LCP. Рядом с Core Web Vitals проверяйте размер ресурсов, long tasks и ошибки.
\nГотовность наступает, когда для выбранного URL есть p75 LCP, INP и CLS по явно названным сегментам; для каждой плохой строки указан element, interaction или shifted element; изменение повторено тем же способом; соседние метрики не ухудшились. Если одного поля нет, вывод нужно назвать неполным, а не превращать его в общий score.
\nПроблема начинается с отчёта «страница медленная». Такой симптом не говорит, что именно увидел пользователь: крупнейший блок появился поздно, клик обработался с задержкой или контент прыгнул под пальцем. Цена ошибки — чинить не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить загрузку, оставив интерфейс заблокированным длинной задачей.
\nПричина — свести LCP, INP и CLS к одному среднему score. Эти метрики отвечают на разные вопросы и требуют разных разрезов данных. LCP описывает появление крупнейшего видимого элемента, INP — задержку взаимодействия до следующей отрисовки, CLS — неожиданный сдвиг уже видимого layout. Сначала определите тип симптома и границу измерения, потом выбирайте изменение в коде.
\nBudget — это не красивое число в дашборде. Это набор порогов, сегментов, периода и действий владельца. Для каждого показателя нужно знать, какую часть опыта он описывает, где искать причину и что считать регрессией. Рекомендованные границы Core Web Vitals — LCP до 2500 мс, INP до 200 мс и CLS до 0,1. Они помогают классифицировать опыт, но не доказывают причину.
\nДля полевых данных используйте percentile, а не только среднее. Например, p75 показывает значение, ниже которого находится 75 процентов наблюдений в выбранном сегменте. Смешивать в одной строке мобильные и десктопные устройства, разные версии и разные периоды нельзя: итог потеряет смысл. В budget явно запишите URL, устройство, соединение, release и окно наблюдения.
\nLCP фиксирует момент, когда крупнейший контентный элемент стал видимым в пределах загрузки. На результат влияют TTFB, критический CSS, изображение, шрифт, сеть и работа браузера. Поэтому плохой LCP — сигнал разобрать цепочку, а не команда сразу добавить preload или сжать картинку.
\nINP оценивает задержку после взаимодействий пользователя. Ищите конкретное interaction, обработчик и long task на main thread. Причиной может быть тяжёлый обработчик, сторонний скрипт, лишняя синхронная работа или слабое устройство. Быстрый LCP не означает, что интерфейс быстро отвечает после ввода.
\nCLS (Cumulative Layout Shift) отвечает на вопрос: насколько неожиданно сдвигался уже видимый контент? В актуальном определении CLS выбирает самое большое session window — окно, в котором сдвиги идут с интервалами менее секунды и общей длительностью не более пяти секунд, — и суммирует баллы внутри него. Поэтому «сумма всех сдвигов за страницу» — устаревшее упрощение. Типовые источники — изображение без зарезервированных размеров, поздняя реклама, вставленный сверху контент или изменение шрифта. Для разбора нужны layout-shift entries, source-элементы и момент сдвига.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| LCP выше 2500 мс | TTFB, ресурс, CSS или шрифт | element timing, TTFB, waterfall | Ускорить критический путь и повторить замер |
| INP выше 200 мс | long task или тяжёлый handler | interaction trace и main thread | Разбить работу или перенести её после ответа |
| CLS выше 0,1 | Нет места под изображение или вставку | Session window, source-элемент и layout trace | Зарезервировать размер и стабилизировать layout |
| Среднее хорошее, p75 плохой | Длинный хвост или смешанные сегменты | p75 по URL, устройству и release | Разделить сегменты и назначить владельца |
| Lab и field расходятся | Разная среда и состав трафика | Сопоставить условия запуска | Не подменять один тип данных другим |
Допустим, в отчёте checkout для мобильного сегмента p75 INP хуже порога, а LCP и CLS проходят. Это учебный payload, а не измерение реального сайта: он показывает, какие поля нельзя потерять при передаче результата между сбором, дашбордом и владельцем.
\n{\n 'url': '/checkout',\n 'segment': { 'device': 'mobile', 'connection': '4g' },\n 'release': '2027.12.1',\n 'percentile': 75,\n 'metrics': { 'lcpMs': 2180, 'inpMs': 640, 'cls': 0.08 }\n}\nЗдесь проблема не в общем score: LCP 2180 мс и CLS 0,08 проходят рекомендованные границы, а INP 640 мс — нет. Следующий вопрос — не «как ускорить страницу», а «какое взаимодействие сформировало p75 и чем занят main thread перед следующим кадром».
\nДля triage можно использовать маленький классификатор. Он не измеряет браузер и не считает p75: функция принимает уже агрегированные значения одного URL и сегмента. Все пороги в коде — проектная копия опубликованных рекомендаций; при изменении официальных границ их нужно обновлять вместе с тестами.
\nconst BUDGET = {\n lcpMs: { good: 2500, poor: 4000 },\n inpMs: { good: 200, poor: 500 },\n cls: { good: 0.1, poor: 0.25 },\n};\n\nfunction classify(value, limits) {\n if (!Number.isFinite(value) || value < 0) return 'invalid';\n if (value <= limits.good) return 'good';\n if (value <= limits.poor) return 'needs-improvement';\n return 'poor';\n}\n\nfunction classifyWebVitals({ lcpMs, inpMs, cls }) {\n const status = {\n lcp: classify(lcpMs, BUDGET.lcpMs),\n inp: classify(inpMs, BUDGET.inpMs),\n cls: classify(cls, BUDGET.cls),\n };\n\n if (Object.values(status).includes('invalid')) {\n return { ok: false, reason: 'vital-input-invalid' };\n }\n\n const overall = Object.values(status).includes('poor') ? 'poor'\n : Object.values(status).includes('needs-improvement')\n ? 'needs-improvement'\n : 'good';\n\n return { ok: true, ...status, overall };\n}\n\nconsole.log(classifyWebVitals({ lcpMs: 2180, inpMs: 640, cls: 0.08 }));\n// { ok: true, lcp: 'good', inp: 'poor', cls: 'good', overall: 'poor' }\nКлассификатор нужен для единого triage, но он не отвечает на вопрос о причине. Улучшение LCP после preload не доказывает, что preload был единственной причиной: могли измениться сервер, кеш или состав трафика. Не превращайте результат функции в автоматический rollback без проверяемой связи с release и без отдельного анализа trace.
\nfield data: p75 + сегмент + release\n -> какая граница нарушена?\n -> какой объект её объясняет: resource / interaction / shift?\n -> какой один участок принадлежит владельцу?\n -> изменение и повторный замер в том же сегменте\n -> соседние метрики не ухудшились? да: оставить, нет: откатить\nПоток разделяет две операции. Метрика говорит, где болит пользовательский опыт. Trace или запись элемента показывает, что проверять в системе. Владелец изменения отвечает за участок кода или доставки, но не может объявить причину доказанной только по совпавшему времени.
\nЛокальный запуск классификатора не является field evidence. Performance Timeline даёт браузерные примитивы для доступа к entries и PerformanceObserver, но не проверяет вашу sampling-логику, backend-агрегацию или дашборд. Полевой INP может отсутствовать, если на странице не было подходящего взаимодействия. LCP зависит от эвристик и прекращает поиск кандидатов после определённых действий пользователя; bfcache и same-document navigation требуют отдельной обработки.
\nОптимизация одной метрики может ухудшить другую. Сжатие изображения уменьшает передаваемый объём, но может добавить CPU-декодирование или изменить момент отрисовки. Разделение JavaScript способно помочь INP, но добавить запросы и повлиять на LCP. Резервирование места снижает CLS, но не исправляет медленный ресурс. Рядом с Core Web Vitals проверяйте размер ресурсов, long tasks, ошибки и пользовательский сценарий.
\nГотовность наступает, когда для выбранного URL есть p75 LCP, INP и CLS по явно названным сегментам; для каждой плохой строки указан кандидат, interaction или source-элемент; изменение повторено тем же способом; соседние метрики не ухудшились. Если одного поля нет, вывод нужно назвать неполным, а не превращать его в общий score.
\nПроблема становится видимой после XSS или downgrade-атаки: приложение отдаёт страницу по HTTPS, но разрешает произвольный inline-скрипт или продолжает открываться по HTTP. Симптомы можно увидеть в DevTools: CSP фиксирует нарушение или отсутствует вовсе, а HTTP-запрос сначала доходит до редиректа. Цена ошибки — выполнение чужого кода в контексте origin, утечка токена и потеря данных. Один security header не закрывает весь риск.
Ошибка начинается с копирования длинной строки заголовка без карты ресурсов и момента включения политики. CSP управляет тем, откуда страница может загружать и выполнять ресурсы. HSTS говорит браузеру обращаться к домену по HTTPS после получения политики через доверенный HTTPS-ответ. Ни один заголовок не исправляет серверный XSS, плохой сертификат, mixed content или секрет, который уже попал в JavaScript.
Политику нужно проектировать от конкретной страницы. Сначала назовите нужные скрипты, стили, изображения, API и фреймы. Затем разрешите только эти источники и отдельно проверьте отрицательные сценарии. Так CSP становится исполняемым контрактом браузера, а не декоративной строкой в конфигурации.
default-src задаёт запасное правило для типов ресурсов, но не заменяет явные директивы. script-src управляет JavaScript. object-src 'none' закрывает загрузку plugin/object, если она не нужна. base-uri 'self' ограничивает изменение базового URL. Такая политика уменьшает поверхность атаки, но не превращает небезопасный sink в безопасный.
Браузер получает Content-Security-Policy вместе с ответом и сопоставляет каждую попытку загрузки или выполнения с директивой. При несовпадении ресурс блокируется в режиме enforce. В режиме Report-Only браузер отправляет нарушение для анализа, но не блокирует действие. Поэтому отчёт помогает собрать карту, но сам по себе не является исправлением.
Nonce нужен для конкретного inline-скрипта, который пока нельзя вынести в файл. Сервер генерирует непредсказуемое значение заново для каждого ответа, вставляет его в CSP и в атрибут нужного script. Статическая строка не даёт защиты: тот, кто её узнает, получает разрешение. Полный nonce не стоит писать в диагностические логи.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Inline-скрипт заблокирован | Нет nonce или разрешение слишком узкое | Сверить директиву script-src и атрибут nonce | Вынести код в файл или выдать свежий nonce только этому скрипту |
| В отчёте много нарушений | Политика собрана без карты ресурсов | Сгруппировать отчёты по директиве, URL и типу ресурса | Удалить лишние источники и обосновать каждый оставшийся |
| Сайт всё ещё доступен по HTTP | Браузер ещё не получил HSTS или первый переход был HTTP | Проверить заголовок в HTTPS-ответе и цепочку переходов | Исправить HTTPS и redirect; отдельно оценить требования preload |
| Поддомен перестал открываться | includeSubDomains включили до проверки всех имён | Проверить сертификаты, HTTPS и endpoint каждого поддомена | Расширять область и max-age только после контролируемой проверки |
| После CSP остаётся XSS | Небезопасный HTML или sink не исправлен | Проверить контекстное экранирование и места вроде innerHTML | Исправить источник XSS; CSP оставить дополнительным барьером |
Ниже — учебная функция, которая собирает заголовки из nonce и срока HSTS. Она не устанавливает заголовки в серверном ответе и не проверяет шаблонизатор. Проверяемый результат показывает только форму политики: nonce присутствует в CSP, а HSTS содержит числовой срок и includeSubDomains.
import { buildSecurityHeaders } from './security-headers.mjs'; const result = buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c', hstsMaxAge: 31536000 }); if (!result.ok) throw new Error(result.reason); response.setHeader('Content-Security-Policy', result.headers['Content-Security-Policy']); response.setHeader('Strict-Transport-Security', result.headers['Strict-Transport-Security']);В реальном шаблоне nonce должен попасть только в разрешённый inline-скрипт:
<script nonce=\"7c2f1b8e9a4d6f0c\">startApplication();</script>Пример не доказывает, что приложение безопасно. Он проверяет сборку значения и показывает границу между генерацией заголовка и его применением. Тесты должны отдельно проверить отсутствие случайного unsafe-inline, соответствие nonce в двух местах и отправку заголовков только по нужному маршруту.
HSTS начинает действовать после того, как браузер получил Strict-Transport-Security через доверенный HTTPS-ответ. Большой max-age не защищает самый первый HTTP-переход, если домен ещё неизвестен браузеру. Он также не исправляет сертификат или mixed content. Для защиты первого перехода существует preload с отдельными требованиями и риском: сначала нужно проверить готовность домена.
includeSubDomains распространяет правило на поддомены. Включайте его только после проверки всех имён, которые ещё нужны пользователям, API и административным инструментам. Если один поддомен не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь срок политики. Возможность отката — часть безопасности, а не последняя строка runbook.
max-age и область.Nonce не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение передаёт строку в innerHTML, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. Нужны контекстное экранирование и безопасные API. CSP снижает последствия и обнаруживает часть нарушений, но не заменяет исправление причины.
Эта схема не проверяет совместимость целевых браузеров, CDN, service worker, iframe-политику, сертификаты, preload и настройки API-клиента. CSP Level 3 остаётся Working Draft, поэтому директивы и поддержку следует сверять с целевыми браузерами. RFC 6797 описывает HSTS, но не даёт защиты до первого безопасного ответа.
Готовность наступает, когда для конкретной страницы есть карта ресурсов, Report-Only нарушения разобраны, enforce проверен, HSTS подтверждён на каждом нужном домене, а откат воспроизводится. Дополнительный признак — автоматическая проверка заголовков ловит возврат широкого источника или исчезновение обязательной политики. Без этих проверок строка конфигурации остаётся предположением.
Сбой обычно замечают в двух местах: DevTools показывает нарушение Content Security Policy (CSP), а переход на http:// сначала попадает на редирект вместо немедленной замены схемы. Если в этот момент просто добавить длинную строку заголовков, приложение может продолжить выполнять инъецированный код, а первый HTTP-запрос всё ещё может уйти без шифрования. Цена ошибки — украденная сессия, отправленная форма или недоступный поддомен, а не «потерянный зелёный чек».
Главный вопрос не в том, какой шаблон заголовка скопировать, а в том, какую границу он действительно проводит. CSP ограничивает ресурсы и выполнение внутри страницы. HSTS учит браузер обращаться к уже известному хосту только по HTTPS. Ни один из них не исправляет XSS, не выдаёт сертификат и не делает безопасным секрет, который уже попал в JavaScript. Значит, внедрение нужно вести как проверяемое изменение поведения браузера: симптом → причина → отрицательный тест → откат.
\nCSP приходит в ответе страницы и применяется к её документу или связанному worker-контексту. Браузер сопоставляет попытку загрузки или выполнения с директивой: script-src отвечает за JavaScript, connect-src — за fetch/XHR и другие соединения, img-src — за изображения, а default-src задаёт запасное правило там, где нет более узкой директивы. object-src 'none' закрывает plugin-контент, если он приложению не нужен; base-uri 'self' ограничивает адреса, допустимые в элементе base.
HSTS работает иначе. После корректного заголовка Strict-Transport-Security, полученного по TLS без ошибки, браузер сохраняет хост как известный HSTS-хост на срок max-age. При следующем обращении к нему по HTTP браузер сам меняет схему на HTTPS. Заголовок, пришедший по обычному HTTP, браузер обязан проигнорировать. Поэтому серверный redirect нужен для первого контакта и старых клиентов, но не заменяет HSTS для клиента, который ещё не знает домен.
| Слой | Действие браузера | Чего не делает | Проверка и владелец |
|---|---|---|---|
| CSP | Блокирует ресурс или выполнение, не совпавшее с политикой; может только отправить отчёт | Не санитизирует HTML и не исправляет небезопасный innerHTML | Негативные сценарии страницы; владелец приложения |
| HSTS | Для известного хоста заменяет HTTP на HTTPS и требует успешный TLS | Не защищает первый HTTP-переход и не чинит сертификаты | HTTPS-ответы и каждый поддомен; владелец платформы |
| Redirect | Получает ответ сервера с новой схемой | Не скрывает исходный HTTP-запрос от сетевого посредника | Цепочка 3xx и канонический URL; владелец edge |
| Исправление XSS | Убирает исполняемый ввод из приложения | Не заменяется заголовком | Контекстное экранирование и безопасные API; владелец кода |
Начните с одной страницы и составьте список фактических источников: скрипты, inline-блоки, стили, шрифты, изображения, API, iframe, service worker и plugin-контент. Затем выражайте этот список в директивах. Разрешение https: или широкого CDN может убрать сообщения в консоли, но одновременно разрешить больше серверов, чем нужно странице. Узкий список — не самоцель: каждую внешнюю зависимость нужно связать с конкретной загрузкой и владельцем.
Content-Security-Policy-Report-Only позволяет сначала наблюдать нарушения без блокировки. Это полезный этап инвентаризации, но не защита: сломанный или вредоносный inline-скрипт продолжит выполняться. После разбора отчётов ту же политику переводят в Content-Security-Policy. Проверка должна включать отрицательный путь — например, попытку загрузить https://unknown.example/evil.js — иначе успешная загрузка штатного bundle ничего не доказывает.
Nonce — исключение для конкретного inline-скрипта. Сервер создаёт новое случайное значение для каждого ответа, помещает его в script-src и в атрибут nonce нужного элемента. CSP Level 3 требует уникальное значение; спецификация рекомендует не менее 128 бит до кодирования и криптографически стойкий генератор. Статическая строка в конфигурации нарушает эту модель: её можно повторно использовать и предсказать.
Nonce разрешает inline-скрипт, но не inline-обработчик события вроде onclick и не произвольный HTML. Если пользовательская строка попала в DOM через небезопасный sink, атакующий может использовать разрешённый bootstrap или другой разрешённый путь. Поэтому исправление XSS, контекстное экранирование и безопасные DOM-API остаются первым слоем, а CSP — ограничителем последствий.
Ниже — учебный файл security-headers.mjs. Он запускается в Node.js без сторонних пакетов, создаёт nonce и проверяет связь между политикой и HTML. Значения img-src, connect-src, frame-ancestors и годовой max-age — проектный пример, а не готовая политика для любого сайта.
import { randomBytes } from 'node:crypto';\n\nfunction buildSecurityHeaders() {\n const nonce = randomBytes(16).toString('base64');\n const contentSecurityPolicy = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"script-src 'self' 'nonce-\" + nonce + \"'\",\n \"style-src 'self'\",\n \"img-src 'self' data:\",\n \"connect-src 'self'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n ].join('; ');\n\n return {\n nonce,\n headers: {\n 'Content-Security-Policy': contentSecurityPolicy,\n 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',\n },\n };\n}\n\nconst { nonce, headers } = buildSecurityHeaders();\nconst bootstrap = \"<script nonce='\" + nonce + \"'>startApplication();</script>\";\nconst csp = headers['Content-Security-Policy'];\nconst nonceSource = \"'nonce-\" + nonce + \"'\";\n\nif (!csp.includes(nonceSource)) throw new Error('nonce is absent from CSP');\nif (!bootstrap.includes(\"nonce='\" + nonce + \"'\")) throw new Error('nonce is absent from HTML');\nif (csp.includes(\"'unsafe-inline'\")) throw new Error('broad inline permission detected');\n\nconsole.log({ headers, bootstrap });\nЗапуск node security-headers.mjs должен вывести два заголовка и один bootstrap с одинаковым nonce. Это проверяет только сборку значения. Серверный адаптер всё ещё обязан отправить заголовки именно в HTTPS-ответе HTML и передать тот же nonce шаблону. При кэшировании страницы нужно проверить, что HTML и CSP не разъезжаются: сохранённый HTML со старым nonce при новом заголовке будет заблокирован, а повторно использованный nonce ослабит модель.
Для страницы с внешним CDN добавьте конкретный origin только после проверки реального запроса, например https://cdn.example/asset.js, и повторите отрицательный тест. Не добавляйте 'unsafe-inline' или 'unsafe-eval' ради исчезновения одной ошибки сборки: сначала выясните, какой код создаёт inline или динамическое выполнение. Если библиотека требует это исключение, запишите риск, владельца и срок удаления рядом с конфигурацией.
У HSTS есть неудобная граница: политика появляется только после доверенного HTTPS-ответа, если браузер заранее не знает домен из своего preload-списка. Пользователь, впервые открывший http://example.test, сначала зависит от сети и server redirect. Посредник может изменить этот первый запрос или ответ до того, как браузер узнает о HSTS. Поэтому порядок такой: исправить TLS, включить редирект на HTTPS, проверить сертификат и только затем отдать HSTS в HTTPS-ответе.
includeSubDomains расширяет политику на все поддомены. Это не декоративный флаг: отдельный API, старый кабинет, health endpoint или внешний инструмент может перестать открываться, если он не готов к HTTPS или имеет другой сертификат. Сначала соберите список имён и владельцев, затем проверьте их вручную и автоматикой. В учебном примере выше включение флага — осознанное значение для домена, где такая инвентаризация уже пройдена.
max-age=0 позволяет сообщить браузеру по HTTPS, что политика хоста больше не должна считаться действующей. Это не мгновенный глобальный откат: клиент должен снова успешно соединиться по HTTPS, а другие клиенты могли ещё не получить новое значение. Чем дольше срок и шире область, тем дороже ошибка конфигурации. HSTS нельзя использовать как замену управляемому rollout и плану восстановления.
eval, iframe, worker, CDN и кэш.curl -sS -D - -o /dev/null https://example.test/ и убедитесь, что HTTPS-ответ содержит ровно одну CSP и один HSTS. Для http:// проверьте код и Location; не принимайте redirect за доказательство HSTS.includeSubDomains проверьте сертификат, HTTPS-ответ, API-клиента и административные пути. Если список неполон, начните с области без флага и не расширяйте её по привычке.unsafe-inline/unsafe-eval. В релизном плане укажите владельца, наблюдаемость нарушений и восстановление через версионируемую конфигурацию.Эта статья не обещает, что два заголовка закрывают всю модель угроз. CSP зависит от того, какие контексты и браузеры поддерживает продукт, как CDN переписывает ответы, где рендерится HTML и какие сервисы загружаются после навигации. Worker, iframe, кэш, service worker, API CORS и сертификаты нужно проверять отдельно. В частности, HSTS не заменяет настройку TLS, а CSP не контролирует логику сервера или уже украденный токен.
\nВ указанной датированной редакции W3C CSP Level 3 имеет статус Working Draft, поэтому директиву, поддержку браузеров и поведение конкретной версии следует сверять перед релизом. RFC 6797 — нормативная спецификация HSTS, но она не превращает первый HTTP-переход в защищённый. Если проект использует preload-список, рассматривайте его как отдельный операционный процесс с собственными условиями и стоимостью вывода домена.
\nГотовность можно доказать коротким пакетом: HTML-ответ содержит согласованные CSP и HSTS по HTTPS; Report-Only нарушения разобраны; enforce блокирует искусственно добавленный неизвестный ресурс; штатный bootstrap проходит с новым nonce; каждый поддомен прошёл TLS-проверку; CI ловит ослабление политики. Если хотя бы один пункт неизвестен, безопаснее оставить конкретную границу в ограничении и назначить следующий тест, чем назвать заголовок защитой.
\nmax-age, includeSubDomains и обязательное игнорирование STS-заголовка, пришедшего по незашифрованному соединению.Проблема во время инцидента начинается с фразы «сервис упал после релиза». Она смешивает наблюдение, время и причинность. На практике симптом может выглядеть иначе: HTTP 503, рост p95 latency, очередь сообщений или ошибки только у одного метода и в одном регионе.
Цена такой неточности — лишние изменения и потеря следов причины. Оператор перезапускает всё подряд, меняет несколько параметров или откатывает код, не проверив совместимость со схемой базы. Ошибка исчезает на минуту, а команда не знает, что именно помогло и не осталось ли повреждённое состояние.
Хороший runbook не угадывает первопричину за оператора. Он превращает тревожный сигнал в короткий цикл: зафиксировать факт, сузить область, сформулировать гипотезу, выполнить одну обратимую проверку и измерить результат. Если гипотеза не подтверждается, инструкция должна вести к следующей проверке, а не подталкивать к тому же действию.
Начинайте с полей, которые можно заполнить без интерпретации: timestamp, affected endpoint, регион, версия, доля ошибок, latency, saturation и baseline. Запись «всё медленно» не задаёт ни области поиска, ни критерия улучшения. Запись «POST /payments, EU, 5xx 8%, p95 1,8 с, baseline — предыдущее окно» уже позволяет выбрать следующий шаг.
Статус 500, задержка и error rate описывают разные стороны одного события. Они могут иметь общий корень, но не обязаны. Поэтому классификация сигнала должна быть детерминированной и прозрачной: высокий приоритет получает отказ или превышение error threshold, затем проверяется latency threshold. Классификатор помогает выбрать срочность, но не доказывает причину.
Гипотеза связывает наблюдение с проверяемым признаком: «после изменения лимита pool выросло ожидание соединения; проверка — метрика pool wait в том же временном окне». В формулировке должны быть условие и ожидаемый сигнал. Гипотеза «виноват backend» слишком широкая: её нельзя опровергнуть одним локальным измерением.
Первое изменение должно уменьшать blast radius. Подойдут отключение feature flag, остановка нового consumer или перевод небольшой доли трафика на старую версию — если такой путь предусмотрен архитектурой. Перезапуск без фиксации метрик может убрать симптом и одновременно удалить полезный контекст.
Ниже — небольшая чистая функция. Она принимает HTTP status, latency и error rate, применяет два явно заданных порога и возвращает классификацию. Числа в примере учебные: функция не знает бизнес-критичность endpoint, не отправляет уведомления и не выбирает rollback.
function classifySignal({ status, latencyMs, errorRate, latencyLimitMs = 1000, errorLimit = 0.05 }) {\n if (!Number.isInteger(status) || !Number.isFinite(latencyMs) || !Number.isFinite(errorRate)) {\n return { ok: false, reason: 'signal-invalid' };\n }\n if (status >= 500 || errorRate >= errorLimit) {\n return { ok: true, severity: 'high', reason: 'availability-or-error-threshold' };\n }\n if (latencyMs >= latencyLimitMs) {\n return { ok: true, severity: 'medium', reason: 'latency-threshold' };\n }\n return { ok: true, severity: 'low', reason: 'signal-below-threshold' };\n}\n\nconsole.log(classifySignal({ status: 503, latencyMs: 820, errorRate: 0.08 }));\n// { ok: true, severity: 'high', reason: 'availability-or-error-threshold' }Проверяемость здесь важнее полноты. Для одинакового входа функция возвращает одинаковый результат; невалидный status или нечисловая метрика не превращаются в уверенный severity. Реальный runbook должен отдельно описать источник каждой метрики, допустимое окно и владельца действия.
| Симптом | Возможная причина | Проверка | Первое действие |
|---|---|---|---|
| 5xx растёт сразу после выката | Новая версия или flag затронули часть трафика | Сравнить версии, долю трафика и error rate по endpoint | Ограничить flag или traffic split, затем повторить измерение |
| p95 растёт, 5xx не меняется | Ожидание соединения, CPU или внешнего ответа | Сопоставить latency breakdown, saturation и pool wait | Не перезапускать всё; убрать один изменённый лимит, если он подтверждён |
| Очередь увеличивается после отката | Consumer остановлен или не справляется с потоком | Проверить lag, rate обработки и ошибки consumer | Вернуть только совместимый consumer или остановить входной поток по policy |
| 5xx снизился, операции не завершаются | Сервис доступен, но есть незавершённое состояние | Сверить успешные операции, очередь, дубли и состояние данных | Не закрывать инцидент; запустить recovery-проверку и сохранить факты |
| Timeout при rollback | Изменение не применилось или control plane недоступен | Проверить фактическую версию и audit log, а не только код команды | Остановить новые изменения и выбрать заранее описанный ручной путь |
В таблице указаны направления проверки, а не универсальные причины. Один симптом может иметь несколько объяснений. Оператор должен подтвердить выбранную ветку своим сигналом и записать результат рядом с timestamp.
Rollback возвращает конфигурацию, feature flag или binary. Recovery означает, что система снова выполняет допустимую работу и данные остаются согласованными. Можно успешно вернуть старую версию и оставить очередь, двойные записи или несовместимые события. Поэтому после rollback проверяйте не только 5xx, но и lag, успешность операций, saturation и консистентность данных.
До отката проверьте совместимость. Старая версия должна читать текущую схему базы и понимать события, которые уже создала новая версия. Если перед инцидентом была миграция, порядок действий нельзя восстанавливать по памяти: в runbook нужны матрица совместимости, ожидаемый эффект, риск и способ вернуть сам rollback.
Критерий завершения должен быть измеримым. Формулировка «график выглядит лучше» не подходит. Нужны конкретные условия: error rate ниже согласованного порога, latency вернулась к допустимому диапазону, очередь не продолжает расти, а контрольная бизнес-операция проходит. Интервал наблюдения зависит от окна метрики и характера нагрузки; его задают заранее, а не в момент усталости.
Этот маршрут не заменяет on-call график, права доступа, SLO, уведомления и локальную матрицу severity. Классификатор не видит traces, не отличает бизнес-критичный endpoint от второстепенного и не знает, можно ли безопасно повторить операцию. Порог 5% и latency 1000 мс в примере не являются рекомендацией для конкретной системы.
Runbook готов, если оператор может без устного контекста ответить на пять вопросов: какой факт зафиксировать, где проходит граница влияния, какую гипотезу проверить, какое действие обратимо и по какому сигналу считать recovery подтверждённым. Для каждого опасного шага должны существовать условие остановки и путь возврата.
Минимальная проверка инструкции — проиграть один сценарий на тестовом окружении с контролируемым 503 и пройти ветку до конца. Такой прогон проверяет структуру runbook, но не доказывает поведение production, отсутствие флаков или безопасность реального rollback. Фактические результаты конкретной системы нужно приложить отдельно.
После выката payments-api начал отвечать 503 на POST /payments. Ошибки видны только в EU, а health-check продолжает быть зелёным. Первое решение — откатить последнюю версию, но одного статуса Deployment недостаточно: он может сообщить о завершённом rollout, пока платежи остаются в очереди.
Цена неточного диагноза — второй инцидент поверх первого. Если одновременно перезапустить pod, изменить лимит и откатить код, команда теряет причинную связь. Если старая версия не совместима с уже изменённой схемой или событиями, rollback вернёт бинарник, но добавит ошибки чтения, дубли или потерянные операции. Вопрос runbook такой: как перейти от наблюдаемого симптома к rollback, а затем подтвердить восстановление системы?
\nКод 503 сообщает о недоступности сервиса, а не о том, какой компонент виноват. Код 500 означает, что сервер столкнулся с неожиданным условием и не смог выполнить запрос; он также не раскрывает первопричину. Поэтому первой записью инцидента должен быть не вывод «сломался backend», а воспроизводимый факт: время, метод, endpoint, регион, текущая версия, доля ответов и baseline.
Сигналы нужно разделять по функции. Метрика — числовое измерение во времени, trace — путь отдельного запроса через систему, log — запись события. Они отвечают на разные вопросы и дополняют друг друга. Например, error rate показывает масштаб отказа, trace — где выросло ожидание, а log — какое условие привело к отказу. Не стоит заменять три источника одним общим графиком.
\nСимптом: 503 на POST /payments\nScope: EU, revision 18, 12:10–12:18 UTC\nBaseline: предыдущее сопоставимое окно\nГипотеза: revision 18 не проходит запрос к payment-provider\nПроверка: сравнить trace/span provider.call и ошибки revision 18/17\nОжидаемый признак: проблема сосредоточена в revision 18\nЧисла в этом фрагменте — учебные значения, а не отчёт о реальном сервисе. В настоящем инциденте baseline, окно и источник данных берутся из вашей системы наблюдаемости. Если baseline неизвестен, так и запишите: неизвестное нельзя превращать в порог только ради красивого отчёта.
\nRunbook ограничивает неопределённость не количеством команд, а границами. Перед rollback нужно ответить на пять вопросов.
\nrollout status, но и технический, пользовательский и, при необходимости, data-сигнал.Эта последовательность связывает состояние с владельцем. On-call фиксирует факт и координирует действие, владелец сервиса подтверждает совместимость версии, а владелец данных отвечает за незавершённые операции и recovery. В маленькой команде роли может выполнять один человек, но сами проверки не исчезают.
\n| Действие | Что оно возвращает | Цена и риск | Когда выбирать |
|---|---|---|---|
| Выключить feature flag | Поведение за границей flag | Не поможет, если ошибка в общем коде; старый путь должен оставаться рабочим | Проблема привязана к новому сценарию и flag уже предусмотрен |
| Уменьшить traffic split | Долю пользователей на новой версии | Оставляет часть риска и усложняет сравнение сигналов | Есть независимый старый и новый маршрут, а полное отключение не нужно |
| Откатить Deployment | Pod template и версию контейнера | Не отменяет миграцию базы, внешние записи и уже созданные события | Старая версия подтверждённо совместима с текущим состоянием |
| Перезапустить процесс | Текущее runtime-состояние процесса | Может временно скрыть симптом и уничтожить полезный контекст | Есть подтверждение утечки или зависшего процесса, но это не замена rollback |
Самый дешёвый по blast radius вариант не всегда самый быстрый по времени восстановления. Flag хорош, если проблема действительно находится за ним. Rollback Deployment понятнее, но его граница уже: он не возвращает схему базы и не стирает побочные эффекты. Перезапуск выбирают только под подтверждённую гипотезу, а не как универсальную кнопку.
\nПредставим, что payments-api работает в namespace payments, текущая ревизия — 18, а ревизия 17 прошла проверку совместимости. Это пример для Kubernetes; имена, namespace, таймаут и номер ревизии нужно заменить на значения своего кластера.
# 1. Сохраняем факт и смотрим, что именно было развернуто\nkubectl -n payments rollout history deployment/payments-api\nkubectl -n payments get deployment payments-api \\\n -o jsonpath='{.spec.template.spec.containers[*].image}{"\\n"}'\n\n# 2. Выполняем один обратимый шаг\nkubectl -n payments rollout undo deployment/payments-api --to-revision=17\n\n# 3. Ждём завершения именно этого rollout\nkubectl -n payments rollout status deployment/payments-api --timeout=5m\n\n# 4. Проверяем фактическое состояние и пользовательский путь\nkubectl -n payments get deployment payments-api\ncurl -sS -o /dev/null -w '%{http_code}\\n' \\\n https://payments.example.test/health/ready\nКоманда rollout undo возвращает Deployment к выбранной ревизии, а rollout status показывает состояние rollout. Это техническая проверка контроллера. Она не доказывает, что авторизованный платёж завершился, очередь уменьшается или данные согласованы. Для этого нужен отдельный безопасный контрольный запрос и сверка бизнес-сигнала. Если у endpoint нет публичного readiness URL, используйте внутренний проверочный путь с нужной авторизацией, но не подменяйте его произвольным 200 OK.
Проверку совместимости нельзя заменить номером ревизии. До команды ответьте: читает ли версия 17 текущую схему; понимает ли события, созданные версией 18; можно ли безопасно повторить неуспешную операцию; кто разберёт операции, принятые во время отката. Если хотя бы один ответ неизвестен, остановите rollback и поднимите владельца данных. Это не бюрократическая пауза: команда выбирает между ограниченным отказом и риском повредить состояние.
\nДаже маленькая проверка в коде помогает не превратить runbook в список команд. Функция ниже не выполняет откат и не объявляет recovery. Она только проверяет минимальные условия для учебного плана: есть наблюдаемый scope, известна целевая ревизия, записан путь возврата и подтверждена совместимость.
\nfunction rollbackGate({\n scope,\n currentRevision,\n targetRevision,\n schemaCompatible,\n eventsCompatible,\n rollbackPathReady,\n}) {\n const blockers = [];\n\n if (!scope) blockers.push('scope-missing');\n if (!Number.isInteger(currentRevision)) blockers.push('current-revision-missing');\n if (!Number.isInteger(targetRevision)) blockers.push('target-revision-missing');\n if (currentRevision === targetRevision) blockers.push('same-revision');\n if (schemaCompatible !== true) blockers.push('schema-compatibility-unconfirmed');\n if (eventsCompatible !== true) blockers.push('event-compatibility-unconfirmed');\n if (rollbackPathReady !== true) blockers.push('rollback-path-unprepared');\n\n return { allowed: blockers.length === 0, blockers };\n}\n\nconsole.log(rollbackGate({\n scope: 'EU / POST /payments / revision-18',\n currentRevision: 18,\n targetRevision: 17,\n schemaCompatible: true,\n eventsCompatible: true,\n rollbackPathReady: true,\n}));\n// { allowed: true, blockers: [] }\nГейт намеренно консервативен: undefined не считается подтверждением. В production вместо булевых значений должны быть ссылки на migration contract, описание события или результат отдельной проверки. Сам код не знает, разрешено ли действие политикой доступа, есть ли активная атака и выдержит ли система выбранное окно наблюдения.
| Симптом после действия | Что это может означать | Следующая проверка | Решение |
|---|---|---|---|
| 5xx снизился, но операции не завершаются | Доступность вернулась, а состояние платежа осталось незавершённым | Контрольная операция, очередь, дубли и записи в журнале | Инцидент не закрывать; запускать recovery по процедуре владельца данных |
| p95 снизился, очередь продолжает расти | HTTP-слой быстрее, consumer не успевает обрабатывать вход | Lag, rate обработки и ошибки consumer | Проверять consumer отдельно; не делать вывод об общем восстановлении |
| Ошибки остались только в EU | Локальная зависимость, конфигурация или маршрут региона | Сравнить trace, конфигурацию и версию по регионам | Ограничить scope действия, не откатывать глобально без доказательства |
| После rollback метрики колеблются | Окно наблюдения короче, чем цикл нагрузки или очередь ещё дренируется | Сопоставить окно с baseline и записать критерий остановки | Не считать quiet period доказательством; продолжить наблюдение по плану |
| Команда undo завершилась ошибкой | Нет нужной ревизии, конфликтует rollout или недоступен control plane | История Deployment, фактическая версия, audit log и права | Остановить новые изменения и перейти к заранее описанному ручному пути |
В последней строке используется закрытая ветка: если control plane не отвечает, повтор команды не становится диагностикой. Сначала фиксируем ошибку и фактическое состояние. Действия в обход штатного пути разрешены только политикой вашей платформы и с отдельной записью владельца.
\nКритерий «график стал зелёным» слишком слаб. Минимальный критерий recovery должен быть записан в терминах вашего сервиса: какой error rate допустим, в каком окне; какой latency считается приемлемой; должна ли очередь перестать расти или полностью опустеть; какая безопасная операция подтверждает пользовательский результат. Порог и длительность нельзя честно вывести из этого текста без данных о нагрузке и SLO.
\nЭтот runbook описывает управляемый релизный инцидент, а не все виды аварий. При подозрении на компрометацию, утечку данных, повреждение базы или нарушение регуляторных требований действуют security, legal и data-recovery процедуры. NIST SP 800-61r3 задаёт общий язык и модель Detect, Respond, Recover для киберинцидентов, но не выдаёт пороги, команды Kubernetes или разрешение на конкретный rollback.
\nKubernetes-пример зависит от наличия истории ревизий и прав доступа; политика хранения истории может сделать старую ревизию недоступной. Команда откатывает шаблон Deployment, но не является миграцией базы и не отменяет внешние побочные эффекты. В системах без versioned rollout нужен другой адаптер: например, атомарный переключатель трафика или заранее подготовленный конфигурационный rollback.
\nНаконец, этот текст не доказывает, что ваш сервис восстановится. Перед production добавьте тестовый прогон с контролируемым отказом, проверьте negative path и назначьте владельца каждого сигнала. Если прогон показывает, что команду можно выполнить, но recovery-сигнал получить нельзя, runbook ещё не готов.
\nrollout undo и проверки rollout. Команды нужно сверить с версией и политикой своего кластера.Симптом обычно выглядит просто: upstream отвечает 503 или 429, клиент ждёт timeout, а затем несколько раз отправляет тот же запрос. Если таких клиентов много, восстановление превращается во вторую волну нагрузки. Растут latency и очередь, здоровые запросы получают меньше ресурсов. Для POST цена выше: сервер мог принять запись до разрыва соединения, а повтор может создать второй заказ, платёж или задачу.
\nТезис статьи короткий: retry — это решение о семантике, а не число попыток в конфиге. Сначала нужно понять, можно ли безопасно повторить операцию и как узнать результат первой попытки. Только после этого выбирают статус, задержку, jitter и предел. Backoff не делает небезопасную запись безопасной. Idempotency key не отменяет deadline и не заменяет проверку состояния.
\nУ клиента есть два независимых вопроса. Первый: разрешён ли повтор. Второй: когда его отправить. Код ответа и HTTP-метод помогают ответить на первый вопрос, но не описывают всю бизнес-семантику. GET обычно читает ресурс. PUT и DELETE относятся к идемпотентным методам по смыслу HTTP: повтор не должен менять запрошенный эффект. POST часто создаёт новую операцию, поэтому таймаут оставляет результат неизвестным.
\nНеизвестный результат важнее слова «ошибка». Клиент мог отправить тело, сервер мог записать данные, а ответ мог потеряться при обратной передаче. В этом случае повтор — не восстановление связи, а новая попытка выполнить команду. Для записи нужен один из трёх путей: idempotency key с серверной дедупликацией, запрос статуса операции или контракт, который делает повтор фактически идемпотентным. Если ни одного пути нет, автоматический retry должен закончиться отказом с сохранением диагностического контекста.
\nКоды 429 и 503 тоже не дают универсального разрешения. 429 означает ограничение частоты; ответ может содержать Retry-After. 503 часто указывает на временную недоступность, но повтор без лимита способен продлить перегрузку. 400, 401 и 403 обычно требуют исправить входные данные или права. Повтор не изменит причину. Сетевой timeout не является HTTP-статусом и требует отдельно оценить, была ли операция отправлена и могла ли она завершиться.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 429 с Retry-After | Сработал лимит частоты | Прочитать заголовок и область лимита | Повторить только идемпотентную операцию, не раньше разрешённого времени |
| 503 без Retry-After | Upstream временно недоступен или перегружен | Сверить статус, deadline и счётчик попыток | Применить ограниченный backoff с jitter |
| Timeout GET | Ответ потерян или сервер ещё работает | Повторно прочитать ресурс и проверить остаток deadline | Повторить чтение при наличии бюджета |
| Timeout POST | Запись могла завершиться | Проверить idempotency key или статус операции | Не отправлять второй POST без защиты |
| 400, 401 или 403 | Неверные данные или права | Посмотреть тело ответа и авторизацию | Не повторять автоматически |
Экспоненциальный backoff снижает частоту повторов по мере роста номера попытки. Базовая формула: min(cap, base * 2^attempt). Параметр cap не даёт задержке расти бесконечно. Но один backoff не решает проблему синхронизации. Если тысячи клиентов получили сбой в одном интервале, одинаковая формула разбудит их почти одновременно.
Jitter добавляет случайное смещение. В простом варианте клиент выбирает задержку в диапазоне от нуля до рассчитанного значения. В другом варианте он добавляет небольшой случайный интервал к deterministic backoff. Выбор варианта зависит от клиента и нагрузки. Важно не смешать случайность с бесконтрольным ожиданием: итоговая задержка всё равно должна укладываться в общий deadline и максимальное число попыток.
\nDeadline принадлежит всей операции. Нельзя выдавать каждой попытке новый полный timeout. Если у операции осталось 120 миллисекунд, а рассчитанный backoff равен 500 миллисекундам, нужно завершить операцию или перейти к проверке состояния. Иначе локальный retry будет скрывать задержку от вызывающего кода и увеличивать очередь. В журналах сохраняйте номер попытки, статус, задержку, остаток deadline и причину остановки. Не записывайте секреты и полное тело запроса.
\nНиже — учебная функция для расчёта задержки. Она не выполняет HTTP-запрос, не генерирует случайность и не знает, разрешён ли retry для конкретного метода. Jitter передаётся числом, чтобы пример имел воспроизводимый результат. В реальном клиенте случайное значение нужно получать через управляемый генератор и сравнивать итог с остатком deadline.
\nfunction retryDelay(attempt, baseMs, capMs, jitterMs) {\n if (!Number.isInteger(attempt) || attempt < 0) {\n return { ok: false, reason: 'invalid-attempt' };\n }\n if (![baseMs, capMs, jitterMs].every(Number.isFinite)) {\n return { ok: false, reason: 'invalid-delay' };\n }\n if (baseMs < 0 || capMs < baseMs || jitterMs < 0) {\n return { ok: false, reason: 'invalid-delay-range' };\n }\n\n const exponentialMs = Math.min(capMs, baseMs * (2 ** attempt));\n return {\n ok: true,\n exponentialMs,\n delayMs: exponentialMs + jitterMs,\n };\n}\n\nconst result = retryDelay(3, 100, 1000, 37);\n// { ok: true, exponentialMs: 800, delayMs: 837 }\nЧисла в примере учебные. На попытке с индексом 3 экспоненциальная часть равна 800 миллисекундам, потому что 100 * 2^3 не превышает cap 1000. Этот расчёт не доказывает, что задержка подходит вашему upstream. Для подбора параметров нужны его лимиты, общий deadline, размер пула и допустимая нагрузка. Если jitter добавить после проверки deadline, клиент всё равно может превысить бюджет, поэтому проверку выполняют для итоговой задержки.
Request ID связывает попытки в логах. Он не заставляет сервер считать их одной операцией. Idempotency key должен входить в контракт endpoint. Сервер хранит ключ вместе с идентификатором операции и результатом в течение согласованного времени. При повторе с тем же ключом и тем же содержимым он возвращает тот же результат или текущее состояние. При другом содержимом сервер должен отклонить запрос, а не молча перезаписать первую операцию.
\nУ ключа есть ограничения. Он может истечь до того, как клиент повторит запрос. Сбой может произойти между записью результата и сохранением записи о ключе. Разные пользователи могут случайно выбрать одинаковый ключ, если сервер не включает владельца в область уникальности. При смене версии схемы может потребоваться отдельная совместимость. Поэтому ключ снижает риск дубля, но не обещает успешный исход и не заменяет reconciliation.
\nОтрицательный путь должен быть явным. Если POST завершился timeout, нет ключа и нет API статуса, клиент не повторяет его автоматически. Он возвращает состояние «результат неизвестен», сохраняет correlation id и предлагает безопасную проверку. Если upstream отвечает 400, клиент исправляет запрос или показывает ошибку. Если deadline закончился во время backoff, клиент останавливается. Повтор ради заполнения метрики успешных ответов не является корректным действием.
\nHTTP-метод не описывает побочные эффекты конкретного сервиса. GET может запускать плохо спроектированную команду, а POST может быть идемпотентным по отдельному контракту. Проверяйте реализацию endpoint, а не только название метода.
\nRetry не заменяет circuit breaker, rate limit, очередь, bulkhead и backpressure. Эти механизмы решают разные задачи. Circuit breaker ограничивает вызовы при устойчивом сбое. Rate limit распределяет бюджет запросов. Очередь меняет момент выполнения. Ни один из них не разрешает повторить неизвестную запись без проверки семантики.
\nТаблица и код выше не являются готовой библиотекой. Они не учитывают конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. Пример не содержит production-замеров и не заявляет универсальные значения base, cap или jitter. Эти параметры нужно подтвердить тестом на вашей границе нагрузки.
\nПолитика готова, если для каждого endpoint можно ответить на четыре вопроса: какую операцию повторяем, почему она безопасна, сколько времени и попыток разрешено, и что делаем при неизвестном результате. Тест должен показать, что 429 учитывает Retry-After, 503 не создаёт синхронную вторую волну, timeout POST не создаёт дубль без ключа, а исчерпание deadline останавливает цикл. Если хотя бы один ответ звучит как «повторяем всегда», политика не готова.
\nСимптом знакомый: upstream отвечает 503 или 429, клиент ждёт timeout и отправляет тот же запрос снова. Если так делают тысячи клиентов, восстановление превращается во вторую волну нагрузки. Растут очередь и latency, а здоровые запросы получают меньше ресурсов. Для POST цена ошибки выше: сервер мог сохранить заказ или платёж до разрыва соединения, а повтор создаст второй объект.
\nГлавный вопрос — не «сколько раз повторить», а «можно ли повторить именно эту операцию и как узнать результат первой попытки». Retry безопасен только при двух условиях: повторяемый эффект известен, а задержка подчиняется общему deadline. Backoff снижает частоту запросов, но не исправляет неверную семантику. Idempotency key уменьшает риск дубля, но не отменяет таймаут, лимит попыток или проверку состояния.
\nУ клиента есть два независимых решения. Сначала он определяет право на повтор, затем — момент следующей попытки. HTTP-метод даёт полезную подсказку, но не заменяет контракт endpoint. По RFC 9110, GET, PUT и DELETE относятся к idempotent methods: несколько одинаковых запросов должны иметь тот же намеренный эффект, что и один. Это не запрещает серверу отдельно логировать каждый запрос. POST по умолчанию не даёт такой гарантии.
\nПри timeout результат становится неизвестным. Тело могло дойти до сервера, обработка могла завершиться, а ответ потерялся на обратном пути. Поэтому повтор POST — это не «продолжение» первой попытки, а новая команда. Без idempotency key, статусного endpoint или другого доказуемого механизма дедупликации клиент не знает, создаст ли дубль. В этом случае правильный результат — «состояние неизвестно», а не ещё один POST.
\nСтатус тоже нужно читать как сигнал, а не как разрешение. 429 означает ограничение частоты и может сопровождаться Retry-After. 503 обозначает временную недоступность или перегрузку; сервер также может подсказать время ожидания. 400, 401 и 403 обычно требуют исправить запрос или права, поэтому повтор без изменения входа не помогает. Сетевой timeout вообще не является HTTP-статусом: дополнительно выясните, успел ли транспорт отправить запрос.
| Сигнал | Что известно | Проверка | Решение |
|---|---|---|---|
429 с Retry-After | Сработал rate limit | Прочитать заголовок и область лимита | Ждать не меньше указанного времени; повторять только воспроизводимую операцию |
| 503 | Сервис временно недоступен или перегружен | Проверить deadline и счётчик попыток | Ограниченный backoff с jitter; остановиться при исчерпании бюджета |
| Timeout GET | Ответ потерян, ресурс мог измениться | Сделать чтение состояния и проверить остаток времени | Повторить чтение, если остаётся deadline |
| Timeout POST | Запись могла завершиться | Проверить ключ идемпотентности или статус операции | Не отправлять второй POST без защиты |
| 400, 401, 403 | Вход или права не подходят | Посмотреть тело ответа и контекст авторизации | Не повторять автоматически |
Экспоненциальный backoff увеличивает паузу по номеру попытки: min(cap, base × 2^attempt). cap ограничивает рост задержки, но одинаковая формула всё равно синхронизирует клиентов: после общего сбоя они проснутся почти одновременно. Jitter — случайное смещение — распределяет отправку по интервалу. Вариант full jitter выбирает случайную задержку от нуля до рассчитанного backoff; другой вариант добавляет небольшое смещение к базовой паузе. Выбор зависит от нагрузки и клиента.
Серверный Retry-After задаёт минимальное ожидание. Если он есть, клиент не должен отправлять запрос раньше этого момента, но обязан сравнить его с собственным deadline. Некорректное или чрезмерное значение не должно заставить клиент ждать бесконечно. Deadline принадлежит всей операции: каждой попытке передаётся остаток времени, а не новый полный timeout.
Схема ниже показывает границу ответственности. Клиент сначала классифицирует эффект и сигнал, затем выбирает защиту записи, и только потом рассчитывает паузу. Если любой из этих шагов не дал доказательства безопасности, поток заканчивается проверкой состояния или контролируемой ошибкой.
\nФункция ниже не выполняет HTTP-запрос. Она принимает уже наблюдаемый сигнал и возвращает решение: повторять ли операцию и сколько ждать. Случайность передаётся через randomUnit, поэтому тест получает воспроизводимый вход. В production это значение выдаёт генератор случайных чисел, а итоговая задержка всё равно проходит проверку deadline.
function planRetry({\n method,\n status = null,\n transportError = false,\n attempt,\n nowMs,\n deadlineMs,\n baseMs = 100,\n capMs = 2000,\n maxAttempts = 3,\n randomUnit = 0.5,\n retryAfterMs = null,\n idempotencyKey = false,\n statusEndpoint = false,\n}) {\n const idempotentMethod =\n ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].includes(method);\n const repeatable = idempotentMethod || idempotencyKey || statusEndpoint;\n const transient = transportError || status === 429 || status === 503;\n\n if (!repeatable) return { retry: false, reason: 'unknown-result' };\n if (!transient) return { retry: false, reason: 'not-transient' };\n if (!Number.isInteger(attempt) || attempt < 0 ||\n !Number.isInteger(maxAttempts) || maxAttempts < 1) {\n return { retry: false, reason: 'invalid-attempt' };\n }\n if (attempt >= maxAttempts) {\n return { retry: false, reason: 'max-attempts' };\n }\n if (![nowMs, deadlineMs, baseMs, capMs, randomUnit].every(Number.isFinite) ||\n baseMs < 0 || capMs < baseMs) {\n return { retry: false, reason: 'invalid-delay' };\n }\n if (retryAfterMs != null && (!Number.isFinite(retryAfterMs) || retryAfterMs < 0)) {\n return { retry: false, reason: 'invalid-retry-after' };\n }\n if (nowMs >= deadlineMs) {\n return { retry: false, reason: 'deadline-exhausted' };\n }\n\n const backoffMs = Math.min(capMs, baseMs * (2 ** attempt));\n const jitterMs = Math.floor(backoffMs * Math.min(1, Math.max(0, randomUnit)));\n const delayMs = retryAfterMs == null\n ? jitterMs\n : Math.max(jitterMs, retryAfterMs);\n\n if (nowMs + delayMs >= deadlineMs) {\n return { retry: false, reason: 'deadline-exhausted' };\n }\n return { retry: true, delayMs, backoffMs };\n}\n\nconst decision = planRetry({\n method: 'POST', status: 503, attempt: 2,\n nowMs: 1000, deadlineMs: 3000, randomUnit: 0.5,\n idempotencyKey: true,\n});\n// { retry: true, delayMs: 200, backoffMs: 400 }\nВ примере POST повторяется не из-за одного статуса 503, а потому что передан ключ идемпотентности. При попытке с индексом 2 backoff равен 400 мс, а половина случайного диапазона даёт 200 мс. Эти числа учебные: их нельзя переносить в production без знания лимитов upstream, размера пула, допустимой задержки и общего бюджета операции.
Request ID связывает попытки в логах, но сам по себе не заставляет сервер считать их одной операцией. Ключ идемпотентности должен быть частью контракта endpoint. В одном из распространённых вариантов сервер сохраняет ключ, параметры и результат первой обработки, а повтор с тем же ключом возвращает тот же результат. Тот же ключ с другим содержимым нужно отклонять. Это проектное правило сервиса, а не универсальное свойство HTTP.
\nОговорите срок хранения ключа, область уникальности и поведение при параллельных запросах. Если ключ удалили раньше повторной попытки, сервер может принять её как новую. Если запись результата и запись ключа не согласованы, окно дубля останется. Поэтому после неизвестного ответа может понадобиться reconciliation — сверка состояния с источником истины. Ключ снижает риск, но не обещает успешный исход.
\nbase, cap и максимум попыток. Отдельно решите, что делать с Retry-After, отсутствующим и некорректным заголовком.Метод не описывает побочные эффекты конкретной реализации. GET может запускать плохо спроектированную команду, а POST может быть повторяемым по отдельному контракту. Проверяйте endpoint и его хранилище, а не только глагол.
\nRetry не заменяет rate limit, circuit breaker, очередь, bulkhead или backpressure. Они ограничивают разные части системы и не дают разрешения повторить неизвестную запись. Код выше не учитывает конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. В нём нет production-замеров и универсальных значений задержки.
\nПолитика готова, если для каждого endpoint команда может ответить на четыре вопроса: какой эффект повторяется, почему он безопасен, сколько времени и попыток разрешено, и что делать при неизвестном результате. Тест должен показать, что 429 ждёт Retry-After, 503 не синхронизирует клиентов, timeout POST не создаёт дубль без защиты, а исчерпание deadline останавливает цикл.
Retry-After и 503.Retry-After.Команда ALTER TABLE проходит на пустой базе, но на большой таблице может ждать блокировку и задержать пользовательские запросы. Другой симптом появляется после выката: новый writer сохраняет только новую форму данных, а ещё работающий старый reader ищет старую колонку и получает ошибку или неполную запись.
Цена ошибки — очередь запросов, простой части сервиса и откат приложения, который уже не возвращает совместимость со схемой. Откат кода не отменяет записи, сделанные новым writer, а обратное изменение типа или удаление данных может оказаться необратимым. Поэтому миграцию проектируют как последовательность совместимых состояний, а не как один SQL-файл.
Безопасный порядок такой: добавить новую форму данных, научить код работать со старой и новой формами, переключить чтение, проверить потребителей и только затем удалить старую форму. Старый и новый binary некоторое время живут одновременно, экземпляры обновляются не синхронно, а миграция может остановиться между фазами.
Рассмотрим замену вычисляемого имени из first_name и last_name на колонку display_name. Сначала новая колонка должна быть совместима со старым кодом: она nullable или имеет безопасное значение по правилам домена. Пока старый reader ещё работает, новый writer не может отказаться от старых полей без fallback.
Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.
| Фаза | Чтение | Запись | Что разрешено | Контроль |
|---|---|---|---|---|
| Expand | старая форма | старая форма | добавить nullable-колонку или совместимый индекс | старый binary продолжает работать |
| Dual write | старая форма, новая с fallback | обе формы | заполнять новую форму пачками или при записи | сверять значения и ошибки записи |
| Switch | новая форма с fallback | обе формы | перевести reader после проверки данных | наблюдать долю чтения fallback |
| Contract | новая форма | новая форма | удалить старую форму отдельным изменением | есть сигнал, что старые потребители ушли |
| Rollback | старая или fallback | совместимая запись | вернуть binary без потери данных | путь отката проверен до switch |
Новый writer без совместимого reader — небезопасное состояние. Двойная запись решает только доставку данных в две формы; она не доказывает, что значения одинаковы, что backfill не перезапишет более свежую запись и что все потребители готовы к switch.
Изменение таблицы зависит от блокировок, объёма работы и конкретной версии PostgreSQL. В review смотрите на lock mode, время ожидания, границы транзакции, индексы, триггеры, репликацию и план восстановления. Добавление колонки, создание индекса, backfill и изменение типа имеют разную стоимость. Объединять их в одну «маленькую миграцию» нельзя без проверки.
Backfill — отдельная нагрузка, а не деталь миграции схемы. Большой UPDATE конкурирует с пользовательскими запросами и может увеличить WAL. Идемпотентные ограниченные пачки позволяют остановить работу и продолжить её позже. Размер пачки, пауза и условие обновления зависят от вашей нагрузки; пример ниже не задаёт универсальные значения.
Небольшая функция формализует главный запрет: нельзя включать новую запись, если нет reader, который понимает новую форму. Это учебная проверка совместимости; она не подключается к базе, не запускает DDL и не заменяет проверку конкретного кластера.
function classifyMigrationStep({ oldReads, newReads, oldWrites, newWrites }) {\n if (newWrites && !oldReads && !newReads) {\n return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n }\n if (newWrites && !oldWrites) {\n return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n }\n if (newReads && oldReads && newWrites) {\n return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n }\n if (oldReads && !newReads && !newWrites) {\n return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };\n }\n return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };\n}\n\nconsole.log(classifyMigrationStep({\n oldReads: false,\n newReads: false,\n oldWrites: false,\n newWrites: true,\n}).phase);\n// unsafeВ настоящей миграции вместо boolean-признаков нужны конкретные версии приложения, формы записи и список потребителей. Проверка должна отвечать на вопрос «кто прочитает запись после этого шага?», а не только на вопрос «принял ли SQL сервер?».
Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только display_name, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.
Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.
Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.
Миграция готова к contract, когда новая форма заполнена и сверена, новый reader работает без скрытой зависимости от старой формы, старый binary больше не является потребителем, а rollback-путь проверен на промежуточном состоянии. Если хотя бы один пункт нельзя доказать наблюдением или проверкой, оставьте старую форму и продолжите расследование.
Миграция падает не только на самом ALTER TABLE. Частый симптом выглядит так: новый релиз уже пишет в display_name, а экземпляр старого кода ещё читает first_name и last_name. Другой вариант — DDL ждёт блокировку, пока пользовательские запросы продолжают работать. В обоих случаях одна команда пытается поменять контракт сразу для базы, writer и reader.
Цена ошибки — ошибки чтения, потерянное значение имени или очередь запросов. Откат бинарника не возвращает данные, которые новый writer уже записал только в новую форму, а обратный DDL не восстановит информацию после необратимого преобразования. Главный вопрос миграции поэтому такой: как сделать каждый промежуточный шаг совместимым с уже работающими потребителями?
Под reader будем понимать любой код, который читает строку, а под writer — код, который её создаёт или изменяет. Переход совместим, если после каждого шага любой ещё работающий reader может прочитать запись, созданную любым writer. Это правило относится и к фоновым задачам, отчётам, админке, скриптам и другим сервисам, а не только к основному HTTP-приложению.
Схема old -> new не выкатывается одной миграцией. Сначала расширяем контракт, потом некоторое время обслуживаем две формы, затем переключаем чтение и лишь в конце сужаем контракт. Именно такой смысл у паттерна expand and contract в документации GitLab: expand сохраняет обратную совместимость, migrate переводит потребителей, contract удаляет совместимость.
Пусть в таблице users уже есть nullable-колонки first_name и last_name. Новому экрану нужен display_name. Правило форматирования в примере учебное: соединить непустые части одним пробелом. В реальном домене нужно отдельно решить порядок имени, локаль, пробелы, пустые строки и допустимое отсутствие значения.
| Фаза | Reader | Writer | Действие | Выход из фазы |
|---|---|---|---|---|
| Expand | Старая форма | Старая форма | Добавить nullable display_name без требования для старого кода | Старый binary читает и пишет как прежде |
| Dual write | Новая форма с fallback | Обе формы в одной операции | Обновлять колонку при каждой записи и заполнить старые строки | Проверка расхождений и готовности readers |
| Switch | Новая форма, fallback виден | Обе формы | Перевести основной путь чтения и наблюдать ошибки | Ни один потребитель не использует fallback |
| Contract | Новая форма | Новая форма | Убрать fallback, затем удалить старые колонки отдельными шагами | Есть подтверждённый план отката или принято решение жить без него |
Fallback в этой таблице — не молчаливый костыль. Он должен быть измеримым: например, код увеличивает счётчик чтений старой формы и пишет идентификатор потребителя. Если fallback не виден, команда не может доказать, что contract безопасен.
Для PostgreSQL 16 минимальный первый шаг может выглядеть так:
BEGIN;\nSET LOCAL lock_timeout = '1s';\nALTER TABLE users ADD COLUMN display_name text;\nCOMMIT;1s здесь — проектный пример, а не универсальное значение. При тайм-ауте транзакция должна завершиться с ошибкой, а миграционный runner — оставить понятный результат для повторного запуска. Документация PostgreSQL указывает, что требуемый lock level зависит от формы ALTER TABLE, а без специальной оговорки берётся ACCESS EXCLUSIVE. Поэтому nullable-колонка без backfill всё равно может ждать уже занятую блокировку.
После expand старый binary не должен требовать новую колонку. На этом шаге не добавляйте без проверки NOT NULL, тяжёлый default, изменение типа и удаление старых полей в ту же операцию. У этих действий другая стоимость и другой риск. Сначала отдельно подтвердите, что схема появилась, старые запросы всё ещё проходят, а повторный запуск миграции обрабатывается вашей системой миграций.
Новый writer должен в одной логической операции сохранить обе формы. Простейшая чистая функция показывает контракт, но не притворяется ORM или транзакцией:
function formatDisplayName(firstName, lastName) {\n return [firstName, lastName]\n .filter((part) => part !== null && part !== undefined && part !== '')\n .join(' ');\n}\n\nfunction readDisplayName(row) {\n if (row.display_name !== null && row.display_name !== undefined) {\n return row.display_name;\n }\n return formatDisplayName(row.first_name, row.last_name);\n}\n\nfunction writeUser(input) {\n return {\n ...input,\n display_name: formatDisplayName(input.first_name, input.last_name),\n };\n}\n\nconsole.assert(\n readDisplayName({ display_name: null, first_name: 'Ada', last_name: 'Lovelace' }) ===\n 'Ada Lovelace',\n);\nconsole.assert(\n writeUser({ first_name: 'Ada', last_name: 'Lovelace' }).display_name ===\n 'Ada Lovelace',\n);В приложении результат writeUser нужно записать атомарно с изменением исходных полей. Если ORM выполняет два независимых запроса, ошибка между ними создаёт ещё один промежуточный формат. Поэтому проверяйте не только функцию, но и границу транзакции, обработку повторной попытки и поведение при конфликте.
Старые строки заполняются отдельным backfill. Идемпотентная пачка для PostgreSQL 16 может быть такой:
UPDATE users\nSET display_name = trim(concat_ws(' ', first_name, last_name))\nWHERE id > $1\n AND id <= $2\n AND display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL);Параметры $1 и $2, размер пачки и пауза между пачками зависят от runner и нагрузки. Условие display_name IS NULL защищает уже заполненную строку от повторной записи, но не решает доменное различие между «пусто» и «ещё не обработано». Если исходные поля меняются во время backfill, задайте конфликтное правило: общий транзакционный writer, версия строки или повторная сверка.
Перед switch сравните старую и новую формы, а не просто посчитайте обработанные строки. Для примера полезно разделить строки без исходных данных и строки с расхождением:
SELECT\n count(*) FILTER (\n WHERE display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL)\n ) AS missing_display_name,\n count(*) FILTER (\n WHERE display_name IS NOT NULL\n AND display_name <> trim(concat_ws(' ', first_name, last_name))\n ) AS different_display_name\nFROM users;Нулевой результат этих двух счётчиков ещё не доказывает готовность всей системы. Нужно проверить потребителей: старый binary, новый binary, фоновые workers, экспорт, SQL-запросы и кэш. Для каждой записи в журнале изменения должно быть понятно, кто её пишет и какая форма будет прочитана после переключения.
Переводите reader отдельно от writer. Сначала новый reader использует display_name и считает fallback. Затем включайте новый путь постепенно, если такая возможность есть. Наблюдайте ошибки чтения, задержку, lock wait, отставание реплик и количество fallback. При росте ошибки верните reader на совместимый fallback, но оставьте dual write: откат чтения не должен остановить поддержание обеих форм.
После switch старые колонки ещё нужны для rollback и для забытых потребителей. Удаляйте их минимум двумя отдельными изменениями: сначала код и fallback, затем схема. Для старых данных полезно иметь явный сигнал готовности, например отсутствие fallback за согласованное окно наблюдения и успешные проверки всех потребителей. Само по себе отсутствие ошибок в основном endpoint не является таким сигналом.
Индексы и ограничения требуют отдельного плана. PostgreSQL описывает CREATE INDEX CONCURRENTLY как способ не блокировать обычные вставки, обновления и удаления, но такая операция делает больше работы, ждёт другие транзакции и не выполняется внутри транзакционного блока. Она не превращает любой DDL в безопасный для production и не отменяет проверку диска, CPU, репликации и времени ожидания.
Если нужно добавить уникальность или NOT NULL, разделите проверку существующих данных и изменение контракта. Например, constraint можно сначала добавить как NOT VALID, затем проверить и валидировать отдельной операцией, если конкретный тип ограничения и версия PostgreSQL это поддерживают. Не копируйте этот приём для каждого ограничения: синтаксис, блокировки и поведение нужно сверить с документацией вашей версии.
concat_ws не заменяет это решение.Expand/switch/contract не гарантирует нулевую блокировку и не заменяет резервное копирование, репликацию и проверку восстановления. На результат влияют major-версия PostgreSQL, размер и партиционирование таблицы, индексы, триггеры, ORM, внешние readers, длительные транзакции, политика lock timeout и способ доставки релиза. Для MySQL, другой СУБД или другой major-версии PostgreSQL нельзя механически переносить lock behavior из этого примера.
К contract переходите только когда новая форма заполнена и сверена, каждый известный writer поддерживает её, readers больше не обращаются к старым полям, fallback не нужен по наблюдаемому сигналу, а команда понимает необратимые последствия удаления. Если один пункт нельзя доказать запросом, метрикой, логом или тестом, остановитесь на dual write и продолжите сбор сведений.
На практике полезно начать с маленькой таблицы или копии данных: выполнить expand, остановить backfill, вернуть старый binary и проверить чтение записи, созданной новым writer. Такой сценарий показывает реальный путь отката лучше, чем зелёный финальный smoke test.
NOT VALID.CONCURRENTLY.Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.
Часто ломается не сама трассировка, а граница между компонентами. Proxy удаляет неизвестный заголовок, middleware создаёт новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Исправление должно проверять весь путь: что отправили, что пропустил proxy, что разобрал сервис и что записал logger.
W3C Trace Context задаёт переносимый HTTP-заголовок traceparent. Он связывает участки обработки одного запроса, но не гарантирует наличие span, корректность логирования или сохранность заголовка на каждом proxy. Поэтому проверять нужно не только парсер. Нужен контракт между клиентом, gateway, middleware и сервисом.
Валидный внешний контекст можно продолжить как родительский. После этого локальная библиотека создаёт новый span для текущего сервиса. Невалидный контекст нельзя молча чинить: сервис отбрасывает его, создаёт новый локальный trace и фиксирует короткую причину отказа. Trace-id не заменяет авторизацию и не должен содержать пользовательские данные.
traceparent состоит из четырёх полей: версии, trace-id, parent-id и flags. Для базового формата важны точные длины, lowercase hexadecimal и ненулевые идентификаторы. Ошибка в одном поле делает вход непригодным для продолжения. Парсер должен вернуть результат проверки, а не угадывать намерение отправителя.
Proxy — часть этого контракта. Он может удалить неизвестный заголовок, нормализовать имя, ограничить размер или создать собственный контекст. Локальный тест функции парсинга не доказывает, что значение дошло до приложения. Проверка должна проходить через ту же пару gateway и service, с которой работает запрос.
Даже сохранённый trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. Если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.
Ниже парсер получает одну строку и возвращает разобранные поля только после проверки формата. Для плохого входа он отдаёт короткую причину, которую можно считать в метрике без записи полного заголовка. Пример не создаёт span и не отправляет telemetry: он показывает только проверяемую границу.
import { parseTraceparent } from './upgrade-2027-10.mjs'; const valid = parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'); const invalid = parseTraceparent('00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01'); console.log(valid.ok, valid.traceId.slice(0, 8)); console.log(invalid.ok, invalid.reason); // true 4bf92f35 // false trace-id-invalidВ примере uppercase trace-id отклоняется. Это controlled fallback, а не 500: сервис не принимает повреждённую строку как родительский контекст и не использует её как право доступа. Конкретную политику для version и flags нужно закрепить интеграционным тестом выбранной библиотеки.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В gateway и service разные trace-id | Proxy удалил заголовок или middleware начал новый trace | Сравнить raw-заголовок до и после proxy | Разрешить передачу traceparent и проверить продолжение валидного контекста |
| Контекст есть только в локальном запуске | Реальный proxy применяет другой allow-list или лимит размера | Повторить запрос через gateway с теми же правилами | Добавить integration test для gateway + service |
| Валидный на вид заголовок отклонён | Uppercase, неверная длина, нулевой id или неподдерживаемая версия | Прогнать parser fixture на каждое поле | Отбросить вход и записать безопасную причину |
| По trace-id не находится событие | Logger пишет другой ключ или sampling удалил event | Проверить структурное поле и политику sampling | Унифицировать JSON key и сохранить ошибку парсинга как счётчик |
| После HTTP-запроса теряется связь с очередью | HTTP-контекст не перенесён в механизм брокера | Проверить envelope или headers сообщения | Использовать механизм контекста очереди и отдельный link между участками |
Этот подход проверяет HTTP-заголовок и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, backend sampling, полноту collector и форматы контекста Kafka или другой очереди. Он также не делает trace-id бизнес-идентификатором: повтор операции и асинхронная обработка требуют отдельного безопасного operation-id.
Готовность можно считать доказанной, если интеграционный тест через реальный proxy сохраняет один trace-id на границе gateway и service, валидный контекст получает локальный span, а повреждённый вход приводит к новому локальному trace без 500. Логи должны содержать единый trace-id key, а проверка не должна превращать внешний идентификатор в секрет или разрешение на действие.
Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.
Чаще ломается не сама трассировка, а граница между компонентами. Proxy не пропускает заголовок, middleware начинает новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Главный вопрос статьи — как доказать, что один HTTP-запрос сохранил контекст на этой границе, не превратив trace-id в право доступа или бизнес-идентификатор.
W3C Trace Context задаёт переносимые HTTP-заголовки traceparent и tracestate. Первый несёт общий trace-id, идентификатор родительской операции и flags; второй хранит необязательное состояние конкретных систем трассировки. Контекст связывает участки обработки, но не гарантирует наличие span, корректность логирования или доставку telemetry.
На границе нужно заранее выбрать один из трёх режимов. Обычный proxy пересылает валидный контекст. Инструментированный proxy участвует в трассе, создаёт свой span и меняет parent-id для следующего участка. Защитный gateway может начать новую трассу на доверенной границе. Последний вариант разрывает внешнюю корреляцию по решению безопасности, поэтому его нельзя выдавать за «потерю заголовка».
| Режим | Что уходит дальше | Цена | Когда применять |
|---|---|---|---|
| Forward | Валидные traceparent и tracestate; proxy не создаёт span | Дешевле, но задержка внутри proxy не видна отдельным участком | Тонкий reverse proxy без собственной инструментализации |
| Participate | Тот же trace-id, новый parent-id и span proxy | Нужны SDK, sampling и единые правила имён | Когда задержка маршрутизации входит в SLO сервиса |
| Restart | Новый локальный trace; внешний контекст не становится родителем | Корреляция через границу теряется | Явная trust boundary или защита от злоупотребления входным контекстом |
Для обычной внутренней HTTP-границы начинайте с forward или participate. Владелец proxy должен записать режим в конфигурации и интеграционном тесте. Иначе команда будет спорить по логам, где разные trace-id могут быть как дефектом, так и намеренным restart.
В формате версии 00 значение traceparent содержит четыре поля: version-trace-id-parent-id-trace-flags. Это две hex-цифры версии, 32 lowercase hex-цифры trace-id, 16 lowercase hex-цифр parent-id и две lowercase hex-цифры flags. Trace-id и parent-id не могут состоять из одних нулей. Имя HTTP-заголовка регистронезависимо, но отправлять его следует в lowercase; uppercase внутри значений — уже другая проверка.
Версия 00 — формат, который проверяет пример ниже. Спецификация описывает правила для будущих версий: pass-through-компонент не должен без причины разбирать неизвестное расширение, а инструмент, который участвует в трассе, обязан иметь политику обработки более высокой версии. Поэтому «не поддерживаем version» — это проектное решение парсера, а не универсальное требование W3C.
Извлечение выполняет propagator, а не случайный middleware с split('-'). Невалидный carrier не должен приводить к исключению и не должен записываться в контекст как новый родитель. Если валидного входящего контекста нет, SDK создаёт локальный trace по своей политике. Для валидного удалённого контекста текущий сервис создаёт свой span и передаёт downstream новый parent-id, сохраняя trace-id.
Proxy проверяется отдельно от библиотеки. Сначала выясните, пропускает ли его allow-list заголовок, затем проверьте лимит размера и поведение при нескольких значениях. Нормализация регистра имени — нормальна для HTTP, а удаление поля, непреднамеренный restart или разные правила для HTTP/1.1 и HTTP/2 — уже часть вашего runtime-контракта. Локальный unit test парсера этого не доказывает.
Trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. OpenTelemetry отдельно определяет HTTP span и его атрибуты; если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.
Этот фрагмент можно сохранить как отдельный файл и запустить в Node.js без пакетов. Он проверяет только синтаксический контракт версии 00: не создаёт span, не обращается к collector и не утверждает, что заголовок дошёл через proxy. Причина отказа короткая, поэтому её можно считать в метрике без сохранения полного внешнего значения.
function parseTraceparent(value) { if (typeof value !== 'string') return { ok: false, reason: 'missing-or-not-string' }; const parts = value.split('-'); if (parts.length !== 4) return { ok: false, reason: 'field-count' }; const [version, traceId, parentId, flags] = parts; if (!/^[0-9a-f]{2}$/.test(version) || version === 'ff') return { ok: false, reason: 'version-invalid' }; if (version !== '00') return { ok: false, reason: 'version-unsupported-by-example' }; if (!/^[0-9a-f]{32}$/.test(traceId) || /^0+$/.test(traceId)) return { ok: false, reason: 'trace-id-invalid' }; if (!/^[0-9a-f]{16}$/.test(parentId) || /^0+$/.test(parentId)) return { ok: false, reason: 'parent-id-invalid' }; if (!/^[0-9a-f]{2}$/.test(flags)) return { ok: false, reason: 'trace-flags-invalid' }; return { ok: true, version, traceId, parentId, flags }; } const fixtures = [['valid', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'], ['uppercase-value', '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01'], ['zero-parent', '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01']]; for (const [name, value] of fixtures) { const result = parseTraceparent(value); console.log(name, result.ok ? 'accepted' : result.reason); } // valid accepted // uppercase-value trace-id-invalid // zero-parent parent-id-invalidUppercase trace-id здесь отклоняется, хотя имя заголовка TraceParent само по себе допустимо. Это controlled fallback, а не HTTP 500: сервис не использует повреждённую строку как родительский контекст. В production коде такую проверку лучше отдать официальному propagator выбранного SDK, а fixture оставить как контракт интеграции и регрессионный тест.
| Симптом | Вероятная граница | Проверка | Действие |
|---|---|---|---|
| В gateway и service разные trace-id | Proxy удалил заголовок, либо выбран restart | Сравнить raw-заголовок до и после proxy и прочитать режим | Исправить allow-list или документировать trust boundary |
| Контекст есть только локально | Runtime применяет другой маршрут, лимит или фильтр | Повторить запрос через тот же gateway с теми же правилами | Добавить integration test gateway + service |
| Валидный на вид заголовок отклонён | Uppercase в значении, длина, нулевой id или version | Прогнать fixture на каждое поле и записать reason | Отбросить вход без 500; не логировать полное значение |
| По trace-id не находится событие | Другой ключ, sampling или задержка доставки | Проверить структурное поле, span и exporter | Развести correlation gap и отсутствие telemetry |
| После HTTP-запроса теряется связь с очередью | HTTP carrier не перенесён в message carrier | Проверить envelope или headers сообщения | Настроить propagator брокера и отдельный link при необходимости |
traceparent до proxy и на входе сервиса. Не передавайте в логах пользовательские данные и не делайте trace-id секретом.ff и выбранную политику для более высокой версии. Для каждого случая зафиксируйте expected decision.Этот подход проверяет HTTP carrier и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, полноту collector, backend sampling или форматы контекста Kafka и другой очереди. Для асинхронного сообщения нужен отдельный message carrier; иногда правильнее использовать span link, а не притворяться прямым parent-child. Повтор операции и бизнес-связь требуют отдельного безопасного operation-id.
Готовность доказана, когда интеграционный тест через реальный proxy подтверждает выбранный режим, валидный context получает ожидаемый локальный span, а повреждённый вход приводит к controlled fallback без 500. Логи используют единый trace-id key, span boundaries позволяют искать задержку, а документация явно говорит, где сделан restart. Если эти условия не проверены, проблема «trace-id пропал» остаётся гипотезой.
Проблема обычно выглядит противоречиво: браузер открывает адрес, а сервисный клиент получает certificate error; либо один контейнер подключается, а второй — нет. Цена ошибки — отключить проверку TLS «временно», потерять имя хоста в диагностике и превратить сетевую проблему в уязвимость.
Причина в том, что «сертификат валиден» — это не одна проверка. Клиент строит цепочку до доверенного корня, проверяет период действия, имя назначения и ограничения сертификата. Разные хранилища корней, SNI, proxy и часы системы меняют результат. Нужно разделить слой протокола, X.509-структуру и локальную политику доверия.
TLS handshake создаёт защищённый канал, но доверие к peer не появляется из шифрования автоматически. Сертификат содержит открытый ключ, имя и подпись издателя; клиент проверяет цепочку и применимость к назначенному хосту. Если запрос идёт на api.example.test, сертификат только для admin.example.test не должен считаться подходящим из-за того, что ключ технически рабочий.
Период действия проверяется по часам клиента. Ошибка в системном времени даёт симптом «сертификат ещё не действителен» или «истёк», хотя сервер ничего не менял. Переход на другой контейнер может поменять корневое хранилище и набор промежуточных сертификатов. Поэтому при сравнении сред нужно собирать не только URL, но и hostname, SNI, trust store, время и цепочку.
| Проверка | Вопрос | Отказ | Что собрать |
|---|---|---|---|
| Срок | now между notBefore и notAfter? | not-yet-valid / expired | UTC-время клиента и поля сертификата |
| Имя | host есть в SAN? | hostname mismatch | SNI, hostname и SAN |
| Цепочка | есть путь до доверенного корня? | unknown issuer | leaf, intermediate, trust store |
| Подпись | алгоритм и ключ разрешены? | signature/algorithm error | TLS policy и negotiated version |
| Отзыв | политика проверяет статус? | revoked/unknown | OCSP/CRL policy и доступность |
Флаг вроде insecure меняет вопрос с «можно ли доверять peer» на «зашифрован ли канал до кого-то». Запрос начинает проходить, но факт успеха перестаёт говорить о подлинности сервера. Если потом этот флаг попадёт в общий клиент или пример конфигурации, временная отладка станет постоянной дырой.
Надёжнее вывести диагностическую информацию без обхода проверки: имя хоста, SNI, цепочку, срок, код ошибки и идентификатор корня. В тестовой среде можно добавить собственный CA в доверенное хранилище или передать его явно. Такой путь сохраняет настоящую проверку и делает отличие среды видимым.
Функция ниже не строит X.509-цепочку и не заменяет TLS-библиотеку. Она принимает ISO-даты, hostname и список SAN, затем показывает две базовые проверки, которые полезно видеть в тестах и диагностическом выводе. Входы специально простые; ожидаемый результат различает успех, истёкший сертификат и несовпадение имени.
import { validateCertificateWindow } from './upgrade-2027-10.mjs';\n\nconst common = {\n host: 'api.example.test',\n sans: ['api.example.test', 'api.internal.test'],\n notBefore: '2026-01-01T00:00:00Z',\n notAfter: '2027-01-01T00:00:00Z',\n};\n\nconsole.log(validateCertificateWindow({ ...common, now: '2026-06-01T00:00:00Z' }));\nconsole.log(validateCertificateWindow({ ...common, host: 'cdn.example.test', now: '2026-06-01T00:00:00Z' }).reason);\n// { ok: true, reason: 'certificate-window-and-san-match' }\n// host-not-listed-in-sanНаличие intermediate в файле сервера не означает, что клиент доверяет корню. И наоборот, локальный trust store может содержать корень, но сервер не отправить промежуточный сертификат. Успешная проверка строит путь по подписи и ограничениям, а не по совпадению строк в PEM-файле. Это объясняет, почему «в браузере работает» может быть правдой одновременно с ошибкой минимального контейнера.
Сертификат также не сообщает всю эксплуатационную политику. Клиент может проверять отзыв, запрещать старый алгоритм или требовать минимальную версию TLS. Если диагностический отчёт пишет только «certificate valid», он скрывает полезную часть причины. Сохраняйте код ошибки библиотеки и параметры соединения, а секретный ключ и полное содержимое лишний раз не логируйте.
Учебная функция не проверяет подпись, CRL, OCSP, wildcard-правила, DNS и реальное TLS-согласование. Она не является security scanner. Её роль — сделать две часто потерянные проверки явными и тестируемыми без сетевой зависимости.
Следующий шаг — воспроизвести ошибку в том же контейнере, где работает сервис, собрать hostname/SNI, цепочку, время и код ошибки, а затем исправить конкретный слой. После исправления оставьте regression test на истёкший срок и неверный SAN, чтобы повторное «временное» отключение доверия стало заметным.
Симптом знакомый: curl -I https://api.example.test/health с ноутбука возвращает ответ, а приложение в контейнере получает ошибку сертификата. Иногда браузер открывает тот же адрес, но PHP cURL сообщает unable to get local issuer certificate. Цена неверного вывода — не только потерянное время. Если сделать запрос успешным через отключение проверки, сервис может отправить токен или платёжные данные узлу, чью личность он не подтвердил.
Главный вопрос здесь не «работает ли curl», а «какой именно клиент, с каким trust store и каким именем прошёл какие проверки». Успешный запрос доказывает конкретный маршрут, часы, TLS-библиотеку, набор доверенных центров сертификации и политику одного процесса. Это не сертификат исправности браузера, другого контейнера или production-конфигурации. Ниже разберём границы доказательства и оставим runbook, который можно повторить в том же окружении, где живёт ошибка.
\nTLS не начинается с HTTP-статуса. Сначала клиент устанавливает соединение, договаривается о параметрах и получает от сервера сертификат или цепочку сертификатов. Затем он решает, можно ли доверять цепочке и подходит ли заявленная идентичность имени из URL. Только после успешного обычного handshake появляется защищённый канал для HTTP.
\nПоэтому фраза «curl работает» слишком короткая. Она может означать: CLI использовал встроенный путь к CA bundle, попал на другой IP, получил сертификат для нужного виртуального хоста и проверил его по часам этой машины. PHP-процесс в контейнере может использовать другой libcurl, другой TLS backend, другой файл доверия, другой proxy и другое системное время. Оба результата будут честными, но отвечать на разные вопросы.
\nURL и hostname\n -> DNS и TCP-маршрут\n -> ClientHello с SNI (если клиент его отправляет)\n -> сертификат и цепочка от сервера\n -> путь до доверенного CA + срок + политика\n -> совпадение имени с сертификатом\n -> обычный HTTP-запрос\nВ TLS 1.3 серверное сообщение Certificate передаёт цепочку, когда аутентификация опирается на сертификат. Сам протокол описывает handshake и защищённый канал, но подробные правила проверки X.509 и сопоставления имени дополняются профилями и политикой клиента. Это важная граница: TLS сообщает, как обменяться сертификатом и ключами, но не выбирает за приложение доверенный корень.
| Слой | Кто владеет | Успех означает | Ещё нужно проверить |
|---|---|---|---|
| Маршрут | DNS, сеть, proxy | Клиент дошёл до некоторого TLS endpoint | Что это нужный IP и proxy-путь |
| SNI и имя | Клиент и TLS-сервер | Выбранный сертификат покрывает hostname из URL | SAN, redirect и имя, которое видит приложение |
| Цепочка | Сервер и trust store клиента | Из leaf можно построить путь к доверенному CA | Все intermediate и состав доверия этого процесса |
| Время | Часы клиента | now попадает в окно notBefore–notAfter | UTC-время контейнера и узла |
| Политика | TLS backend и приложение | Алгоритмы, версии и режим проверки разрешены | Настройки конкретной сборки и требования endpoint |
Сертификат не является одним флагом «валиден». RFC 5280 описывает путь сертификации: сертификаты в цепочке должны связываться по issuer/subject, быть действительными в рассматриваемый момент и вести к trust anchor. Выбор trust anchor — локальная политика. Поэтому одинаковый PEM-файл на двух машинах не гарантирует одинаковый результат, если процессы читают разные файлы или один из них использует системное хранилище.
\nВремя — такой же вход, как URL. Поле notBefore задаёт начало, а notAfter — конец периода действия. Отставшие часы дают ошибку «ещё не действителен», спешащие — «истёк». Повторный запрос или новый DNS-ответ это не исправят. Снимайте время именно внутри контейнера или виртуальной машины, где запущен клиент.
SNI (Server Name Indication) — расширение ClientHello, в котором клиент сообщает имя сервера. Виртуальный хост использует его, чтобы выбрать сертификат среди нескольких конфигураций на одном IP. После этого клиент всё равно должен сопоставить ожидаемое имя с идентификатором в сертификате. SNI помогает получить правильный сертификат, но не превращает неправильное имя в правильное.
\nДля HTTPS ожидаемое имя обычно берётся из hostname URL. Заголовок Host отправляется на HTTP-уровне позже. Если клиент подключился к IP и получил сертификат default-vhost, добавление другого Host не отменяет уже случившийся отказ TLS. Когда нужно проверить конкретный IP, сохраняйте hostname и меняйте только маршрут. Для этого подходит контролируемый тест вроде curl --resolve api.example.test:443:IP https://api.example.test/health, где IP заменяется на адрес из вашей инфраструктуры.
RFC 9525 описывает service identity через reference identifier и presented identifier. Для DNS-имени это означает сравнение имени клиента с DNS-ID в subjectAltName; wildcard тоже подчиняется правилам сопоставления, а не произвольному поиску подстроки. Поэтому проверка «в сертификате где-то встречается нужное слово» недостаточна. Смотрите SAN и фактический hostname, а не только Common Name в старом просмотрщике.
CLI cURL и PHP cURL могут выглядеть одинаково в логе, но иметь разную конфигурацию. Официальная документация cURL отмечает, что CA store зависит от сборки и TLS backend: в одних окружениях используется файл, в других — нативное хранилище ОС. PHP задаёт curl.cainfo как значение по умолчанию для CURLOPT_CAINFO, и для него нужен абсолютный путь. Это уже две точки расхождения до того, как приложение добавило собственные настройки.
Проверка peer и проверка имени — отдельные операции. CURLOPT_SSL_VERIFYPEER => true проверяет, что сертификат можно связать с доверенным CA. CURLOPT_SSL_VERIFYHOST => 2 проверяет заявленное имя. Выключение первой операции не исправляет вторую; выключение обеих только убирает доказательство личности. Шифрование канала при этом может остаться, но оно не отвечает на вопрос, с тем ли endpoint вы говорите.
Опция --cacert или CURLOPT_CAINFO — способ явно указать доверенный CA bundle для конкретного клиента. Это не команда «взять сертификат у сервера и доверять ему». Для публичного endpoint нужен поставляемый и проверенный набор доверенных CA; для частного CA — согласованный владельцем инфраструктуры сертификат корневого центра и контролируемая доставка. Если сервер не прислал intermediate, добавление leaf в общий bundle маскирует проблему вместо исправления серверной цепочки.
Ниже минимальный CLI-скрипт без фреймворка. Он принимает URL и, при необходимости, существующий абсолютный путь к CA bundle. В коде проверка peer и имени включена явно, а номер ошибки и текст сохраняются до закрытия дескриптора. Значения таймаутов здесь учебные: они не являются рекомендацией для каждого API.
\n<?php\nfunction request($url, $caFile = null)\n{\n if ($caFile !== null and !is_readable($caFile)) {\n throw new RuntimeException('CA file is not readable: ' . $caFile);\n }\n\n $handle = curl_init($url);\n if ($handle === false) {\n throw new RuntimeException('curl_init failed');\n }\n\n $options = array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_SSL_VERIFYPEER => true,\n CURLOPT_SSL_VERIFYHOST => 2,\n CURLOPT_CONNECTTIMEOUT => 5,\n CURLOPT_TIMEOUT => 15,\n );\n\n if ($caFile !== null) {\n $options[CURLOPT_CAINFO] = $caFile;\n }\n\n curl_setopt_array($handle, $options);\n $body = curl_exec($handle);\n $errorNo = curl_errno($handle);\n $error = curl_error($handle);\n $info = curl_getinfo($handle);\n curl_close($handle);\n\n if ($body === false) {\n throw new RuntimeException('cURL ' . $errorNo . ': ' . $error);\n }\n\n return array(\n 'httpStatus' => $info['http_code'],\n 'sslVerifyResult' => $info['ssl_verifyresult'],\n 'bodyBytes' => strlen($body),\n );\n}\n\n$url = $argv[1] ?? 'https://example.com/';\n$caFile = $argv[2] ?? null;\nvar_export(request($url, $caFile));\nЗапуск без второго аргумента использует CA store, который видит libcurl процесса:
\nphp tls-check.php https://api.example.test/health\nphp tls-check.php https://api.example.test/health /absolute/path/to/ca-bundle.pem\nЕсли доверие не строится, скрипт завершится исключением до HTTP-статуса. Если handshake прошёл, результат содержит HTTP-код и значение ssl_verifyresult, но это не заменяет проверку ответа API. Успешный 200 может означать только то, что TLS и HTTP для данного запроса завершились; он не доказывает правильность авторизации, данных или бизнес-операции.
curl --version, php --ini и php -i | grep -E 'curl.cainfo|openssl.cafile' в том же окружении. Версия CLI не является версией libcurl внутри PHP.curl -vS --connect-timeout 5 --max-time 15 https://api.example.test/health -o /dev/null. Не публикуйте в тикете cookies, authorization-заголовки и чувствительные URL. Вывод показывает путь диагностики, но не заменяет тест приложения.openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null. Ключ -servername важен для виртуального хоста. Этот инструмент показывает выданные сертификаты; итоговое доверие всё равно оценивайте с тем CA bundle, который использует приложение.--resolve, сохранив исходное имя. Не заменяйте hostname на IP как «проверку»: это меняет проверяемую идентичность.date -u внутри процесса или контейнера и убедитесь, что пользователь приложения может читать заявленный CA bundle. Ошибка доступа к файлу и отсутствие нужного корня выглядят по-разному на уровне причины, но обе проявляются до HTTP.CURLOPT_CAINFO или настройку curl.cainfo. Если проблема в intermediate, исправьте цепочку на TLS-сервере. Если проблема в SAN, перевыпустите сертификат с правильным именем. Не меняйте все слои одновременно.php.ini перезапустите PHP-FPM или другой долгоживущий процесс. Выполните безопасный health-запрос из того же контейнера и сохраните путь к bundle, версии и команду отката.Исправление имеет владельца. Неполная цепочка — задача владельца TLS endpoint; отсутствующий корпоративный root — задача поставки trust store; неверный SAN или SNI — задача конфигурации имени и виртуального хоста; неверные часы — задача среды; запрещённый алгоритм или версия — задача совместимости и политики. Смена CA bundle не лечит все четыре случая.
\ncurl -k и CURLOPT_SSL_VERIFYPEER => false полезны только как короткий локальный эксперимент, когда нужно доказать, что дальше есть HTTP-ответ. Они не должны попадать в production-конфигурацию, общий helper или постоянный пример. Такой прогон отвечает на вопрос «можно ли продолжить без проверки», но не на вопрос «кому мы отправляем данные».
Не скачивайте новый bundle по URL при каждом старте и не добавляйте в него любой сертификат, который встретился в handshake. CA bundle — часть поставки и политики доверия, а не кеш наблюдаемого ответа. Не считайте проверку сертификата доказательством прав доступа: после TLS остаются HTTP-аутентификация, авторизация, корректность endpoint и безопасность данных.
\nЭтот разбор не обещает диагностировать отзыв сертификата одинаково во всех TLS backend: поддержка CRL, OCSP и режима «best effort» зависит от клиента и политики. Он также не проверяет DNSSEC, корректность proxy, mutual TLS, pinning или авторизацию API. openssl s_client без явного набора параметров не является полным эквивалентом PHP cURL. Учебный скрипт не валидирует X.509 сам и не должен становиться security scanner.
Считайте работу готовой, когда один и тот же PHP-SAPI в целевом окружении читает ожидаемый trust store, hostname совпадает с SAN, цепочка строится до согласованного CA, часы корректны, проверка peer и имени остаётся включённой, а безопасный запрос проходит без ошибки TLS. После этого отдельно проверяется HTTP-контракт. Вот почему «curl работает» — полезный сигнал, но не закрытие проверки: закрыть её можно только воспроизводимым результатом всех условий конкретного клиента.
\nCertificate и границы ответственности TLS.--cacert, --insecure и различия native/file-based хранилищ.curl.cainfo и требование абсолютного пути.Запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Пользователь видит ошибку, хотя сервер мог завершить операцию. Хуже случай с записью: клиент повторяет POST, не зная, успел ли первый запрос попасть в обработчик. Цена ошибки — потерянный результат, двойное изменение состояния и лишняя нагрузка на upstream.
Симптом не говорит, где потрачено время. Один общий timeout смешивает DNS, TCP, TLS, отправку тела, ожидание первого байта и чтение ответа. Повторная попытка получает новый полный бюджет и скрывает исходную причину. Тезис статьи прост: задайте один абсолютный deadline для операции, разложите его на наблюдаемые фазы и разрешайте retry только после проверки семантики метода.
Общий deadline отвечает на вопрос: до какого момента результат имеет смысл для вызывающего кода. Фазовый timeout отвечает на другой вопрос: сколько можно ждать конкретный переход. Если дать DNS, TCP, TLS и чтению по 800 мс каждому, запрос может жить несколько секунд. Если ограничить connect 50 мс, холодное разрешение имени превратит нормальный запрос в ложный отказ.
Храните абсолютный момент окончания или остаток бюджета. Перед каждой фазой вычисляйте remaining = deadline - now. Если остатка нет, завершайте операцию до следующего сетевого вызова. Новый независимый таймер после истечения deadline нарушает контракт вызывающего кода.
DNS показывает время разрешения имени. TCP connect — установление соединения. TLS handshake — защищённый обмен и проверку сертификата. Request write — передачу заголовков и тела. TTFB показывает ожидание первого байта, а read — получение остального ответа. Эти интервалы отвечают на разные вопросы, поэтому один счётчик total duration не заменяет их.
| Фаза | Что измеряем | Типичный симптом | Действие |
|---|---|---|---|
| DNS | Разрешение имени | Медленный первый запрос | Проверить resolver, кэш и лимит DNS |
| TCP connect | Открытие соединения | Отказ до TLS | Проверить маршрут, pool и connect timeout |
| TLS handshake | Защищённый обмен | Соединение есть, ответа нет | Проверить цепочку, crypto и TLS budget |
| Request write | Отправка тела | Зависает upload | Проверить размер, backpressure и write timeout |
| TTFB/read | Ответ и тело | Клиент ждёт сервер | Сопоставить server time, read timeout и payload |
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Timeout без статуса HTTP | Истёк deadline до первого байта или клиент закрыл сокет | Сравнить timestamps DNS, connect, TLS, TTFB и server log | Разделить фазовые интервалы и не называть отказ HTTP-статусом |
| Первый запрос медленный, следующие быстрые | Cold DNS, TCP или TLS скрыты pool | Сравнить cold и warm соединения | Измерять установление соединения отдельно от чтения |
| POST повторяет изменение | Retry запущен без доказанной идемпотентности | Проверить метод, operation-id и результат первого вызова | Добавить ключ дедупликации или сначала читать статус операции |
| Каждый retry ждёт полный timeout | Попытки не делят общий deadline | Посчитать абсолютный deadline всего запроса | Передавать остаток бюджета, а не запускать новый полный таймер |
| В логах только total duration | Клиент не экспортирует фазы | Проверить API instrumentation и события pool | Фиксировать известную границу и не выдавать реконструкцию за факт |
Timeout не доказывает, что сервер ничего не сделал. Для GET повтор обычно допустим, если ресурс читает актуальное состояние и клиент принимает возможное изменение данных между попытками. Для POST, PUT и DELETE решение зависит от семантики операции. Нужны идемпотентность, ключ дедупликации или endpoint для проверки статуса. Одного сетевого флага retry недостаточно.
Три попытки по 800 мс дают до 2,4 секунды ожидания без учёта задержек и одновременно утроенную нагрузку на upstream. Общий deadline должен охватывать все попытки. Если остатка мало, новая попытка не должна начинать работу. Для записи безопаснее вернуть состояние «результат неизвестен» и проверить operation-id, чем отправлять запрос повторно наугад.
Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не открывает socket, не моделирует отмену запроса и не заменяет таймеры HTTP-клиента. Его граница — проверка арифметического инварианта отдельно от сети.
import { allocateTimeoutBudget } from './upgrade-2027-10.mjs';\n\nconst within = allocateTimeoutBudget({\n totalMs: 800,\n dnsMs: 42,\n tlsMs: 88,\n requestMs: 510,\n});\nconst late = allocateTimeoutBudget({\n totalMs: 800,\n dnsMs: 120,\n tlsMs: 210,\n requestMs: 560,\n});\n\nconsole.log(within.ok, within.remainingMs);\nconsole.log(late.ok, late.reason, late.remainingMs);\n// true 160\n// false deadline-exceeded 0В первом вызове потрачено 640 мс, поэтому остаётся 160 мс. Во втором сумма равна 890 мс: функция возвращает deadline-exceeded и нулевой остаток. Реальный адаптер должен дополнительно отменять вложенные DNS, socket и чтение, иначе завершение функции не остановит работу сокета.
Большой TTFB не доказывает, что сервер медленный: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Клиентский timeout не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.
Если библиотека отдаёт только total duration, реконструированные интервалы нельзя называть фактами. Выберите instrumented adapter или события connection pool. До этого фиксируйте только известную границу: «клиент прекратил ждать через N мс». Неполное, но честное измерение лучше точного на вид вымысла.
Подход не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и детали конкретного proxy. Учебные числа не являются нормативными значениями для вашей сети. RFC описывает протокол, но не выбирает таймауты, retry policy или параметры клиента. Для боевой системы нужен адаптер с отменой всех вложенных операций и отдельная проверка безопасности повторов.
Материал готов к применению, если интеграционный тест для выбранного endpoint показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, а просроченная операция останавливает вложенное чтение. Для записи дополнительно нужен проверяемый operation-id: после timeout система может узнать, был ли первый вызов принят. Если хотя бы одна граница не наблюдается, результат следует считать недоказанным, а не успешным.
Клиент отправляет запрос с бюджетом 800 мс. Сервер отвечает примерно через 900 мс, поэтому пользователь получает ошибку, хотя обработчик мог успеть изменить данные. Если после этого автоматически повторить POST, операция может выполниться дважды. Цена ошибки — потерянный результат, двойное списание или создание лишней записи, а также дополнительная нагрузка на upstream.
Главный вопрос не в том, какое число поставить в поле timeout. Нужно понять, как один абсолютный deadline проходит через DNS, установление соединения, TLS, отправку запроса, ожидание первого байта, чтение тела и retry. Ни одна новая фаза или повторная попытка не получает отдельные 800 мс: они используют только остаток общего бюджета.
Deadline — это момент, после которого результат операции больше не подходит вызывающему коду. Таймаут фазы — верхняя граница отдельного перехода. Эти понятия связаны, но не взаимозаменяемы. Если дать DNS, connect, TLS и чтению по 800 мс каждому, цепочка может длиться несколько секунд. Если поставить каждой фазе слишком маленький лимит, рабочий запрос будет отклонён до исчерпания общего бюджета.
Храните не набор независимых секундомеров, а абсолютный момент окончания, рассчитанный на монотонных часах среды. Перед каждой фазой вычисляйте remaining = deadline - now. При нулевом или отрицательном остатке не начинайте следующий сетевой вызов. Тот же остаток передаётся в retry и в ожидание ответа от endpoint статуса. Настенные часы могут корректироваться синхронизацией, поэтому они не должны быть единственным источником для измерения длительности.
START -> deadline = monotonicNow() + totalBudget -> remaining <= 0? -> STOP: deadline-exceeded -> DNS -> TCP connect + TLS -> request write -> TTFB -> read -> response? -> check status/semantics -> retry only with the same deadline -> otherwise timeout or unknown-resultЭто схема владения состоянием: вызывающий код владеет общим deadline, HTTP-адаптер — фазовыми сигналами и отменой, а API записи — способом узнать судьбу операции. Если один слой заводит новый полный таймер, он нарушает контракт верхнего слоя.
Полный путь HTTPS-запроса можно описать как разрешение имени, TCP-соединение, TLS handshake, запись сообщения и получение ответа. RFC 9110 описывает для схемы https последовательность от разрешения адреса и TCP до TLS и HTTP-запроса; RFC 8446 описывает handshake, после которого стороны получают ключевой материал для прикладных данных. Это протокольная последовательность, а не обещание конкретной библиотеки показать каждую фазу.
Пул соединений меняет картину. На тёплом соединении DNS, TCP и TLS могли произойти раньше, поэтому текущая операция начинается с записи запроса. В HTTP/2 несколько запросов используют одно соединение, и TLS нельзя честно приписать одному из них. Название измерения должно говорить, что именно наблюдалось: connection_setup_ms, request_write_ms, ttfb_ms или response_read_ms.
| Фаза | Сигнал | Симптом | Что можно заключить | Владелец проверки |
|---|---|---|---|---|
| DNS | Время от lookup до адреса | Медленный первый запрос | Задержка возникла при разрешении, если lookup измерен отдельно | Клиент или resolver |
| TCP connect | Время до установления TCP | Нет ответа до TLS | Проблема на маршруте, в пуле или лимите connect; причина требует сетевой проверки | HTTP-адаптер и сеть |
| TLS handshake | Время до готовности защищённого канала | Соединение открывается, HTTP не начинается | Затронута установка TLS или проверка сертификата, если событие записано | Клиент и владелец TLS |
| Request write | Время передачи заголовков и тела | Зависает upload | Нужно проверить размер тела, backpressure и write timeout | Клиент и proxy |
| TTFB | От конца записи до первого байта | Клиент ждёт сервер | В бюджет попали очередь, proxy и обработка; это не доказательство медленного origin | Proxy и сервис |
| Read | Время от первого байта до полного тела | Статус уже получен, тело не пришло | Нужно проверить размер ответа, streaming и скорость downstream | Клиент и сервис |
Если инструмент сообщает только total_duration, нельзя восстановить DNS, TLS или TTFB делением общего числа. Такая реконструкция выглядит точной, но не является измерением. До instrumented-адаптера логируйте только известные границы: время старта, время отмены, наличие HTTP-статуса и размер полученного тела.
Распределение фаз — проектное решение, а не таблица из RFC. Сначала измерьте cold и warm соединения, затем выберите резерв для вариативности. Жёсткий лимит connect защищает upstream от зависших попыток, но не должен превращать редкий cold start в массовый отказ. Для долгого ответа важнее отделить ожидание первого байта от чтения тела, иначе команда будет увеличивать read timeout, пытаясь лечить медленную обработку.
Ниже учебный бюджет в 800 мс. Он показывает арифметику, а не рекомендуемые значения. Сумма потолков равна общему deadline; фактические фазы могут закончиться раньше и оставить место для безопасного действия. У каждой строки должен быть владелец, измерение и тест на границе.
| Участок | Плановый предел | Зачем выделен | Если превышен |
|---|---|---|---|
| DNS | 80 мс | Не держать запрос из-за resolver | Отменить lookup и проверить кэш/резолвер |
| TCP + TLS | 180 мс | Открыть защищённое соединение | Проверить сеть, pool и холодное соединение |
| Write | 60 мс | Передать небольшой запрос | Проверить тело и backpressure |
| TTFB | 300 мс | Дождаться решения upstream | Сопоставить trace, очередь и server time |
| Read | 180 мс | Получить всё тело | Проверить streaming и размер payload |
Таблица полезна только как видимый контракт. На практике некоторые лимиты не складываются последовательно: тёплый pool пропускает DNS, TCP и TLS, а HTTP/2 делит соединение между запросами. Поэтому конфигурация должна хранить и общий deadline, и фазовые наблюдения, но не выдавать плановый предел за фактическое время.
Таймаут не сообщает, был ли запрос принят сервером. Клиент мог закрыть сокет после отправки тела, а сервер продолжить обработчик. Для чтения это обычно означает повторную проверку состояния. Для записи сначала определите, можно ли повторить действие без второго эффекта.
RFC 9110 называет идемпотентными безопасные методы, а также PUT и DELETE: повтор одинакового запроса должен иметь тот же задуманный эффект, что и один запрос. Та же спецификация отдельно предупреждает, что клиенту не следует автоматически повторять неидемпотентный метод, если нет способа доказать идемпотентность или узнать, что исходный запрос не применился. Сам метод не делает прикладную операцию безопасной: сервер может отправлять лог или создавать побочный аудит при каждом обращении.
POST с ключом дедупликации может получить прикладную идемпотентность, но это контракт API, а не свойство HTTP. Ключ должен быть стабильным для одной операции, храниться на сервере достаточно долго и связываться с результатом. Если такого контракта нет, честный ответ после таймаута — unknown-result. Клиент показывает возможность проверить статус, а не угадывает, что запись не произошла.
| Случай | Что известно | Следующий шаг | Риск |
|---|---|---|---|
| GET чтения | Нужно получить состояние ресурса | Повторить только в оставшемся бюджете или запросить актуальное состояние | Данные могли измениться между попытками |
| PUT/DELETE | Метод идемпотентен по HTTP-семантике, но побочные эффекты сервера отдельны | Повторить с тем же deadline после проверки API-контракта | Конкретный endpoint может иметь дополнительный эффект |
| POST с operation-id | Сервер умеет связать повторы с одной операцией | Повторить или прочитать статус по тому же идентификатору | Истёк срок хранения ключа или статус недоступен |
| POST без дедупликации | Неизвестно, применилось ли изменение | Не повторять вслепую; вернуть unknown-result и дать проверку | Дублирование записи или списания |
| Ответ 503/504 | Сигнал от сервера или gateway, но не доказательство отсутствия записи | Проверить retry policy и состояние операции | Повтор может усилить перегрузку |
Коды 408 и 504 тоже нельзя превращать в универсальную команду «повторить». 408 означает, что сервер не получил полный запрос за время ожидания; 504 означает, что gateway или proxy не дождался нужного upstream. Оба кода описывают наблюдаемую точку в цепочке, но не устанавливают прикладную судьбу POST. Для этой границы нужны логи сервера, correlation-id и endpoint статуса.
Перед подключением к реальному клиенту удобно проверить сам инвариант: сумма уже потраченных фаз не должна превысить общий бюджет, а следующий вызов стартует только при положительном остатке. Фрагмент ниже запускается обычным node, не импортирует локальные модули и не притворяется HTTP-адаптером.
function evaluateAttempt({ totalMs, phases }) {
if (!Number.isFinite(totalMs) || totalMs <= 0) throw new RangeError('totalMs must be positive');
const elapsedMs = phases.reduce((sum, phase) => {
if (!Number.isFinite(phase.ms) || phase.ms < 0) throw new RangeError('phase duration must be non-negative');
return sum + phase.ms;
}, 0);
const remainingMs = Math.max(0, totalMs - elapsedMs);
return { ok: elapsedMs <= totalMs, elapsedMs, remainingMs, canStartNextPhase: remainingMs > 0, reason: elapsedMs > totalMs ? 'deadline-exceeded' : null };
}
const within = evaluateAttempt({ totalMs: 800, phases: [{ ms: 42 }, { ms: 88 }, { ms: 310 }, { ms: 200 }] });
const late = evaluateAttempt({ totalMs: 800, phases: [{ ms: 120 }, { ms: 210 }, { ms: 380 }, { ms: 180 }] });
console.log(within);
console.log(late);
// { ok: true, elapsedMs: 640, remainingMs: 160, canStartNextPhase: true, reason: null }
// { ok: false, elapsedMs: 890, remainingMs: 0, canStartNextPhase: false, reason: 'deadline-exceeded' }Функция считает длительности, которые ей уже передали; она не измеряет сеть. В production-адаптере каждый вызов должен получать remainingMs, а при отмене должен завершаться и его дочерний lookup, socket или stream. Иначе арифметический тест будет зелёным, а сокет продолжит жить после ответа пользователю.
operation-id или зафиксируйте, почему его нет.unknown-result.Единый deadline не делает любую цепочку быстрой. На результат влияют очереди, планировщик, proxy, размер тела, повторные TLS-соединения, multiplexing и отмена в конкретной библиотеке. Фазовые лимиты не заменяют rate limit, circuit breaker или контроль размера запроса. Их задача уже: не дать вложенной операции пережить смысловой бюджет вызывающего кода.
Отдельно проверьте границу между клиентом и сервером. HTTP-таймаут клиента может оставить серверный обработчик работающим. Если сервер умеет принимать deadline в заголовке или контексте, согласуйте его с клиентским значением и оставьте запас на ответ. Не копируйте это поведение без документации вашего proxy и сервиса.
Решение готово к эксплуатации, когда тест показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, отмена останавливает вложенное чтение, а логи позволяют связать клиентский отказ с proxy и server trace. Для записи нужен ещё один проверяемый факт: по operation-id можно узнать, был ли первый вызов принят. Если этой проверки нет, успешный HTTP-статус и отсутствие ошибки сети не должны превращаться в универсальный вывод о retry.
https, свойства методов, идемпотентность и значения 408/504; спецификация не выбирает таймауты конкретной библиотеки.Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.
\nТезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.
\nAPI состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.
\nТип изменения недостаточен. Поле может остаться строкой, но поменять часовой пояс, единицу измерения или правило пустого значения. Формальная схема пропустит такой ответ, а клиент изменит поведение. Поэтому отдельно фиксируйте структурную совместимость и семантическую совместимость. Первая отвечает на вопрос «можно ли распарсить данные». Вторая — «можно ли продолжить прежнюю операцию с тем же смыслом».
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый клиент не создаёт ресурс | В запрос добавили required-поле | Найти builders, SDK и fixtures старой версии | Оставить поле optional, задать совместимое значение или выпустить версию |
| Клиент падает на успешном ответе | Удалили свойство или изменили тип | Поиск чтения свойства и contract-тест старого клиента | Сначала deprecated-окно и новое поле, затем удаление |
| Новая ветка обработки не срабатывает | Сузили enum или добавили неизвестное значение | Прогнать значения через старые switch и парсеры | Сохранить старые значения либо сменить версию |
| Откат приложения не восстанавливает работу | Новый сервер записал только новый формат | Проверить чтение старой версией после частичного rollout | Сначала expand, затем switch, потом contract |
| Тесты зелёные, внешний потребитель сломан | Потребитель не попал в репозиторий | Проверить registry, документацию, логи и владельцев интеграций | Остановить удаление и оставить совместимый путь |
Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.
\nfunction classifyApiChange(change) {\n const removed = Array.isArray(change.removedProperties)\n ? change.removedProperties\n : [];\n const required = Array.isArray(change.addedRequiredProperties)\n ? change.addedRequiredProperties\n : [];\n const narrowedEnum = Boolean(change.narrowedEnum);\n const breaking = removed.length > 0\n || required.length > 0\n || narrowedEnum;\n\n return {\n status: breaking ? 'breaking' : 'compatible',\n action: breaking\n ? 'version-or-expand-compatibility-window'\n : 'run-consumer-contract-tests',\n };\n}\n\nconst result = classifyApiChange({\n removedProperties: ['displayName'],\n addedRequiredProperties: [],\n narrowedEnum: false,\n});\n\nconsole.log(result);\n// { status: 'breaking',\n// action: 'version-or-expand-compatibility-window' }\nФункция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с additionalProperties: false или сравнением полного JSON может сломаться даже на расширении. Статус compatible здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».
Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только displayNameV2, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.
На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.
\nКлассификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.
\nСхема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде If-Match, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.
Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.
\nИзменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.
\nВ review приходит небольшой API-diff: в ответ добавили поле, в запросе появился новый параметр, а локальный тест всё ещё получает 200. Через несколько минут старый клиент отправляет прежнюю форму и получает 400, либо успешно читает ответ, но падает на новом значении enum. Цена такой ошибки — не только красный график: приходится останавливать rollout, искать неизвестного потребителя и решать, совместим ли уже записанный новый формат со старой версией приложения.
\nГлавный вопрос ревью звучит так: может ли старый потребитель выполнить ту же операцию, пока новая версия уже частично работает? Ответ нельзя получить из diff сервера в одиночку. Нужно связать форму запроса и ответа, смысл полей, список читателей и писателей, состояние данных и план возврата. Ниже — рабочий порядок: классификация, проверка потребителей, выбор перехода и явная точка остановки.
\nAPI-контракт — это не только TypeScript-интерфейс или OpenAPI-файл. У него есть метод, путь, media type, статусы, форма запроса, форма ответа и допустимые значения. У поля есть ещё смысл: часовой пояс, единица измерения, правило пустого значения и связь с состоянием ресурса. Изменение может пройти структурный валидатор и всё равно изменить операцию для клиента.
\nOpenAPI 3.1.1 описывает поверхность HTTP-интерфейса и позволяет инструментам строить документацию, клиентов и тесты. Это полезный источник формы, но не доказательство фактического поведения конкретного сервиса. Сверяйте описание с обработчиком, реальным ответом и потребителем. В статье примеры используют OAS 3.1.1 и JSON Schema Validation 2020-12; если проект работает на другой версии или диалекте, сначала проверьте поддерживаемые ключевые слова и генератор.
\n| Изменение | Риск для старого потребителя | Проверка | Решение и stop condition |
|---|---|---|---|
| В запрос добавили required-поле | Старый отправитель не проходит валидацию | Запустить старый SDK или fixture без поля | Сделать поле optional или выпустить новую версию; остановиться, если старый путь получает 4xx |
| Из ответа удалили поле или изменили его тип | Чтение становится ошибочным или теряет значение | Найти чтения поля и прогнать старый декодер | Сначала новое поле рядом со старым; удаление только после сигнала отсутствия чтений |
| В ответ добавили новое значение enum | Закрытый switch или декодер не знает значение | Передать новое значение старому consumer | Добавить unknown-ветку или версию; остановиться на необработанной ветке |
| В запросе сузили принимаемый enum | Ранее допустимый отправитель получает отказ | Сравнить старый набор входов с новым и проверить логи 4xx | Сохранить старое значение или сменить версию; удаление — после миграции отправителей |
| Новый writer сохраняет только новый формат | Rollback бинарника оставляет старый reader без данных | Записать новой версией, затем прочитать старой | Expand/contract с двойным чтением или записью; остановиться до contract-фазы |
| Потребитель вне репозитория | Зелёный CI не видит интеграцию | Проверить registry, владельца, документацию и журналы вызовов | Назначить owner или оставить совместимый путь; неизвестный consumer блокирует удаление |
Required отвечает на вопрос о наличии поля. В JSON Schema объект проходит это ограничение, только если каждое имя из массива required есть в экземпляре. Поэтому добавление обязательного поля в запрос меняет минимальную форму, которую должен уметь отправить старый клиент.
Enum ограничивает множество значений. Расширение enum в ответе опасно для клиента с закрытым набором веток. Сужение enum в запросе опасно для отправителя, который ещё шлёт удалённое значение. Удаление значения из ответа не равно автоматически breaking-изменению: оно может быть безопасным для парсера, но изменить бизнес-переход. Этот смысл проверяется отдельно, а не угадывается по схеме.
\nНеизвестные свойства зависят от декодера. Один клиент их игнорирует, другой валидирует ответ схемой с additionalProperties: false, третий сравнивает JSON целиком. Поэтому «добавили поле — безопасно» — только гипотеза. В карточке изменения укажите реальное поведение потребителя, иначе статус compatible означает лишь отсутствие очевидного признака, а не доказанную безопасность.
Наконец, тип и форма не покрывают инвариант. Поле revision может оставаться строкой, но сервер может начать требовать его актуальность. Для конкурентной записи полезен условный запрос: RFC 9110 описывает If-Match как проверку текущего entity tag до выполнения метода и допускает ответ 412 Precondition Failed, если условие не выполнено. Это отдельный контракт состояния, а не следствие сравнения JSON.
Ниже самодостаточный Node.js-скрипт без пакетов. Он не парсит OpenAPI и не пытается автоматически одобрить pull request. Скрипт получает две уже нормализованные формы, находит добавленное required-поле, удалённое свойство и опасное изменение набора входных enum. Сохраните его как api-diff.mjs и запустите командой node api-diff.mjs.
const before = {\n requestRequired: ['name'],\n requestEnums: { role: ['reader', 'editor'] },\n responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nconst after = {\n requestRequired: ['name', 'ownerId'],\n requestEnums: { role: ['reader'] },\n responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nfunction difference(left, right) {\n return left.filter((value) => !right.includes(value));\n}\n\nfunction classifyApiDiff(oldContract, newContract) {\n const oldRequired = oldContract.requestRequired || [];\n const newRequired = newContract.requestRequired || [];\n const addedRequired = difference(newRequired, oldRequired);\n const removedResponse = difference(\n Object.keys(oldContract.responseProperties || {}),\n Object.keys(newContract.responseProperties || {}),\n );\n const narrowedRequestEnums = Object.entries(oldContract.requestEnums || {})\n .filter(([name, oldValues]) => {\n const newValues = newContract.requestEnums?.[name] || [];\n return difference(oldValues, newValues).length > 0;\n })\n .map(([name]) => name);\n\n const breaking = addedRequired.length > 0\n || removedResponse.length > 0\n || narrowedRequestEnums.length > 0;\n\n return {\n status: breaking ? 'breaking-risk' : 'no-obvious-breaking-change',\n addedRequired,\n removedResponse,\n narrowedRequestEnums,\n next: breaking ? 'stop-and-open-compatibility-plan' : 'run-consumer-tests',\n };\n}\n\nconsole.log(JSON.stringify(classifyApiDiff(before, after), null, 2));\nДля приведённых данных результат содержит ownerId в addedRequired и role в narrowedRequestEnums. removedResponse пуст. Скрипт намеренно не помечает добавление необязательного response-поля как breaking: это оставляет место для проверки tolerant и strict consumers. Он также не проверяет изменение смысла, статусы, авторизацию, события, кеши и записи. Эти границы важнее красивого зелёного результата, поэтому автоматический классификатор — первая страховка, а не решение reviewer.
После классификации составьте карту поверхности. Для каждого endpoint запишите клиентов браузера и мобильного приложения, SDK, batch-задачи, webhooks, очереди и внешние интеграции. Внутри репозитория ищите сгенерированные типы, сериализаторы, имена полей, fixtures и contract-тесты. Вне репозитория запросите owner и дату последнего вызова; отсутствие записи в монорепозитории не доказывает отсутствие потребителя.
\nРазделяйте направление зависимости. Старый writer → новый reader обычно проверяется легче, чем новый writer → старый reader. Второй путь критичен для rollback: новая версия могла уже записать данные, которые старая не умеет прочитать. Для событий добавьте задержанную доставку и повторное проигрывание. Для кеша проверьте старую сериализацию после истечения TTL, а не только свежий запрос.
\nold client --request v1--> new server --response v2--> old client\n | |\n +-- old data <-- new writer ---+\n\nПеред switch проверяем четыре перехода:\n1. old client -> old server\n2. old client -> new server\n3. new client -> old server, если rollback реален\n4. new writer -> old reader, если данные переживают rollback\nЕсли команда не может воспроизвести третий или четвёртый переход, это не повод молча исключить его из тестов. Это сигнал, что rollback не определён. Тогда сначала ограничьте rollout, сохраните старый writer или подготовьте чтение обеих форм. Название «backward compatible» без конкретного направления мало помогает reviewer.
\nСовместимое расширение — самый дешёвый путь, когда добавляется optional-поле, сохраняются прежние значения и все декодеры это допускают. Цена — временно поддерживать две формы и не путать отсутствие значения с пустым значением. Этот вариант хорош для одного независимого поля, но не спасает изменение смысла.
\nНовая версия endpoint или media type изолирует breaking-контракт. Клиенты мигрируют по отдельному графику, а старый путь живёт до объявленного срока. Цена — две документации, два набора тестов, маршрутизация и наблюдение за обоими путями. Версия оправдана, когда старую и новую семантику нельзя честно совместить.
\nExpand/contract подходит для данных, которые переживают релиз. На expand добавьте новую колонку или поле, не ломая старый reader. На compatibility научите новый код читать старую и новую форму и, если нужно, писать обе. На switch переведите потребителей и включите новый writer. На contract удалите старую форму только после сигнала использования и проверенного восстановления. Это дороже в коде и миграциях, зато rollback остаётся возможным после записи.
\nПорядок можно записать коротким flow: diff → направление чтения/записи → список consumers → contract-тест → частичный rollout → сигнал → switch → contract. На каждой стрелке должен быть владелец. Если сигнал не определён, переход заканчивается на предыдущем узле.
Schema diff не видит авторизацию и права, конкурентную запись, идемпотентность, подписанные payload, кэш, задержанную очередь и клиент, обновляющийся вне вашего графика доставки. JSON Schema проверяет форму экземпляра, но не знает, имеет ли пользователь право изменить ресурс и не устарела ли его версия. Эти условия должны появиться в тесте boundary или в runbook, если они входят в ваш контракт.
\nHTTP-статус тоже нельзя выводить из имени поля. Ответ 200 может содержать бизнес-отказ, а 412 требует реальной проверки precondition на сервере. Если используете If-Match, проверьте сильное сравнение entity tag, порядок проверки до изменения состояния и поведение повторной доставки. Если такой контракт не поддержан сервером, не добавляйте заголовок в пример только ради видимости надёжности.
Учебные имена ownerId, role и status не описывают конкретный production-сервис. В тексте нет измерений rollout и заявленного результата: его нужно получить у своей системы. Версии OAS и JSON Schema здесь указаны для воспроизводимости примера; генератор, валидатор и политика неизвестных полей всё равно требуют проверки в проекте.
Я закрываю API-diff, когда в одном месте видны baseline и candidate, классификация с конкретными полями, список readers/writers, четыре перехода, contract-тесты, выбранный способ миграции, сигнал частичного rollout и операция возврата. Для изменения данных добавляю результат чтения старой версией после записи новой. Для внешнего consumer указываю owner или оставляю старый путь.
\nНачните со следующего небольшого изменения и заполните только одну карточку: endpoint, направление, поле, consumer, проверка и stop condition. Если после этого нельзя ответить, что произойдёт с данными при rollback, остановите удаление и сначала сделайте чтение старой и новой формы совместимым. Такой review занимает место в процессе, но возвращает команде управляемый выбор вместо срочного восстановления неизвестного клиента.
\nenum и required. Граница: схема не моделирует права, конкурентное состояние и бизнес-переход.412 Precondition Failed. Граница: RFC не выбирает миграцию базы, версионирование endpoint или стратегию rollout.