From 471cfe08ebbde32cf1ee9ca5d25ae15cf2d96954 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 12:41:21 +0300 Subject: [PATCH] editorial: revise articles 017-022 to 10/10 --- editorial/agent-rewrites/017.json | 2 +- editorial/agent-rewrites/018.json | 2 +- editorial/agent-rewrites/019.json | 2 +- editorial/agent-rewrites/020.json | 2 +- editorial/agent-rewrites/021.json | 2 +- editorial/agent-rewrites/022.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/editorial/agent-rewrites/017.json b/editorial/agent-rewrites/017.json index 4376198..fbbf7a7 100644 --- a/editorial/agent-rewrites/017.json +++ b/editorial/agent-rewrites/017.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-07-mechanism-reliability-capstone", "title": "Retry после timeout: как не повторить бизнес-операцию дважды", "excerpt": "Timeout сообщает о потерянном ответе, но не о результате записи. Идемпотентный контракт связывает повторы одной операции и не даёт сетевому сбою превратиться в дубль.", - "contentHtml": "

Клиент отправляет запрос на создание заказа и ждёт ответа. Через пять секунд он получает timeout. Пользователь нажимает «Повторить», а клиент автоматически отправляет тот же POST ещё раз. В системе появляются два заказа. Для платежа цена выше: можно получить двойное списание, повторное письмо или две отгрузки.

\n

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

\n

Тезис: надёжный retry начинается с контракта бизнес-операции. Для чтения достаточно ограниченного повтора с общим deadline. Для записи нужен идемпотентный метод или ключ операции, который сервер проверяет атомарно вместе с результатом. Один request id, повторная отправка POST и надежда на быстрый ответ такой контракт не заменяют.

\n

Что именно скрывает timeout

\n

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

\n

Для GET повтор обычно не создаёт новый объект. Для DELETE повтор может вернуть другой статус, но ожидаемое состояние остаётся удалённым. POST по умолчанию не даёт такой гарантии: сервер может создать новый ресурс при каждом запросе. Нельзя выводить безопасность повтора из короткого имени метода. Нужно проверить доменное действие.

\n
Четыре идентификатора и их границы
ИдентификаторКто создаётЧто связываетЧего не гарантирует
Connection IDтранспортпакеты одного соединениярезультат бизнес-операции
Request IDклиент или gatewayодну попытку и её логиотсутствие повторного эффекта
Idempotency-Keyклиент для операциинесколько попыток одного действияатомарность, если сервер её не реализует
Resource IDдоменный сервиссозданный объектсвязь двух попыток без контракта
\n
\"Матрица
Матрица показывает, какой слой отвечает на конкретный вопрос. Она не доказывает доставку отдельного запроса.
\n

Идемпотентность означает один эффект

\n

Операция идемпотентна, если один или несколько одинаковых запросов дают тот же ожидаемый эффект, что и один запрос. Ответы при этом могут отличаться. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно состояние и контракт, а не только код ответа.

\n

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

\n

Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Ключ order-42 может быть уникальным в пределах одного клиента, магазина или всей системы. После истечения срока тот же ключ может стать новой операцией. Это часть API-контракта.

\n

Учебный сервер с защитой от дубля

\n

Ниже — ограниченный учебный пример на Node.js. Он показывает только идею: два POST с одним ключом получают один номер записи. Данные хранятся в Map в памяти процесса. Пример не заменяет транзакцию, распределённое хранилище, аутентификацию и проверку тела запроса.

\n
const results = new Map(); let nextId = 1; function create(key) { if (!results.has(key)) results.set(key, { id: nextId++, state: 'created' }); return results.get(key); } console.log(create('order-42')); console.log(create('order-42'));
\n

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

\n

Хранилище должно запоминать параметры операции или их отпечаток. Если первый запрос создаёт заказ на 100 рублей, а повтор с тем же ключом просит 10 000 рублей, сервер не должен молча отдавать старый результат. Он должен вернуть конфликт до нового побочного эффекта. Успешный результат, ошибка валидации и ошибка сервера требуют отдельных правил хранения.

\n

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

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Timeout, но ресурс уже естьОтвет потерялся после записиСопоставить trace, request id и состояние ресурсаПовторить только с тем же ключом или запросить результат по resource id
Каждый retry создаёт новый ресурсPOST не имеет дедупликацииОтправить два запроса с одним ключом и сравнить записиДобавить контракт ключа или запретить автоматический retry
Один ключ даёт разные ответыКлюч не связан с результатом или истёкПроверить TTL и область уникальностиЗафиксировать срок, namespace и правило истечения
Повтор с другим телом проходитСервер хранит только строку ключаСравнить отпечатки телВернуть конфликт до побочного эффекта
После сбоя растёт очередьRetry не учитывает deadlineПосчитать попытки, задержки и время отменыОграничить повторы, backoff и бюджет времени
\n

Когда повторять нельзя

\n

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

\n

Не превращайте любой 5xx в разрешение на повтор. 503 может сопровождаться Retry-After, но его наличие не доказывает, что запрос не был принят. Gateway может вернуть свой 503 после выполнения upstream-операции. Сетевое исключение, отмена deadline и ответ посредника должны различаться в логах.

\n

Не повторяйте после истечения общего deadline. Отдельные таймауты на каждый вызов могут растянуть цепочку на минуты и создать лавину в зависимостях. Backoff снижает частоту, но не исправляет небезопасный эффект. Circuit breaker ограничивает давление, но не сообщает, была ли запись принята.

\n

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

\n
  1. Назовите доменное действие и его побочный эффект: создание заказа, списание, отправка письма или изменение лимита.
  2. Определите состояние после timeout и запрос, который проверит его без нового побочного эффекта.
  3. Проверьте контракт HTTP и серверную реализацию. Не делайте вывод только из метода или статуса.
  4. Для записи задайте формат, область уникальности и срок жизни Idempotency-Key.
  5. Сделайте тест потери ответа после записи. Повтор должен вернуть тот же результат или явный конфликт.
  6. Сделайте тест повторного ключа с другим телом. Вторая команда не должна менять состояние.
  7. Добавьте общий deadline, лимит попыток, backoff и поля operation key, request id и attempt.
  8. Проверьте отрицательный путь: при отсутствии контракта клиент останавливается.
\n

Ограничения

\n

Idempotency-Key не решает конкуренцию сам по себе. Нужны атомарная запись, согласованное хранилище и правило восстановления после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают операцию.

\n

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

\n

Учебный сервер не моделирует рестарт, несколько процессов, транзакцию базы, частичный ответ и истечение TTL. Он полезен только для проверки различия между попыткой и операцией. Не переносите его Map в production без этих механизмов.

\n

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

\n

Механизм готов, если для одного operation key можно воспроизвести четыре исхода: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух случаях состояние содержит один ресурс и один побочный эффект. Третий случай возвращает тот же результат или согласованный статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request id и номеру попытки, а общий deadline ограничивает цепочку.

\n

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

\n

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

\n" + "contentHtml": "

Клиент отправляет запрос на создание заказа и ждёт ответа. Через пять секунд он получает timeout. Пользователь нажимает «Повторить», а клиент отправляет тот же POST ещё раз. В системе появляются два заказа. Для платежа цена выше: возможны двойное списание, повторное письмо или две отгрузки.

\n

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

\n

Тезис: безопасный retry начинается с контракта бизнес-операции. Безопасные и идемпотентные запросы можно повторять в пределах общего deadline. Запись требует либо идемпотентной семантики самого метода, либо ключа операции, который сервер связывает с входными параметрами и результатом. Request ID, повторная отправка POST и короткий timeout такой контракт не заменяют.

\n

Timeout сообщает о попытке, а не о результате

\n

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

\n

RFC 9110 называет идемпотентным такой метод, у которого несколько одинаковых запросов имеют тот же предполагаемый эффект, что и один. В стандарте к ним относятся безопасные методы, PUT и DELETE. RFC отдельно предупреждает: клиенту не следует автоматически повторять неидемпотентный метод, если он не знает прикладную семантику или не умеет проверить, что исходный запрос не применился.

\n

Это не означает, что любой GET можно повторять бесконечно. Чтение не добавляет запись, но каждый вызов потребляет квоту и нагрузку. Повтор всё равно ограничивают числом попыток и общим временем. И наоборот, POST не обречён на дубль: конкретный API может сделать его идемпотентным через ключ, предварительное условие или другую часть контракта.

\n
Идентификаторы отвечают на разные вопросы
ИдентификаторКто создаётЧто связываетЧего не доказывает
Connection IDтранспортпакеты одного соединениярезультат бизнес-операции
Request IDклиент или gatewayодну попытку и её логиотсутствие повторного эффекта
Idempotency-Keyклиент для операциинесколько попыток одного действияатомарность без серверной реализации
Resource IDдоменный сервиссозданный или изменённый объектчто именно сделал этот клиент
\n
\"Матрица
Матрица разделяет вопросы о доставке, семантике запроса и состоянии операции. Она не доказывает доставку конкретного запроса.
\n

Идемпотентность — свойство эффекта

\n

Идемпотентность описывает итоговое изменение состояния, а не одинаковость ответов и не отсутствие логов. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно число созданных ресурсов, состояние и побочные эффекты, а не только код ответа.

\n

Для POST сервер часто принимает заголовок Idempotency-Key. Это не универсальная гарантия HTTP и не стандартный алгоритм хранения. API должно определить, как ключ связывается с аккаунтом или арендатором, сколько живёт, что происходит при одновременных запросах и какой ответ получает повтор.

\n

Ключ относится к смысловой операции, а не к соединению. Один retry может получить новый Request ID, но должен сохранить тот же ключ операции. Если ключ order-42 уникален только внутри магазина, тот же текст в другом магазине допустим. После истечения TTL ключ может начать новую операцию. Эти границы нужно записать в контракте, иначе дедупликация будет случайной.

\n

Контракт ключа: вход, результат и конкуренция

\n

Надёжный слой идемпотентности хранит не одну строку, а связь между ключом, отпечатком входных параметров и исходом выполнения. Повтор с тем же ключом и другим телом должен получить конфликт до нового побочного эффекта. Иначе клиент может принять старый результат за подтверждение новой команды.

\n
Минимальные решения, которые должен зафиксировать API
Вопрос контрактаПроверяемое решениеРиск при пропуске
Область уникальностиАккаунт, tenant или вся системаЧужая операция блокирует ключ
Срок храненияTTL не короче окна допустимого повтораПоздний retry создаёт дубль
Содержимое повтораТот же результат, статус или явная ошибкаКлиент повторяет вслепую
Другое телоСравнение отпечатка и конфликтОдин ключ скрывает другую команду
КонкуренцияАтомарная запись состояния «выполняется»Два процесса создают два ресурса
\n

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

\n

Учебный сервер с проверкой тела

\n

Код ниже можно сохранить в файл idempotency-demo.mjs и запустить командой node idempotency-demo.mjs. Сервер принимает локальные POST, сохраняет тело для сравнения и возвращает тот же id при повторе. Два разных тела с одним ключом получают 409. Пример намеренно хранит данные в Map одного процесса: он показывает поведение контракта, но не заменяет базу, транзакцию или аутентификацию.

\n
import { createServer } from 'node:http';\n\nconst records = new Map();\nlet nextId = 1;\n\nconst server = createServer((request, response) => {\n  if (request.method !== 'POST') {\n    response.writeHead(405).end();\n    return;\n  }\n  const key = request.headers['idempotency-key'];\n  if (typeof key !== 'string' || key.length < 8) {\n    response.writeHead(400).end('Idempotency-Key required');\n    return;\n  }\n  let body = '';\n  request.setEncoding('utf8');\n  request.on('data', (chunk) => { body += chunk; });\n  request.on('end', () => {\n    const saved = records.get(key);\n    if (saved && saved.body !== body) {\n      response.writeHead(409).end('key reused with different body');\n      return;\n    }\n    const result = saved || { body, id: nextId++, state: 'created' };\n    records.set(key, result);\n    response.writeHead(saved ? 200 : 201, { 'content-type': 'application/json' });\n    response.end(JSON.stringify({ id: result.id, state: result.state }));\n  });\n});\n\nserver.listen(0, '127.0.0.1', () => {\n  console.log('listening on ' + server.address().port);\n});
\n

Два последовательных запроса с ключом order-42 вернут один id; запрос с тем же ключом и другим телом вернёт 409. В примере это обеспечивается одним циклом событий Node.js и памятью процесса. В production проверка ключа, тела и записи результата должна быть атомарной в общем хранилище. Иначе два экземпляра сервиса одновременно увидят отсутствующий ключ.

\n

Повторять ли запрос: симптом → проверка → действие

\n
Диагностическая матрица для решения о retry
СимптомЧто проверитьБезопасное действие
Timeout, ресурс уже естьСостояние ресурса и operation keyПолучить результат или повторить с тем же ключом
Каждый retry создаёт ресурсСерверный контракт POSTОтключить автоматический retry или добавить дедупликацию
Один ключ даёт разные ответыTTL, namespace и сохранённый результатЗафиксировать правило повтора и истечения
Другое тело проходитТело или его отпечатокВернуть конфликт до побочного эффекта
После сбоя растёт очередьЧисло попыток и remaining deadlineОграничить backoff, attempts и общий бюджет
\n

Различайте причины отказа. HTTP 503 может сопровождаться Retry-After, но сам статус не доказывает, что upstream не успел записать данные. Gateway может вернуть 503 после завершения операции в зависимом сервисе. Сетевое исключение, отмена deadline и ответ посредника должны попадать в разные поля логов.

\n

Повтор не должен передавать секреты и персональные данные в диагностические поля. Достаточно operation key, request ID, номера попытки, метода, endpoint без чувствительных параметров, статуса, длительности, причины решения и остатка общего времени. Тело запроса логируйте только по явной политике и с редактированием данных.

\n

Когда автоматический retry запрещён

\n

Не повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Не превращайте любой 5xx в разрешение на повтор. Даже если ответ выглядит временным, повтор небезопасен без знания прикладного эффекта.

\n

Backoff и jitter уменьшают синхронный всплеск, но не исправляют двойную запись. Circuit breaker ограничивает давление на зависимость, но не сообщает, была ли команда принята. Общий deadline нужен для всей цепочки, а не только для каждого отдельного вызова: иначе три коротких таймаута растянутся и продолжат нагрузку после того, как пользователь уже ушёл.

\n

Отдельно проверяйте операции с внешними эффектами. Идемпотентная запись заказа не делает автоматически однократной отправку письма или вызов платёжного провайдера. Для них нужен собственный ключ провайдера, транзакционный outbox, дедупликация обработчика или сверка состояния. Гарантию «ровно один раз» нельзя обещать всей цепочке, если хотя бы один участник не имеет такого контракта.

\n

Порядок внедрения и тест потери ответа

\n
  1. Назовите доменное действие и каждый побочный эффект: создание заказа, списание, письмо или изменение лимита.
  2. Опишите состояния до, во время и после выполнения. Для timeout добавьте запрос чтения или сверки без нового эффекта.
  3. Проверьте HTTP-метод и прикладную реализацию. Не делайте вывод только из POST, 503 или наличия Request ID.
  4. Для записи задайте формат ключа, namespace, TTL, тело или его отпечаток и правило для повторного результата.
  5. Сделайте тест: принять запись, намеренно потерять ответ, повторить запрос с тем же ключом и сравнить ресурс.
  6. Сделайте тест параллельных запросов с одним ключом. Должен появиться один ресурс и один побочный эффект.
  7. Сделайте тест того же ключа с другим телом. Ответ должен быть конфликтом до новой записи.
  8. Добавьте общий deadline, лимит попыток, backoff с jitter и поля operation key, request ID и attempt.
  9. Проверьте отрицательный путь: без контракта клиент останавливается и передаёт неопределённый исход на разбор.
\n

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

\n

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

\n

TTL выбирают по максимальному времени повторной доставки, задержке очередей и бизнес-риску. Слишком короткий срок снова разрешит дубль; слишком длинный увеличит хранилище и может удерживать старые параметры. Размер и способ вычисления отпечатка, правила приватности и ключ шифрования — отдельные решения, которых нет в учебном примере.

\n

Если результатом является асинхронная задача, сохранённый HTTP-ответ означает только принятие команды. Клиенту нужен статус операции по тому же ключу или resource ID. Если провайдер не умеет повторять безопасно, система должна выбрать сверку, компенсацию или ручную обработку, а не маскировать неопределённость новым POST.

\n

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

\n

Контракт готов, если для одного operation key воспроизводятся четыре сценария: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух сценариях остаются один ресурс и один побочный эффект. Третий возвращает тот же результат или явно определённый статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request ID и номеру попытки, а общий deadline ограничивает цепочку.

\n

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

\n

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

\n" } diff --git a/editorial/agent-rewrites/018.json b/editorial/agent-rewrites/018.json index 1dd0d6f..3328472 100644 --- a/editorial/agent-rewrites/018.json +++ b/editorial/agent-rewrites/018.json @@ -1 +1 @@ -{"index":18,"slug":"editorial-2027-07-practice-reliability-capstone","title":"Retry без двойной записи: как связать timeout, 503 и идемпотентность","excerpt":"Разбираем, почему повтор HTTP-запроса нельзя включать одной настройкой. Сначала проверяем семантику операции, затем ограничиваем время, попытки и последствия сбоя.","contentHtml":"

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

Есть и обратный симптом. Чтение каталога получает 503 Service Unavailable, клиент сразу сдаётся, а пользователь видит отказ, хотя зависимость восстановилась через секунду. Команда добавляет общий retry для всех запросов и исправляет один сценарий, но открывает другой: небезопасную операцию можно выполнить повторно.

Тезис простой: retry — это часть контракта операции, а не свойство сетевого клиента. Клиент должен знать, что он повторяет, сколько времени осталось, какой ответ разрешает повтор и как отличить неизвестный результат записи от подтверждённого отказа.

Механизм: ответ и результат — не одно и то же

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

У запроса есть две разные характеристики. Безопасный метод не меняет состояние сервера. Идемпотентная операция допускает повторение с тем же ожидаемым эффектом. GET обычно читается повторно. PUT может перезаписать ресурс по известному ключу. POST, который создаёт новый ресурс, нельзя повторять по умолчанию. Заголовок Idempotency-Key меняет правило только тогда, когда сервер действительно хранит ключ, результат и срок его действия.

Статус 503 не является командой «повтори». Он говорит, что сервис временно не готов обработать запрос. Заголовок Retry-After может задать паузу. Клиент всё равно должен проверить метод, deadline, лимит попыток и нагрузку на зависимость. Повтор через proxy может снова попасть в перегруженный origin.

Диагностика повтора HTTP-запроса
СимптомПричинаПроверкаДействие
После timeout появились две записиСервер принял POST, но клиент не получил ответСверить request id и журнал транзакцииНе повторять без ключа или проверки состояния
GET завершился после первого 503Клиент не различает чтение и записьПроверить метод, status и deadlineПовторить ограниченно с backoff
Три попытки вышли за времяЛимит попыток не связан с deadlineИзмерить запросы и паузыСчитать остаток времени перед каждой попыткой
Сервис перегружается после сбояКлиенты повторяют одновременноСопоставить rate retry, 503 и нагрузку originДобавить jitter, бюджет и circuit breaker
Причина ошибки исчезает в логахВсе исключения сведены к одному типуПроверить method, status, attempt и request idСохранить безопасный контекст без payload
Дерево решения для HTTP-повтора: deadline, метод, статус и окончательное действие
Безопасное решение начинается с deadline и семантики метода. Статус 503 не отменяет проверку побочного эффекта.

Минимальный пример с общим deadline

Ниже — учебный пример для локального сервера. Он показывает два ответа 503, затем 200. Сервер не моделирует потерю ответа после записи, балансировщик, очередь и реальную нагрузку. Результат примера нельзя выдавать за производственный замер.

async function getWithRetry(url, { maxAttempts = 3, deadlineMs = 1000 } = {}) { const deadline = Date.now() + deadlineMs; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('deadline exceeded'); const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(remaining) }); if (response.ok) return { attempt, status: response.status }; if (response.status !== 503 || attempt === maxAttempts) throw new Error('stop on status ' + response.status); await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); } }

Функция повторяет только GET. Она ограничивает суммарное время, а не умножает timeout на число попыток. В настоящем клиенте нужно отдельно обработать сетевую ошибку, проверить Retry-After, добавить случайную добавку к паузе и передать request id. Для POST эта функция не подходит: её сигнатура специально не принимает тело и метод.

Последовательность важна. Сначала проверяется остаток deadline. Затем клиент получает ответ. Успех возвращается сразу. 503 разрешает следующую попытку только при оставшемся времени и ненулевом бюджете. Другой статус останавливает цикл. Ошибка валидации или авторизации не превращается в поток бесполезных повторов.

Что делать с записью

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

Idempotency key должен входить в контракт API. Сервер сохраняет ключ вместе с результатом и возвращает тот же результат при повторе того же ключа. Надо определить срок хранения, связь ключа с параметрами запроса и ответ при несовпадении параметров. Если клиент отправит тот же ключ с другим заказом, сервер должен отклонить запрос, а не изменить исходную операцию.

Логирование помогает расследованию, но не делает повтор безопасным. Записывайте метод, endpoint без секретных параметров, request id, idempotency key в обезличенном виде, номер попытки, статус и длительность. Не записывайте токены, платёжные данные и полный payload. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.

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

  1. Опишите побочный эффект операции и ключ, по которому можно проверить состояние.
  2. Разделите методы и статусы: чтение, идемпотентная запись, создание и явный отказ.
  3. Задайте общий deadline для всей операции и передавайте остаток времени в каждый вызов.
  4. Разрешите retry только для подтверждённых сценариев. Для POST сначала зафиксируйте idempotency key и правила сервера.
  5. Ограничьте число попыток, суммарную задержку и долю трафика на повторы.
  6. Обработайте Retry-After, backoff и jitter. При росте 503 или превышении бюджета остановите повтор.
  7. Сохраните безопасный контекст попытки и отделите timeout от ответа сервера.
  8. Проверьте чтение после 503, timeout после принятой записи и отказ без повторной отправки.

Ограничения

Ни один клиент не узнает из timeout, выполнил ли сервер запись. Это ограничение протокола, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация требует поддержки на сервере. Если такого контракта нет, безопасное действие после timeout — остановиться и передать операцию на разбор, а не угадывать.

Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. QUIC и HTTP/2 могут менять транспортное поведение, однако транспорт не превращает создание заказа через POST в идемпотентную операцию.

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

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

Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.

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

"} +{"index":18,"slug":"editorial-2027-07-practice-reliability-capstone","title":"Retry без двойной записи: как связать timeout, 503 и идемпотентность","excerpt":"Разбираем, почему повтор HTTP-запроса нельзя включать одной настройкой. Сначала проверяем семантику операции, затем ограничиваем время, попытки и последствия сбоя.","contentHtml":"

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

Есть и обратный симптом. Чтение каталога получает 503 Service Unavailable, клиент сразу сдаётся, а пользователь видит отказ, хотя зависимость восстановилась через секунду. Команда добавляет общий retry для всех запросов и исправляет один сценарий, но открывает другой: небезопасную операцию можно выполнить повторно.

Тезис простой: retry — это часть контракта операции, а не свойство сетевого клиента. Клиент должен знать, что он повторяет, сколько времени осталось, какой ответ разрешает повтор и как отличить неизвестный результат записи от подтверждённого отказа.

Механизм: ответ и результат — не одно и то же

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

У запроса есть две разные характеристики. Безопасный метод не просит изменить состояние ресурса; сервер при этом всё равно может вести журнал, считать метрики или выполнять другие побочные действия. Идемпотентная операция допускает повторение с тем же намеренным эффектом на ресурсе. GET безопасен и идемпотентен по HTTP-семантике, а PUT и DELETE относятся к идемпотентным методам; конкретный endpoint всё равно должен соблюдать эту семантику. POST, который создаёт новый ресурс, не следует автоматически повторять без отдельного контракта. Заголовок Idempotency-Key меняет правило только тогда, когда сервер действительно хранит ключ, результат и срок его действия.

Статус 503 не является командой «повтори». Он говорит, что сервис временно не готов обработать запрос. Заголовок Retry-After может задать паузу. Клиент всё равно должен проверить метод, deadline, лимит попыток и нагрузку на зависимость. Повтор через proxy может снова попасть в перегруженный origin.

Диагностика повтора HTTP-запроса
СимптомПричинаПроверкаДействие
После timeout появились две записиСервер принял POST, но клиент не получил ответСверить request id и журнал транзакцииНе повторять без ключа или проверки состояния
GET завершился после первого 503Клиент не различает чтение и записьПроверить метод, status и deadlineПовторить ограниченно с backoff
Три попытки вышли за времяЛимит попыток не связан с deadlineИзмерить запросы и паузыСчитать остаток времени перед каждой попыткой
Сервис перегружается после сбояКлиенты повторяют одновременноСопоставить rate retry, 503 и нагрузку originДобавить jitter, бюджет и circuit breaker
Причина ошибки исчезает в логахВсе исключения сведены к одному типуПроверить method, status, attempt и request idСохранить безопасный контекст без payload
Дерево решения для HTTP-повтора: deadline, метод, статус и окончательное действие
Безопасное решение начинается с deadline и семантики метода. Статус 503 не отменяет проверку побочного эффекта.

Минимальный пример с общим deadline

Ниже — учебная клиентская функция для локального endpoint. Если сервер отвечает двумя 503, а затем 200, функция сделает три попытки в пределах общего deadline. Она не моделирует потерю ответа после записи, балансировщик, очередь и реальную нагрузку. Результат примера нельзя выдавать за производственный замер.

async function getWithRetry(url, { maxAttempts = 3, deadlineMs = 1000 } = {}) { const deadline = Date.now() + deadlineMs; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('deadline exceeded'); const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(remaining) }); if (response.ok) return { attempt, status: response.status }; if (response.status !== 503 || attempt === maxAttempts) throw new Error('stop on status ' + response.status); const delay = Math.min(50 * attempt, Math.max(0, deadline - Date.now())); await new Promise((resolve) => setTimeout(resolve, delay)); } }

Функция повторяет только GET. Она ограничивает суммарное время, а не умножает timeout на число попыток, и не оставляет backoff за пределами deadline. AbortSignal.timeout требует среды, где этот API доступен; для другой среды нужен эквивалентный механизм отмены. В настоящем клиенте нужно отдельно обработать сетевую ошибку, проверить Retry-After, добавить случайную добавку к паузе и передать request id. Для POST эта функция не подходит: её сигнатура специально не принимает тело и метод.

Последовательность важна. Сначала проверяется остаток deadline. Затем клиент получает ответ. Успех возвращается сразу. 503 разрешает следующую попытку только при оставшемся времени и ненулевом бюджете. Другой статус останавливает цикл. Ошибка валидации или авторизации не превращается в поток бесполезных повторов.

Что делать с записью

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

Idempotency key должен входить в контракт API. В одном из вариантов контракта сервер сохраняет ключ вместе с параметрами и результатом и возвращает тот же результат при повторе того же ключа. Надо определить срок хранения, связь ключа с параметрами запроса и ответ при несовпадении параметров. Если клиент отправит тот же ключ с другим заказом, сервер должен отклонить запрос, а не изменить исходную операцию.

Логирование помогает расследованию, но не делает повтор безопасным. Записывайте метод, endpoint без секретных параметров, request id, idempotency key в обезличенном виде, номер попытки, статус и длительность. Не записывайте токены, платёжные данные и полный payload. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.

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

  1. Опишите побочный эффект операции и ключ, по которому можно проверить состояние.
  2. Разделите методы и статусы: чтение, идемпотентная запись, создание и явный отказ.
  3. Задайте общий deadline для всей операции и передавайте остаток времени в каждый вызов.
  4. Разрешите retry только для подтверждённых сценариев. Для POST сначала зафиксируйте idempotency key и правила сервера.
  5. Ограничьте число попыток, суммарную задержку и долю трафика на повторы.
  6. Обработайте Retry-After, backoff и jitter. При росте 503 или превышении бюджета остановите повтор.
  7. Сохраните безопасный контекст попытки и отделите timeout от ответа сервера.
  8. Проверьте чтение после 503, timeout после принятой записи и отказ без повторной отправки.

Ограничения

Из одного timeout клиент не узнает, выполнил ли сервер запись. Это ограничение наблюдаемого результата, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация — поддержки на сервере. Если такого контракта нет, автоматический клиент не должен повторять запрос вслепую: безопаснее остановиться и передать операцию на разбор.

Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. Транспортный протокол также не превращает создание заказа через POST в идемпотентную операцию.

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

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

Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.

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

"} diff --git a/editorial/agent-rewrites/019.json b/editorial/agent-rewrites/019.json index 952c029..ce6003f 100644 --- a/editorial/agent-rewrites/019.json +++ b/editorial/agent-rewrites/019.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-06-field-performance-capstone", "title": "p95 не изменился: как найти настоящую причину медленной страницы", "excerpt": "Уменьшение bundle не гарантирует быстрый ответ. Разбираем полевой симптом, разделяем сервер, сеть и браузер, а затем принимаем решение по повторяемому p95.", - "contentHtml": "

После релиза JavaScript-бандл стал меньше на 180 КБ, но p95 загрузки каталога остался около 3,1 секунды. Пользователь по-прежнему видит пустой первый экран. Цена ошибки — потратить спринт на минификацию, а затем обнаружить, что запрос к базе ждёт 1,8 секунды или браузер тратит время на главный поток. Размер файла изменился. Причина задержки могла остаться прежней.

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

Что именно измеряет p95

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

У любой цифры есть граница. TTFB показывает, когда начал приходить ответ. Он не описывает выполнение JavaScript. LCP показывает момент отрисовки крупнейшего видимого элемента, но зависит от HTML, CSS, шрифта, изображения, viewport и устройства. Размер bundle показывает объём передачи и распаковки, но не говорит, какой запрос блокирует страницу. Эти значения нужно хранить раздельно.

Минимальный протокол одного сравнимого замера
ПолеПримерЗачем оно нужно
Версияcommit abc123Связать результат с конкретным кодом
УсловияChromium, 1280×800, cold cacheНе смешать разные сценарии
Выборка20 повторовПонимать устойчивость p95
Сырые данныеJSON со всеми значениямиПроверить выбросы и пересчитать итог
МетрикиTTFB, LCP, p95, long tasksОтделить сервер от браузера
\"Цикл
Сравнение возвращается к тем же условиям после одного изменения. Иначе разницу нельзя уверенно связать с исправлением.

Механизм: задержка складывается из разных очередей

Навигация начинается с запроса документа. До первого байта браузер ждёт сеть, proxy и сервер. Сервер в этот момент может ждать соединение с базой, блокировку или внешний сервис. После первого байта браузер получает остальной HTML. Затем parser встречает CSS и обычные script. Они меняют порядок загрузки и работы главного потока. Позже браузер выбирает крупный элемент для LCP.

Пусть время до полезного экрана можно представить как сумму server_wait + html_transfer + blocking_resources + main_thread_work + paint. Это не универсальная формула пользовательской метрики. Это рабочая карта расследования. Если TTFB вырос, сначала ищите серверную или сетевую задержку. Если TTFB стабилен, а LCP вырос, смотрите ресурсы, layout и JavaScript. Изменение одной части не подтверждает улучшение всей страницы.

В браузере начните с записи навигации и ресурсов. performance.getEntriesByType('navigation')[0] даёт временные точки документа. Для ресурсов используйте performance.getEntriesByType('resource'). Сопоставьте ранние записи с HTML-тегом или инициатором в waterfall. Не делайте вывод по одной полосе: ресурс мог загрузиться рано, но не влиять на первый экран.

Учебный локальный замер HTTP-пути

Следующий пример специально ограничен локальным HTTP-путём. Он не моделирует браузер, мобильную сеть, CDN или реальную базу данных. Сервер задерживает ответ на 40 миллисекунд. Клиент делает двадцать одинаковых запросов, сохраняет сырые времена и считает медиану и p95. Это позволяет проверить арифметику и увидеть влияние выброса до анализа страницы.

import { performance } from 'node:perf_hooks'; import { createServer } from 'node:http'; const server = createServer((request, response) => { setTimeout(() => response.end('ready'), 40); }); function percentile(values, rank) { const sorted = [...values].sort((a, b) => a - b); const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1); return sorted[index]; } server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); const samples = []; for (let attempt = 0; attempt < 20; attempt += 1) { const started = performance.now(); await (await fetch('http://127.0.0.1:' + port)).text(); samples.push(performance.now() - started); } console.log({ count: samples.length, median: percentile(samples, 0.5).toFixed(1), p95: percentile(samples, 0.95).toFixed(1), samples: samples.map(value => value.toFixed(1)) }); server.close(); });

В нормальном запуске count равен 20, а значения находятся немного выше 40 миллисекунд из-за накладных расходов процесса. Точное число зависит от машины. Это ожидаемый учебный результат, а не production-результат. Если добавить один искусственный выброс, p95 вырастет, хотя девятнадцать запросов не изменились. Поэтому отчёт хранит и итог, и выборку.

В рабочем замере не смешивайте cold и warm cache. В первом режиме браузер и CDN скачивают ресурсы. Во втором часть данных уже доступна локально. Если перемешать режимы, p95 описывает смесь сценариев. То же относится к viewport, throttling, версии браузера, размеру ответа и состоянию данных.

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

Карта решения после первого наблюдения
СимптомВозможная причинаПроверкаДействие
TTFB и p95 вырослисервер ждёт базу или upstreamtrace, серверные тайминги, план запросаисправить узкий участок и повторить тот же сценарий
TTFB стабилен, LCP выросблокирующий CSS, шрифт или scriptwaterfall, resource entries, long tasksизменить порядок или размер ресурса, затем проверить первый экран
bundle меньше, LCP тот жеузким местом был не bundleсравнить TTFB, ресурсы и главный потокне объявлять успех; выбрать доминирующий участок
Среднее лучше, p95 хужестал тяжелее хвост или появились выбросысырые значения, размер выборки, нагрузканайти поздние запуски и не заменять p95 средним
Метрика пропаланет поддержки или запись ограничена политикой доступаsupportedEntryTypes, браузер, originпометить отсутствие и выбрать доступный сигнал

Как связать цифру с причиной

Сначала найдите доминирующий участок, а не самое знакомое слово в отчёте. Высокий TTFB не доказывает, что виновата база. Он только говорит, что ответ начал приходить поздно. Разделите server timing, сеть и proxy. Если серверная часть стабильна, проверьте передачу HTML и очередь ресурсов.

Ранний script тоже не равен проблеме. Он может быть маленьким и нужным для маршрутизации. Большой script может прийти поздно и не влиять на LCP, если крупный элемент уже отрисован. Смотрите на блокировку главного потока и на связь с конкретным элементом. В отрицательном пути команда не находит причины, потому что проверяет только размер файла. Тогда замер нужно остановить и расширить до навигации, ресурсов и trace.

Изменяйте один фактор за раз. Например, сначала уберите лишний preload, затем повторите двадцать запусков с теми же условиями. Не меняйте одновременно SQL, компрессию, порядок script и viewport. Иначе улучшение или регрессия не принадлежит одному решению.

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

  1. Запишите симптом, URL, commit, браузер, viewport, сеть и режим кэша.
  2. Выберите одну метрику решения и сохраните связанные метрики: TTFB, LCP, p95 и long tasks.
  3. Сделайте одинаковую серию запусков и сохраните каждое сырое значение.
  4. Разделите задержку на сервер, HTML, ресурсы, главный поток и отрисовку.
  5. Проверьте одну гипотезу минимальным изменением, которое можно откатить.
  6. Повторите серию при тех же условиях и сравните распределения, а не только средние.
  7. Проверьте отрицательный путь: cold cache, слабый CPU, поздний upstream или отсутствующую запись.
  8. Зафиксируйте действие, ограничение вывода и результат для того же критерия.

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

Локальный стенд проверяет код подсчёта, но не пользовательскую скорость. Лабораторный browser-run показывает повторяемость, но не покрывает все устройства и сети. Полевой p95 зависит от состава пользователей, частоты запусков и способа агрегации. Порог нельзя переносить между страницами без объяснения.

Работа готова, когда другая команда может открыть отчёт, увидеть исходные условия и пересчитать p95 из сохранённых значений. В отчёте есть один доминирующий участок, проверенная гипотеза, повтор после одного изменения и отрицательный сценарий. Для страницы критерий должен включать конкретную метрику и порог, например: p95 LCP не выше согласованного значения при указанном браузере, viewport, сети и режиме кэша. Это проверяемое утверждение. «Стало быстрее» — нет.

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

" + "contentHtml": "

После релиза JavaScript-бандл стал меньше на 180 КБ, но p95 загрузки каталога остался около 3,1 секунды. Пользователь по-прежнему видит пустой первый экран. Цена ошибки — потратить спринт на минификацию, а затем обнаружить, что запрос к базе ждёт 1,8 секунды или браузер тратит время на главный поток. Размер файла изменился; причина задержки могла остаться прежней.

Числа в этом вступительном сценарии — иллюстрация, а не выгрузка telemetry конкретного проекта. Практический вопрос от этого не меняется: как доказать, какой участок пути тормозит страницу? Разложим один запуск на ожидание ответа, передачу документа и ресурсов, работу главного потока и отрисовку. Затем повторим измерение с теми же условиями. Результат становится инженерным доказательством, когда рядом с ним сохранены входы, сырые наблюдения, метод расчёта и критерий решения.

Что именно измеряет p95

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

Маленькая выборка делает хвост хрупким. На двадцати измерениях nearest-rank p95 фактически выбирает второе значение с конца; один поздний запуск заметно меняет результат. На пяти измерениях это ещё менее устойчиво. Поэтому в отчёте указывайте число наблюдений и храните исходные значения. Для полевой метрики отдельно фиксируйте окно агрегации, сегмент пользователей и правила исключения.

Не смешивайте показатели. TTFB — согласованная командой оценка времени до начала ответа, обычно связанная с точкой responseStart; она не описывает выполнение JavaScript. LCP — момент отрисовки крупнейшего видимого элемента в рамках правил метрики; он зависит от HTML, CSS, шрифта, изображения, viewport и устройства. Размер bundle описывает объём ресурса, но не говорит, какой запрос или задача главного потока задерживает первый экран.

Минимальный протокол сравнимого замера
ПолеПримерЗачем фиксировать
Версияcommit abc123Связать результат с кодом
Сценарийоткрыть каталог и дождаться первого экранаНе менять пользовательское действие между сериями
УсловияChromium, 1280×800, cold cacheНе смешивать разные режимы
Выборка20 повторов в лабораторииПонимать устойчивость хвоста
Сырые данныеJSON со всеми значениямиПересчитать итог и увидеть выбросы
МетрикиTTFB, LCP, p95, long tasksОтделить сервер от браузера
\"Цикл
Один фактор меняется между двумя сериями, а все остальные условия возвращаются к baseline. Так результат можно связать с конкретным решением.

Механизм: задержка складывается из разных очередей

Навигация начинается с запроса документа. До первого байта браузер ждёт сеть, proxy и сервер. Сервер в это время может ждать соединение с базой, блокировку, очередь или внешний сервис. После первого байта браузер получает остальной HTML. Затем parser обнаруживает CSS, обычные script и другие зависимости: они влияют на порядок загрузки и работу главного потока. LCP появляется только после того, как браузер нашёл и отрисовал подходящий крупнейший элемент.

Для расследования полезна рабочая карта server_wait + html_transfer + blocking_resources + main_thread_work + paint. Это не универсальная формула пользовательской метрики: этапы могут перекрываться, а браузерные и серверные часы не образуют простую арифметическую сумму. Карта нужна, чтобы задать следующий вопрос. Если до первого байта стало дольше, проверяйте сервер и сеть. Если эта часть стабильна, а LCP вырос, переходите к ресурсам, layout и JavaScript.

Navigation Timing предоставляет временные точки навигации, включая начало записи и получение первого и последнего байта. Resource Timing даёт записи о ресурсах и их инициаторах. В браузере можно начать с performance.getEntriesByType('navigation')[0] и performance.getEntriesByType('resource'), но не считать наличие записи доказательством влияния на LCP. Сопоставьте запись с waterfall, HTML-тегом, initiator и фактически изменившимся элементом.

Учебный локальный замер HTTP-пути

Этот пример намеренно ограничен loopback HTTP-путём. Он не моделирует браузер, мобильную сеть, CDN, TLS, реальную базу или пользовательский состав. Сервер добавляет 40 миллисекунд перед ответом, клиент последовательно делает двадцать запросов, сохраняет сырые времена и считает медиану и p95. Это способ проверить арифметику до анализа страницы, а не доказательство production-производительности.

import { performance } from 'node:perf_hooks';\nimport { createServer } from 'node:http';\n\nconst server = createServer((_request, response) => {\n  setTimeout(() => response.end('ready'), 40);\n});\n\nawait new Promise((resolve) =>\n  server.listen({ host: '127.0.0.1', port: 0 }, resolve)\n);\n\nconst { port } = server.address();\nconst samples = [];\n\ntry {\n  for (let attempt = 0; attempt < 20; attempt += 1) {\n    const started = performance.now();\n    const response = await fetch('http://127.0.0.1:' + port);\n    await response.text();\n    samples.push(performance.now() - started);\n  }\n} finally {\n  await new Promise((resolve) => server.close(resolve));\n}\n\nfunction nearestRank(values, rank) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const index = Math.max(0, Math.ceil(sorted.length * rank) - 1);\n  return sorted[index];\n}\n\nconsole.log({\n  count: samples.length,\n  medianMs: nearestRank(samples, 0.5).toFixed(1),\n  p95Ms: nearestRank(samples, 0.95).toFixed(1),\n  samplesMs: samples.map((value) => value.toFixed(1)),\n});

Сохраните код как measure.mjs и запустите в Node.js 18 или новее, где доступен глобальный fetch. Обычно значения будут немного выше 40 миллисекунд: к искусственной задержке добавятся loopback, HTTP и планирование процесса. Точное число зависит от машины. Функция использует nearest-rank только для прозрачного учебного примера; в production заранее договоритесь о методе percentile и применяйте его одинаково к baseline и новой серии.

Добавьте один искусственный выброс и увидите, что p95 меняется, хотя большинство запросов осталось прежним. Это показывает чувствительность хвоста, но не доказывает регрессию страницы. Для браузерного вывода нужны отдельные запуски с Navigation Timing, Resource Timing и наблюдением LCP. Для серверной причины полезно связать request ID с trace и при необходимости передать этапы через Server-Timing.

Какие наблюдения разделяют гипотезы

От симптома к проверке и следующему действию
НаблюдениеЧто оно поддерживаетЧто проверить дальшеОграничение вывода
TTFB и LCP выросли вместедокумент начал приходить позжеserver timing, trace, очередь, база, upstreamTTFB не указывает единственную причину
TTFB стабилен, LCP выроспроблема возникла после первого байтаwaterfall, render-blocking ресурсы, long tasks, элемент LCPнужен браузерный запуск, а не только серверный лог
bundle меньше, LCP прежнийbundle не был доминирующим участкомсравнить resource entries и CPU-профильзаявление ограничено выбранным сценарием
среднее лучше, p95 хужехвост стал тяжелее или появились выбросысырые значения, размер выборки, сегменты и нагрузкасреднее не заменяет хвостовую метрику
тайминг ресурса неполныйограничение API или cross-originorigin, Timing-Allow-Origin, браузер и тип записинельзя додумывать скрытые значения

Высокий TTFB не доказывает вину базы. Он фиксирует задержку до согласованной точки ответа; причиной могут быть соединение, очередь, proxy, серверный код или upstream. Если сервер отдаёт Server-Timing, браузер может получить названия и длительности заявленных этапов, но это сигнал для сопоставления с trace и логами, а не автоматический root cause.

Ранний script тоже не равен проблеме. Он может быть небольшим и нужным для маршрутизации. Большой script может прийти после отрисовки LCP. Смотрите на фактическую блокировку главного потока, initiator и связь с выбранным элементом. Если trace не подтверждает гипотезу, вернитесь к разбиению пути, а не усиливайте оптимизацию по привычке.

Как поставить эксперимент после изменения bundle

Сначала зафиксируйте baseline: commit, URL, действие пользователя, браузер, класс CPU, viewport, сеть, состояние cache, размер данных и время запуска. Разделите cold cache и warm cache. В первом режиме часть ресурсов и соединений отсутствует локально; во втором путь уже прогрет. Смешанная серия отвечает на неясный вопрос и может скрыть регрессию.

Затем выберите один критерий решения. Например: p95 LCP не выше согласованного порога в mobile-профиле при заданном throttling и cold cache. Рядом храните TTFB, время до responseEnd, размер переданных ресурсов и число long tasks. Сам порог — продуктовая договорённость, а не число, которое автоматически следует из W3C API.

Уберите или уменьшите одного кандидата: конкретный preload, импорт или блокирующий script. Не меняйте одновременно SQL, CDN, компрессию и порядок HTML. Повторите ту же серию, сохраните все наблюдения и сравните распределения. Если LCP не изменился, это полезный результат: bundle не был причиной на выбранном сценарии. Если улучшилось только среднее, решение ещё не подтверждено.

Порядок расследования

  1. Запишите жалобу и наблюдаемый симптом: URL, действие пользователя, метрику и диапазон значений.
  2. Привяжите замер к commit и зафиксируйте браузер, viewport, CPU, сеть, cache и размер данных.
  3. Сделайте серию одинаковых запусков, сохраните каждое значение и метод расчёта percentile.
  4. Разделите путь на ожидание ответа, передачу документа, ресурсы, главный поток и отрисовку.
  5. Выберите доминирующий участок по данным и сформулируйте одну проверяемую гипотезу.
  6. Проведите минимальное обратимое изменение, не меняя остальные условия.
  7. Повторите baseline-серию и сравните p95, связанные метрики и сырые значения.
  8. Проверьте отрицательный путь: cold cache, слабый CPU, поздний upstream, cross-origin без разрешения или отсутствующую запись.
  9. Запишите, что доказано, что осталось неизвестным, какое ограничение действует и кто сможет повторить проверку.

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

Локальный HTTP-тест проверяет арифметику и порядок работы клиента, но не пользовательскую скорость. Лабораторный browser-run помогает сравнить контролируемые условия, но не покрывает все устройства, сети и состав контента. Полевой p95 зависит от числа наблюдений, агрегации, сегментов и того, как инструмент исключает или учитывает невалидные записи. Поэтому один порог нельзя без объяснения переносить между страницами.

LCP — пользовательский сигнал о крупнейшем видимом элементе, а не универсальный показатель завершения приложения. Элемент может измениться, поздняя загрузка изображения может сдвинуть момент, а одно и то же значение не раскрывает серверную причину. TTFB также не заменяет trace. Для честного вывода указывайте, какая метрика была критерием, на какой выборке и в каких условиях она получена.

Расследование закончено, когда другая команда может открыть отчёт, увидеть baseline и повторить сравнение. В отчёте есть исходные данные, метод расчёта, один доминирующий участок, проверенная гипотеза, повтор после одного изменения и отрицательный сценарий. «Стало быстрее» недостаточно; проверяемая формулировка выглядит так: «при заданных URL, браузере, viewport, сети и cache p95 LCP изменился с A до B, а TTFB и размер ресурсов изменились на C и D».

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

" } diff --git a/editorial/agent-rewrites/020.json b/editorial/agent-rewrites/020.json index d676f62..8321fcf 100644 --- a/editorial/agent-rewrites/020.json +++ b/editorial/agent-rewrites/020.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-06-mechanism-performance-capstone", "title": "Waterfall без иллюзий: как доказать, что ресурс задерживает первый экран", "excerpt": "Длинная полоса в waterfall не равна причине задержки. Разбираем зависимость ресурса, инициатора и потребителя, а затем проверяем одно изменение на учебной странице.", - "contentHtml": "

Пользователь видит пустой или неполный первый экран. В DevTools рядом с ним растягивается полоса шрифта или изображения. Команда объявляет этот ресурс виновником и меняет порядок загрузки. Через релиз экран не ускоряется, зато появляется лишний preload, вспышка нестилизованного текста или гонка между скриптами. Цена ошибки — не только потерянные миллисекунды. Вы меняете контракт загрузки, не доказав, что страницу действительно задерживал этот запрос.

\n

Waterfall показывает время сетевой работы. Он не показывает причинность сам по себе. Ресурс влияет на экран только тогда, когда его результат нужен конкретной зависимости: parser ждёт script, CSS нужен для построения стилей, layout ждёт шрифт, а компонент ждёт данные. Поэтому тезис статьи простой: ищите не самую длинную полосу, а цепочку «инициатор → ресурс → потребитель → наблюдаемый эффект».

\n

Что именно нужно доказать

\n

У каждой записи ресурса есть временная и причинная часть. startTime говорит, когда запрос начал работу, а responseEnd — когда браузер получил последний байт. initiatorType помогает понять, кто начал запрос: parser, script, css, fetch или другой источник. Эти поля описывают наблюдение. Они ещё не отвечают, ждал ли результат первый экран.

\n

Для причинности добавьте три вопроса. Какой элемент или код потребляет ответ? В какой момент он потребляет его? Что изменится, если ответ придёт позже или не придёт вовсе? Если на последний вопрос нет проверяемого ответа, запись остаётся кандидатом. Её нельзя называть узким местом.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
CSS заканчивается поздно, первый экран пустойСтили нужны до первого layoutСопоставить тег link, DOM и момент первого визуального результатаУменьшить критический CSS или разделить его, затем проверить layout shift
Script начинается рано и долго выполняетсяParser или главный поток ждёт выполнениеПроверить атрибуты, initiator и long taskПрименить defer или разбить работу только после проверки зависимостей
Шрифт имеет длинную полосу, но контент уже виденШрифт не нужен первому экрану или заменяется fallbackСравнить момент ответа шрифта с визуальным результатом и layoutНе добавлять preload; проверить font-display и потребителя
JSON заканчивается поздно, карточки пустыеКомпонент ждёт fetchНайти вызов, состояние ожидания и время отображения данныхОптимизировать запрос или skeleton, не меняя случайные сетевые приоритеты
Одна запись отсутствует в отчётеНет поддержки поля, кросс-доменное ограничение или буфер очищенПроверить браузер, Timing-Allow-Origin и момент чтенияПометить «нет данных» и выбрать другой сигнал
\n
\"Матрица
Иллюстрация связывает строку waterfall с проверкой зависимости. Размер ресурса — только один из входов, а не итоговый вердикт.
\n

Модель загрузки на конкретном примере

\n

Рассмотрим страницу с таким HTML:

\n
<head>\n  <link rel=\"stylesheet\" href=\"/app.css\">\n  <script src=\"/vendor.js\" defer></script>\n  <script src=\"/analytics.js\" async></script>\n</head>\n<body>\n  <main id=\"catalog\"></main>\n  <script src=\"/catalog.js\" defer></script>\n</body>
\n

app.css может влиять на первый layout. vendor.js и catalog.js загружаются параллельно с разбором HTML, но выполняются после разбора документа и сохраняют порядок между собой. analytics.js выполняется, когда загрузится, поэтому не должен зависеть от глобального объекта, который создаёт другой script. Его длинная полоса не объясняет задержку каталога, если аналитика не участвует в рендере.

\n

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

\n
const navigation = performance.getEntriesByType('navigation')[0];\nconst domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;\n\nconst resources = performance\n  .getEntriesByType('resource')\n  .filter((entry) => entry.startTime < domEnd)\n  .map((entry) => ({\n    name: new URL(entry.name).pathname,\n    initiator: entry.initiatorType || 'unknown',\n    start: Math.round(entry.startTime),\n    end: Math.round(entry.responseEnd),\n    duration: Math.round(entry.duration),\n    blocking: entry.renderBlockingStatus ?? 'unknown',\n  }));\n\nconsole.table(resources);
\n

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

\n

Дальше найдите инициатор в HTML или исходном коде. Для parser проверьте тег. Для script найдите вызов fetch, импорт или создание элемента. Для css проверьте правило и используемый шрифт. Затем назовите потребителя. Формулировка «CSS заканчивается поздно» слишком слабая. Формулировка «карточки не получают стили до первого layout, потому что link указывает на полный файл» уже задаёт проверяемое действие.

\n

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

\n
  1. Зафиксируйте URL, браузер, viewport, сеть, режим кэша и состояние авторизации. Без этих условий две записи waterfall нельзя честно сравнить.
  2. Опишите симптом словами пользователя: пустой первый экран, поздние карточки, скачок текста или задержка интерактивности. Запишите момент и элемент, а не только общую длительность.
  3. Снимите navigation entry и resource entries в одном прогоне. Сохраните исходные значения, включая URL, инициатор, время начала, конец ответа и доступный статус блокировки.
  4. Отберите ранние записи по типу и времени. Для каждой найдите конкретный тег, вызов или CSS-правило, которое запустило запрос.
  5. Назовите потребителя и проверьте его зависимость. Удалите ресурс, отложите его в учебной копии или замените ответом-заглушкой. Если симптом не меняется, гипотеза не подтверждена.
  6. Измените один фактор: defer, разделение CSS, порядок запроса или код потребителя. Не смешивайте изменение сети с изменением рендера.
  7. Повторите прогон в тех же условиях. Сравните визуальный критерий, long tasks и нужный участок waterfall. Сокращение отдельной полосы без изменения симптома не считается успехом.
\n

Когда популярные исправления вредят

\n

preload запускает запрос раньше. Это полезно для действительно критичного ресурса, но лишний preload конкурирует с HTML, CSS и данными. Укажите правильный as и проверьте, что документ использует ответ. Иначе браузер предупреждает о неиспользованной загрузке, а критический путь становится шире.

\n

async освобождает parser, но отдаёт порядок выполнения сети. Он подходит для независимого кода. Если script читает объект, который создаёт другой script, или меняет DOM до инициализации компонента, появится гонка. defer сохраняет порядок отложенных скриптов, но не уменьшает размер файла и не доказывает, что работа главного потока стала короче.

\n

Разделение CSS снижает ранний объём только при точной границе. Ошибка даёт FOUC, неверный порядок правил или layout shift. Отложенный шрифт может убрать блокировку, но изменить переносы строк и высоту блока. Каждое исправление нужно оценивать по тому же потребителю, который породил исходный симптом.

\n

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

\n

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

\n

Resource Timing зависит от браузера и политики доступа. Кросс-доменные значения могут быть скрыты без Timing-Allow-Origin. renderBlockingStatus может отсутствовать. DOMContentLoaded не равен FCP или LCP, а локальная сеть не воспроизводит мобильное устройство. Записи Performance API не описывают серверную очередь, весь главный поток и пользовательский опыт одной цифрой.

\n

Учебный код не даёт production-результатов. Его задача — показать форму проверки. Для реального решения нужны повторяемые прогоны в целевых браузерах, сохранённые условия и выбранный визуальный критерий. Не называйте ресурс блокирующим только потому, что он большой, ранний или заметный на скриншоте DevTools.

\n

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

\n

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

" + "contentHtml": "

Пользователь видит пустой или неполный первый экран. В DevTools рядом с ним растягивается полоса шрифта или изображения. Команда объявляет этот ресурс виновником и меняет порядок загрузки. Через релиз экран не ускоряется, зато появляется лишний preload, вспышка нестилизованного текста или гонка между скриптами. Цена ошибки — не только потерянные миллисекунды. Вы меняете контракт загрузки, не доказав, что страницу действительно задерживал этот запрос.

\n

Waterfall показывает время сетевой работы, но не причинность. Ресурс влияет на экран только тогда, когда его результат нужен конкретной зависимости: обычный внешний script может приостановить разбор HTML, рендер может ждать таблицу стилей, layout — шрифт, а компонент — данные. Поэтому ищите не самую длинную полосу, а цепочку «инициатор → ресурс → потребитель → наблюдаемый эффект».

\n

Причина начинается с потребителя

\n

У записи ресурса есть временная и причинная часть. startTime показывает начало работы запроса, а responseEnd — момент, когда браузер получил последний байт. initiatorType описывает тип инициатора: например, link или img для HTML-элемента, css для URL из CSS, script для загрузки скрипта и fetch для Fetch API. Эти поля фиксируют наблюдение, но не сообщают, ждал ли результат первый экран.

\n

Для причинности ответьте на три вопроса. Какой элемент или код потребляет ответ? В какой момент он потребляет его? Что изменится, если ответ придёт позже или не придёт вовсе? Если на третий вопрос нет проверяемого ответа, запись остаётся кандидатом. Её нельзя называть узким местом.

\n

Это различие удобно представить как контракт. Инициатор объясняет, откуда взялся запрос. Ресурс объясняет, что приехало и когда. Потребитель объясняет, какая операция не может продолжиться без результата. Наблюдаемый эффект связывает техническую цепочку с пользовательским симптомом: поздним текстом, пустыми карточками, скачком высоты или недоступной кнопкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
CSS заканчивается поздно, первый экран пустойСтили нужны до первого визуального результатаСопоставить тег link, DOM и момент появления элементаУменьшить критический CSS или разделить его, затем проверить layout shift
Script начинается рано и долго выполняетсяРазбор или главный поток ждёт выполнениеПроверить атрибуты, инициатор и long taskПрименить defer или разбить работу после проверки зависимостей
Шрифт имеет длинную полосу, но контент уже виденШрифт не нужен первому экрану или заменяется fallbackСравнить ответ шрифта с визуальным результатом и layoutНе добавлять preload; проверить font-display и потребителя
JSON заканчивается поздно, карточки пустыеКомпонент ждёт fetchНайти вызов, состояние ожидания и момент отображения данныхОптимизировать запрос или состояние загрузки
Запись неполная или отсутствуетНет поддержки поля, ограничен доступ или очищен буферПроверить браузер, Timing-Allow-Origin и момент чтенияПометить «нет данных» и выбрать другой сигнал
\n
\"Матрица
Строка waterfall становится доказательством только после связи с потребителем. Размер ресурса — один из входов, а не итоговый вердикт.
\n

Как браузер строит критический путь

\n

Рассмотрим страницу с таким HTML:

\n
<head>\n  <link rel='stylesheet' href='/app.css'>\n  <script src='/vendor.js' defer></script>\n  <script src='/analytics.js' async></script>\n</head>\n<body>\n  <main id='catalog'></main>\n  <script src='/catalog.js' defer></script>\n</body>
\n

app.css может задержать отображение стилизованного содержимого. vendor.js и catalog.js загружаются во время разбора HTML, а классические скрипты с defer выполняются после разбора и сохраняют порядок между собой. analytics.js с async выполняется после загрузки независимо от порядка других скриптов. Поэтому он подходит для независимой аналитики, но не для кода, которому нужен ещё не созданный глобальный объект.

\n

У этой схемы есть два разных пути. CSS и отложенные скрипты могут влиять на то, когда компонент получит DOM и стили. Аналитика может иметь длинную сетевую полосу и не участвовать в рендере вовсе. Если убрать её из учебной копии и первый экран не изменится, полоса была коррелятом, а не причиной.

\n

Проверяйте не только загрузку, но и работу главного потока. Быстрый ответ сервера не помогает, если после него выполняется длинная синхронная задача. И наоборот, поздний ресурс может не менять картинку, если компонент уже показал полезный fallback. В обоих случаях ответ находится на границе ресурса и его потребителя.

\n

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

\n

Учебный наблюдатель ниже запускается в консоли локальной или тестовой страницы после загрузки. Он не меняет сеть и не утверждает, что каждая ранняя запись блокирует экран. Полный url сохраняется намеренно: удаление query-параметров может смешать две разные версии ответа.

\n
const navigation = performance.getEntriesByType('navigation')[0];\nconst domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;\n\nconst resources = performance\n  .getEntriesByType('resource')\n  .filter((entry) => entry.startTime < domEnd)\n  .map((entry) => ({\n    url: entry.name,\n    initiator: entry.initiatorType || 'unknown',\n    start: Math.round(entry.startTime),\n    end: Math.round(entry.responseEnd),\n    duration: Math.round(entry.duration),\n    blocking: entry.renderBlockingStatus ?? 'unknown',\n  }));\n\nconst firstContentfulPaint = performance\n  .getEntriesByType('paint')\n  .find((entry) => entry.name === 'first-contentful-paint');\n\nconsole.table(resources);\nconsole.table({\n  domContentLoaded: navigation?.domContentLoadedEventEnd ?? 'unknown',\n  firstContentfulPaint: firstContentfulPaint?.startTime ?? 'unknown',\n});
\n

Фильтр ограничивает список ресурсами, которые начали работу до DOMContentLoaded. Это удобная граница для первичного поиска, но не доказательство готовности экрана: DOM мог закончить разбор, пока изображение, шрифт или отрисовка ещё продолжаются. first-contentful-paint фиксирует первое отображение текста или изображения, но не подтверждает, что пользователь увидел нужный блок. Если запись недоступна, сохраняйте unknown и измеряйте выбранный элемент браузерным сценарием.

\n

Дальше найдите инициатор в HTML или исходном коде. Для записи, инициированной HTML-элементом, проверьте тег. Для script найдите вызов fetch, импорт или создание элемента. Для css проверьте правило и используемый шрифт. Затем назовите потребителя. «CSS заканчивается поздно» — описание записи. «Карточки не получают стили до первого layout, потому что link указывает на полный файл» — проверяемая гипотеза.

\n

Проверка гипотезы одним изменением

\n
  1. Зафиксируйте URL, браузер, viewport, сеть, режим кэша и состояние авторизации. Без этих условий две записи waterfall нельзя честно сравнить.
  2. Опишите симптом словами пользователя: пустой первый экран, поздние карточки, скачок текста или задержка интерактивности. Запишите момент и элемент, а не только общую длительность.
  3. Снимите navigation entry, resource entries и выбранный paint-сигнал в одном прогоне. Сохраните исходные значения, URL, инициатор, время и доступный статус блокировки.
  4. Для ранней записи найдите конкретный тег, вызов или CSS-правило, которое запустило запрос. Затем назовите потребителя и его зависимость от ответа.
  5. Отложите ресурс, замените ответ заглушкой или удалите его только в учебной копии. Если симптом не меняется, гипотеза не подтверждена; не исправляйте production по одной полосе.
  6. Измените один фактор: defer, разделение CSS, порядок запроса или код потребителя. Не смешивайте изменение сети с изменением рендера.
  7. Повторите прогон в тех же условиях. Сравните визуальный критерий, first contentful paint, long tasks и нужный участок waterfall. Сокращение отдельной полосы без изменения симптома не считается успехом.
\n

Полезен и отрицательный контроль. Если вы проверяете гипотезу о шрифте, повторите прогон с тем же CSS, но с локальным fallback. Если проверяете JSON, оставьте тот же ответ и задержите только его доставку. Контроль должен менять ровно один фактор; иначе вы не узнаете, что именно повлияло на результат.

\n

Почему популярные исправления дают обратный эффект

\n

preload запускает запрос раньше, но не делает ресурс автоматически критичным. Лишняя предварительная загрузка конкурирует с HTML, CSS и данными. Укажите правильный as, проверьте совпадение URL и убедитесь, что документ действительно использует ответ. Иначе браузер скачает ресурс, который не изменит экран.

\n

async не гарантирует порядок выполнения. Это преимущество для независимого кода и источник гонок для зависимого. defer сохраняет порядок классических внешних скриптов и переносит их выполнение после разбора документа, но не уменьшает размер файла и не сокращает время работы главного потока.

\n

Разделение CSS снижает ранний объём только при точной границе. Ошибка даёт FOUC, неверный порядок правил или layout shift. Отложенный шрифт может убрать часть ранней работы, но изменить переносы строк и высоту блока. Оценивайте исправление по тому же потребителю, который породил исходный симптом.

\n

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

\n

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

\n

Resource Timing зависит от браузера и политики доступа. Кросс-доменные значения могут быть скрыты без Timing-Allow-Origin. renderBlockingStatus и paint-записи могут отсутствовать в конкретной реализации. DOMContentLoaded не равен FCP или LCP, а локальная сеть не воспроизводит мобильное устройство. Записи Performance API не описывают серверную очередь, весь главный поток и пользовательский опыт одной цифрой.

\n

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

\n

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

\n

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

" } diff --git a/editorial/agent-rewrites/021.json b/editorial/agent-rewrites/021.json index 5e00f94..2caf132 100644 --- a/editorial/agent-rewrites/021.json +++ b/editorial/agent-rewrites/021.json @@ -1 +1 @@ -{"index":21,"slug":"editorial-2027-06-practice-performance-capstone","title":"Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов","excerpt":"Как разобрать пустой первый экран на измеряемые участки: ожидание сервера, передача HTML и блокировка CSS или JavaScript.","contentHtml":"

Пользователь открывает страницу и несколько секунд видит пустой экран. В отчёте появляется одна цифра: «загрузка заняла 2,4 секунды». Этой цифры недостаточно для решения. 1,6 секунды могли уйти до первого байта HTML, а могли — на выполнение скрипта после ответа сервера. Цена ошибки — менять JavaScript, когда тормозит backend, или добавлять кеш, когда браузер ждёт блокирующий CSS.

Тезис статьи простой: сначала разделите критический путь на участки, затем меняйте один участок и повторяйте тот же замер. TTFB (time to first byte) показывает ожидание первого байта ответа. Он не показывает время до готового экрана. Передача HTML, CSS, JavaScript и работа главного потока требуют отдельных наблюдений.

Механизм: один экран, несколько причин

Браузер начинает навигацию с запроса. Сервер формирует ответ и отправляет первый байт. Только после этого браузер получает весь HTML и строит DOM. Когда parser встречает таблицу стилей, он ждёт CSSOM для расчёта стилей. Синхронный script может остановить parser. Отложенный код продолжает занимать главный поток даже после завершения сетевой загрузки.

Поэтому время нужно разложить. Участок до первого байта относится к сети, proxy и серверному обработчику. Участок от первого байта до конца ответа относится к размеру HTML и передаче. Ранний CSS влияет на построение стилей. Скрипт может задержать DOM, layout или обработку пользовательского ввода. Одна общая длительность скрывает владельца и действие.

Диагностика первой загрузки
СимптомПричинаПроверкаДействие
TTFB стабильно выше 300 мсСервер или зависимость задерживает начало ответаСравнить responseStart - requestStart и журнал handlerПрофилировать серверный путь; не начинать с bundle
TTFB нормален, HTML приходит долгоБольшой ответ или медленная передачаСравнить responseEnd - responseStart и размер HTMLУменьшить ответ, проверить сжатие и кеш
HTML пришёл, первый экран ждёт CSSРанний stylesheet блокирует построение стилейСверить waterfall, initiator и видимый результатСократить критический CSS или отложить второстепенный
Сеть закончилась, экран не готовДолгая задача на главном потокеПосмотреть long tasks и длительность scriptРазбить работу или перенести некритичную часть
После async ломается интерфейсКод потерял порядок инициализацииПроверить зависимости скриптов и ошибки консолиВернуть порядок или использовать defer, если он подходит
Одно измерение лучше остальныхСработал кеш или изменились сеть и устройствоПовторить серию и сравнить p50/p95Не принимать единичный прогон за результат
Критический путь первой загрузки: запрос, TTFB, HTML, CSS и JavaScript
Схема разделяет ожидание ответа, получение HTML и работу ресурсов. Каждому участку нужен собственный замер и собственное действие.

Учебный пример: серверная задержка видна отдельно

Ниже — минимальный локальный сервер. Параметр mode=slow добавляет задержку перед отправкой заголовков. Пример намеренно проверяет только серверный участок. Он не моделирует мобильную сеть, кеш браузера, CDN, рендеринг или реальную нагрузку. Его вывод нельзя выдавать за production-результат.

import { createServer } from 'node:http'; import { performance } from 'node:perf_hooks'; const server = createServer((request, response) => { const url = new URL(request.url, 'http://127.0.0.1'); const delay = url.searchParams.get('mode') === 'slow' ? 300 : 0; setTimeout(() => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end('<main>ready</main>'); }, delay); }); server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); for (const mode of ['fast', 'slow']) { const started = performance.now(); const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode); await response.text(); console.log(mode, Math.round(performance.now() - started), response.status); } server.close(); });

Ожидаемый результат — две строки со статусом 200. Строка slow должна быть примерно на 300 миллисекунд длиннее. Точное значение зависит от машины, поэтому сравнивайте режимы в одном запуске. Если различия нет, проверьте URL, единицы времени и то, что задержка стоит до writeHead, а не после отправки ответа.

Этот пример показывает причинность только для TTFB. Клиент ждёт тело целиком, поэтому его общее время включает передачу HTML. Чтобы измерить участки в браузере, откройте ту же страницу и прочитайте navigation entry. Не переносите число из Node в вывод о FCP или LCP: серверный пример не видит отрисовку.

Читаем Navigation Timing

В браузере найдите запись типа navigation. Поля requestStart, responseStart и responseEnd дают точки для разделения запроса, первого байта и конца ответа. Поле domContentLoadedEventEnd показывает завершение соответствующего события. Это диагностические временные точки, а не готовая оценка качества экрана.

const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { console.table({ ttfb: navigation.responseStart - navigation.requestStart, html: navigation.responseEnd - navigation.responseStart, domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime }); }

Если responseStart - requestStart велик, ищите серверную задержку, соединение и proxy. Если TTFB мал, а responseEnd - responseStart велик, проверьте размер HTML, сжатие и сеть. Если оба участка малы, но пользователь всё ещё видит пустой экран, переходите к ресурсам и главному потоку. Такой отрицательный путь важен: отсутствие серверной проблемы не доказывает, что страница быстрая.

DOMContentLoaded нельзя называть временем готовности экрана. Событие связано с разбором документа и отложенными скриптами. Изображения, шрифты, layout, paint и работа JavaScript могут продолжаться. Для визуального симптома нужен отдельный наблюдаемый критерий. Если команда использует FCP или LCP, измеряйте его тем же браузерным сценарием и не подменяйте его TTFB.

Ресурсы и порядок выполнения

После navigation entry соберите записи ресурсов. Для каждой записи важны URL без секретных параметров, тип ресурса, время начала, конец ответа, initiator и размер. Сначала ищите ресурс, который действительно пересекается с критическим участком. Длинная полоса в waterfall сама по себе не доказывает блокировку: шрифт мог начать загрузку после отрисовки главного содержимого.

Обычный stylesheet влияет на построение стилей. Синхронный script может остановить parser. defer оставляет порядок отложенных скриптов и запускает их после разбора документа. async запускает скрипт по готовности и меняет порядок. Если второй файл использует глобальный объект первого, безоговорочная замена на async создаёт отрицательный путь: сеть стала быстрее, но приложение упало до инициализации.

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

Как выбрать следующее изменение
НаблюдениеПервое изменениеРиск
Высокий TTFB при разных клиентахПрофилировать handler и его зависимостиИзменение кеша скроет, но не устранит причину
Большой HTML при нормальном TTFBПроверить структуру ответа и сжатиеСложнее кеширование и отладка шаблона
CSS задерживает первый полезный контентОтделить критические стили от второстепенныхFOUC и рассинхрон стилей
Script создаёт длинную задачуРазбить вычисление или отложить егоИзменится порядок состояния и событий

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

  1. Зафиксировать симптом: URL, устройство, браузер, сеть, режим кеша и видимый момент задержки.
  2. Повторить страницу серией прогонов, а не одним открытием. Сохранить сырые navigation и resource entries.
  3. Разделить TTFB, передачу HTML и время после получения документа.
  4. Сопоставить каждый участок с серверным журналом, waterfall и главным потоком браузера.
  5. Выбрать одну гипотезу и изменить только её: handler, HTML, CSS, script или порядок ресурса.
  6. Повторить тот же сценарий и сравнить медиану и p95. Проверить, что визуальный симптом изменился вместе с измеряемым участком.
  7. Проверить отрицательный путь: после изменения async/defer открыть страницу с медленной сетью и убедиться, что зависимости не запускаются в неверном порядке.
  8. Зафиксировать ограничения и вернуть изменение, если улучшилась одна цифра, но ухудшился первый экран или интерактивность.

Ограничения

Navigation Timing описывает события навигации, но не знает архитектуру backend и не устанавливает пороги качества. Resource Timing может скрывать часть сведений из-за политики приватности и кросс-доменных ограничений. Браузеры различаются по поддержке отдельных полей. Поэтому один API не заменяет сетевой журнал, профиль главного потока и визуальный замер.

Локальный сервер с фиксированной задержкой проверяет ветвление диагностики, но не показывает распределение latency, холодный кеш, CDN, TLS, балансировщик и конкуренцию запросов. Числа из примера не являются обещанием для production. Полевой вывод требует зафиксированных условий и серии наблюдений на целевом устройстве.

Ускорение одного участка может ухудшить другой. Отложенный CSS уменьшает блокировку, но может вызвать вспышку нестилизованного контента. Разбиение JavaScript сокращает длинную задачу, но увеличивает количество границ состояния. Проверяйте не только сеть, но и визуальную стабильность, ошибки консоли и интерактивность.

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

Диагностика готова, если для страницы есть серия повторяемых замеров, отдельные значения TTFB и передачи HTML, список ранних ресурсов с initiator и запись о главном потоке. Для выбранной гипотезы названо одно действие, а повторный прогон показывает изменение именно целевого участка. Первый экран не ухудшился, консоль не получила новую ошибку, а отрицательный путь проверен на медленной сети. Если команда может сказать только «страница стала быстрее», причина и критерий ещё не определены.

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

"} +{"index":21,"slug":"editorial-2027-06-practice-performance-capstone","title":"Медленная первая загрузка: как отделить TTFB от блокирующих ресурсов","excerpt":"Практический маршрут для случая, когда пользователь видит пустой экран: разделяем ожидание ответа, передачу HTML, CSS и работу JavaScript, а затем проверяем одну гипотезу повторным замером.","contentHtml":"

Пользователь открывает страницу и несколько секунд видит пустой или неполный первый экран. В отчёте команда замечает одну цифру: «загрузка заняла 2,4 секунды», — и начинает уменьшать JavaScript. Но эти 2,4 секунды могли включать ожидание ответа сервера, передачу HTML, построение стилей и длинную задачу на главном потоке. Цена неверной гипотезы — изменить слой, который не владеет задержкой, и получить новый риск без изменения симптома.

Разбор начинается с границы. TTFB (time to first byte) показывает время до первого байта ответа, но не время готовности экрана. После первого байта браузер ещё получает тело документа, строит DOM и CSSOM, загружает ресурсы и выполняет код. Ниже — маршрут, который связывает жалобу пользователя с одним участком критического пути и позволяет проверить исправление в тех же условиях.

Механизм: один экран, несколько причин

Браузер начинает навигацию с запроса. Сервер формирует ответ и отправляет первый байт. Только после этого браузер получает весь HTML и строит DOM. Когда parser встречает таблицу стилей, он ждёт CSSOM для расчёта стилей. Синхронный script может остановить parser. Отложенный код продолжает занимать главный поток даже после завершения сетевой загрузки.

Поэтому время нужно разложить. Участок до первого байта включает соединение, кеш, proxy и серверный обработчик. Участок от первого байта до конца ответа относится к размеру HTML и передаче. Ранний CSS влияет на построение стилей. Скрипт может задержать DOM, layout или обработку пользовательского ввода. Одна общая длительность скрывает владельца и действие.

Диагностика первой загрузки
СимптомПричинаПроверкаДействие
TTFB стабильно выше 300 мсСервер или зависимость задерживает начало ответаСравнить responseStart - startTime и журнал handlerПрофилировать серверный путь; не начинать с bundle
TTFB нормален, HTML приходит долгоБольшой ответ или медленная передачаСравнить responseEnd - responseStart и размер HTMLУменьшить ответ, проверить сжатие и кеш
HTML пришёл, первый экран ждёт CSSРанний stylesheet блокирует построение стилейСверить waterfall, initiator и видимый результатСократить критический CSS или отложить второстепенный
Сеть закончилась, экран не готовДолгая задача на главном потокеПосмотреть long tasks и длительность scriptРазбить работу или перенести некритичную часть
После async ломается интерфейсКод потерял порядок инициализацииПроверить зависимости скриптов и ошибки консолиВернуть порядок или использовать defer, если он подходит
Одно измерение лучше остальныхСработал кеш или изменились сеть и устройствоПовторить серию и сравнить p50/p95Не принимать единичный прогон за результат
Критический путь первой загрузки: запрос, TTFB, HTML, CSS и JavaScript
Схема разделяет ожидание ответа, получение HTML и работу ресурсов. Каждому участку нужен собственный замер и собственное действие.

Учебный пример: серверная задержка видна отдельно

Ниже — минимальный локальный сервер. Параметр mode=slow добавляет задержку перед отправкой заголовков. Пример намеренно проверяет только серверный участок. Он не моделирует мобильную сеть, кеш браузера, CDN, рендеринг или реальную нагрузку. Его вывод нельзя выдавать за production-результат.

import { createServer } from 'node:http'; import { performance } from 'node:perf_hooks'; const server = createServer((request, response) => { const url = new URL(request.url, 'http://127.0.0.1'); const delay = url.searchParams.get('mode') === 'slow' ? 300 : 0; setTimeout(() => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end('<main>ready</main>'); }, delay); }); server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); for (const mode of ['fast', 'slow']) { const started = performance.now(); const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode); await response.text(); console.log(mode, Math.round(performance.now() - started), response.status); } server.close(); });

Ожидаемый результат — две строки со статусом 200. Строка slow должна быть примерно на 300 миллисекунд длиннее. Точное значение зависит от машины, поэтому сравнивайте режимы в одном запуске. Если различия нет, проверьте URL, единицы времени и то, что задержка стоит до writeHead, а не после отправки ответа.

Этот пример показывает причинность только для TTFB. Клиент ждёт тело целиком, поэтому его общее время включает передачу HTML. Чтобы измерить участки в браузере, откройте ту же страницу и прочитайте navigation entry. Не переносите число из Node в вывод о FCP или LCP: серверный пример не видит отрисовку.

Читаем Navigation Timing

В браузере найдите запись типа navigation. Поля requestStart, responseStart и responseEnd дают точки для разделения запроса, первого байта и конца ответа. Поле domContentLoadedEventEnd показывает завершение соответствующего события. Это диагностические временные точки, а не готовая оценка качества экрана.

const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { console.table({ ttfb: navigation.responseStart - navigation.requestStart, html: navigation.responseEnd - navigation.responseStart, domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime }); }

Если responseStart - startTime велик, разделите соединение, кеш, proxy и серверную задержку по журналу и повторному замеру. Если TTFB мал, а responseEnd - responseStart велик, проверьте размер HTML, сжатие и сеть. Если оба участка малы, но пользователь всё ещё видит пустой экран, переходите к ресурсам и главному потоку. Такой отрицательный путь важен: отсутствие серверной проблемы не доказывает, что страница быстрая.

DOMContentLoaded нельзя называть временем готовности экрана. Событие связано с разбором документа и отложенными скриптами. Изображения, шрифты, layout, paint и работа JavaScript могут продолжаться. Для визуального симптома нужен отдельный наблюдаемый критерий. Если команда использует FCP или LCP, измеряйте его тем же браузерным сценарием и не подменяйте его TTFB.

Ресурсы и порядок выполнения

После navigation entry соберите записи ресурсов. Для каждой записи важны URL без секретных параметров, тип ресурса, время начала, конец ответа, initiator и размер. Сначала ищите ресурс, который действительно пересекается с критическим участком. Длинная полоса в waterfall сама по себе не доказывает блокировку: шрифт мог начать загрузку после отрисовки главного содержимого.

Обычный stylesheet влияет на построение стилей. Синхронный script может остановить parser. defer оставляет порядок отложенных скриптов и запускает их после разбора документа. async запускает скрипт по готовности и меняет порядок. Если второй файл использует глобальный объект первого, безоговорочная замена на async создаёт отрицательный путь: сеть стала быстрее, но приложение упало до инициализации.

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

Как выбрать следующее изменение
НаблюдениеПервое изменениеРиск
Высокий TTFB при разных клиентахПрофилировать handler и его зависимостиИзменение кеша скроет, но не устранит причину
Большой HTML при нормальном TTFBПроверить структуру ответа и сжатиеСложнее кеширование и отладка шаблона
CSS задерживает первый полезный контентОтделить критические стили от второстепенныхFOUC и рассинхрон стилей
Script создаёт длинную задачуРазбить вычисление или отложить егоИзменится порядок состояния и событий

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

  1. Зафиксировать симптом: URL, устройство, браузер, сеть, режим кеша и видимый момент задержки.
  2. Повторить страницу серией прогонов, а не одним открытием. Сохранить сырые navigation и resource entries.
  3. Разделить TTFB, передачу HTML и время после получения документа.
  4. Сопоставить каждый участок с серверным журналом, waterfall и главным потоком браузера.
  5. Выбрать одну гипотезу и изменить только её: handler, HTML, CSS, script или порядок ресурса.
  6. Повторить тот же сценарий и сравнить медиану и p95. Проверить, что визуальный симптом изменился вместе с измеряемым участком.
  7. Проверить отрицательный путь: после изменения async/defer открыть страницу с медленной сетью и убедиться, что зависимости не запускаются в неверном порядке.
  8. Зафиксировать ограничения и вернуть изменение, если улучшилась одна цифра, но ухудшился первый экран или интерактивность.

Ограничения

Navigation Timing описывает события навигации, но не знает архитектуру backend и не устанавливает пороги качества. Resource Timing может скрывать часть сведений из-за политики приватности и кросс-доменных ограничений. Браузеры различаются по поддержке отдельных полей. Поэтому один API не заменяет сетевой журнал, профиль главного потока и визуальный замер.

Локальный сервер с фиксированной задержкой проверяет ветвление диагностики, но не показывает распределение latency, холодный кеш, CDN, TLS, балансировщик и конкуренцию запросов. Числа из примера не являются обещанием для production. Полевой вывод требует зафиксированных условий и серии наблюдений на целевом устройстве.

Ускорение одного участка может ухудшить другой. Отложенный CSS уменьшает блокировку, но может вызвать вспышку нестилизованного контента. Разбиение JavaScript сокращает длинную задачу, но увеличивает количество границ состояния. Проверяйте не только сеть, но и визуальную стабильность, ошибки консоли и интерактивность.

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

Диагностика готова, если для страницы есть серия повторяемых замеров, отдельные значения TTFB и передачи HTML, список ранних ресурсов с initiator и запись о главном потоке. Для выбранной гипотезы названо одно действие, а повторный прогон показывает изменение именно целевого участка. Первый экран не ухудшился, консоль не получила новую ошибку, а отрицательный путь проверен на медленной сети. Если команда может сказать только «страница стала быстрее», причина и критерий ещё не определены.

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

"} diff --git a/editorial/agent-rewrites/022.json b/editorial/agent-rewrites/022.json index a59e5af..1c6bda7 100644 --- a/editorial/agent-rewrites/022.json +++ b/editorial/agent-rewrites/022.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-05-field-http-tls-guide", "title": "HTTP и TLS без догадок: как найти границу сетевой ошибки", "excerpt": "504, ошибка сертификата и 404 выглядят похожими в браузере, но рождаются на разных этапах. Разбираем безопасную диагностику: от имени узла и TLS до HTTP-статуса, логов и критерия готовности.", - "contentHtml": "

Пользователь видит в браузере «не удаётся подключиться», а мониторинг показывает 504. Инженер меняет таймаут в приложении, повторяет запрос и получает тот же результат. Иногда он добавляет --insecure, видит ответ и считает проблему решённой. Цена такой ошибки — потерянное время, ослабленная проверка сертификата и повтор запроса, который для POST может создать вторую операцию.

\n

У сетевого сбоя есть граница. Он возникает при разрешении имени, установке TCP-соединения, TLS-рукопожатии, передаче HTTP или обработке маршрута приложением. Код из браузера не называет границу. Поэтому проверяйте этапы по порядку и записывайте только подтверждённые факты.

\n

Тезис: сначала установите, где остановился запрос

\n

HTTP-статус появляется только после того, как клиент получил HTTP-ответ. Если TLS завершился ошибкой, приложение не могло вернуть 404 или 503. Если запрос дошёл до доверенного входа и получил 404, бессмысленно начинать с проверки цепочки сертификата. Один и тот же текст ошибки в интерфейсе может скрывать разные этапы.

\n
Граница запроса и допустимый вывод
ЭтапЧто подтвержденоЧего это не подтверждает
DNSИмя разрешилось в адресПорт принимает соединение
TCPСоединение с адресом установленоСертификат подходит имени
TLSКанал и проверка имени завершилисьМаршрут приложения существует
HTTPПолучены статус и заголовкиОтвет сформировало origin-приложение
ПриложениеЛог связывает запрос с handlerПроблем нет у посредника или клиента
\n

Эта граница защищает расследование от скачка к удобной гипотезе. Статус 504 обычно означает, что компонент, который отвечает клиенту, не дождался другого компонента. Он не доказывает, что origin недоступен: причиной может быть маршрут, лимит соединений, балансировщик или промежуточный proxy. Проверяйте того, кто сформировал статус.

\n
\"Цикл
Сначала остаётся безопасный факт, затем выбирается граница проверки. Гипотеза меняется только после нового наблюдения.
\n

Механизм: что проверяет каждый слой

\n

DNS отвечает на вопрос «какой адрес связан с именем». Запишите имя и выбранный адрес. Если имя разрешается в несколько адресов, один успешный ответ не объясняет поведение остальных. Зафиксируйте также тип записи и момент проверки. Не делайте из DNS-ответа вывод о доступности сервиса.

\n

TCP отвечает на вопрос «принимает ли адрес соединение на порту». Ошибка соединения и таймаут различают отказ узла и отсутствие ответа, но не объясняют причину сами по себе. Балансировщик может принять TCP и не передать запрос дальше.

\n

TLS добавляет проверку защищённого канала и имени. Клиент сравнивает hostname с именами в Subject Alternative Name сертификата и проверяет цепочку доверия и срок действия. Сертификат может быть действующим, но выпущенным для другого имени. Подмена URL на IP часто ломает именно эту проверку. Заголовок Host не исправит ошибку: до HTTP клиент ещё не дошёл.

\n

Флаг --insecure полезен только как ограниченный учебный эксперимент, который показывает, что сервер способен отправить байты. Он отключает проверку сертификата и не является исправлением. После него вернитесь к обычной валидации и не переносите результат в критерий доступности.

\n

HTTP сообщает метод, путь, статус, заголовки и тело. Смотрите на Retry-After, Location, Allow, Cache-Control, Age и Via, если они относятся к вопросу. Один заголовок не доказывает источник ответа: proxy может его добавить, удалить или переписать. Сопоставляйте ответ с логом доверенного входа по request id.

\n

Учебный пример: отделяем запрос от его результата

\n

Ниже — самостоятельный локальный пример без сети. Сервер возвращает безопасный идентификатор, метод и путь. Код демонстрирует форму HTTP-обмена; он не показывает работу CDN, TLS, балансировщика или production-сервиса.

\n
import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  response.writeHead(request.url === '/health' ? 200 : 404, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001'\n  });\n  response.end(JSON.stringify({ method: request.method, path: request.url }));\n});\n\nserver.listen(0, '127.0.0.1', async () => {\n  const { port } = server.address();\n  for (const path of ['/health', '/missing']) {\n    const response = await fetch(\\`http://127.0.0.1:\\${port}\\${path}\\`);\n    console.log(response.status, response.headers.get('x-request-id'));\n  }\n  server.close();\n});
\n

В учебном запуске /health возвращает 200, а /missing — 404. Это проверяет только локальный HTTP-контракт. Идентификатор local-001 задан вручную, поэтому он не является доказательством доверенного происхождения в настоящей системе.

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Ошибка до HTTP-статусаDNS, TCP или TLSСравнить этап и текст ошибки клиентаИсправлять имя, порт или сертификат на подтверждённом этапе
504 от proxyТаймаут ожидания upstreamСопоставить request id, длительность и лог proxyПроверить маршрут, лимит и upstream; не увеличивать таймаут вслепую
404 после успешного TLSПуть, метод или версия APIСверить метод, нормализованный путь и лог handlerИсправить контракт или маршрутизацию
401Аутентификация не принятаПосмотреть challenge и безопасный класс credentialsПроверить выдачу и область токена; секрет не копировать
403Доступ запрещён правиломПроверить policy и origin запросаИсправить право или объяснить отказ; не подменять его повтором
503 с Retry-AfterВременная недоступность сервераСверить зависимость, лимит и семантику методаПовторять только идемпотентную операцию с лимитом
\n

Безопасная запись результата

\n

Полный вывод curl -v удобен для диагностики, но может содержать Authorization, cookie, токены в query и непубличные имена. Очищайте вывод до копирования в issue или чат. Сохраняйте hostname, порт, метод, путь без секретных параметров, этап, статус, длительность, размер ответа и безопасный request id. Время пишите вместе с часовым поясом, длительность — с единицей измерения.

\n
function redactNetworkOutput(text) {\n  return text\n    .replace(/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1[masked]')\n    .replace(/(Cookie:\\s*)[^\\n]+/gi, '$1[masked]')\n    .replace(/([?&](?:token|secret|signature)=)[^&\\s]+/gi, '$1[masked]');\n}\n\nconst sample = 'GET /health?token=abc HTTP/1.1\\nAuthorization: Bearer abc\\nCookie: sid=xyz';\nconsole.log(redactNetworkOutput(sample));
\n

Это учебный санитайзер текстовой строки. Он показывает три известных формата и не обнаруживает неизвестные секреты, JSON-поля, бинарные данные или нестандартные заголовки. Перед передачей всё равно просмотрите результат. Для постоянной диагностики надёжнее allowlist структурированных полей, чем маскирование произвольного текста.

\n

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

\n
  1. Зафиксируйте URL, метод, время, режим proxy и безопасный идентификатор. Уберите Authorization, cookie и персональные query-параметры.
  2. Проверьте DNS и адрес назначения отдельно от приложения. Сохраните выбранный адрес, код ошибки и длительность.
  3. Проверьте TCP-порт. Не называйте сервис доступным только потому, что имя разрешилось.
  4. Для HTTPS проверьте hostname, SAN, цепочку доверия и срок действия сертификата обычным клиентом.
  5. После успешного TLS снимите HTTP-статус и нужные заголовки. Сравните ответ с origin и кэшем, если между ними есть посредник.
  6. Сопоставьте request id с логом доверенного входа и handler. Причину формулируйте только на уровне, подтверждённом наблюдением.
  7. Выберите один следующий тест с ожидаемым результатом. Для POST отдельно проверьте идемпотентность и ключ операции до любого повтора.
\n

Ограничения и отрицательный путь

\n

Один локальный запрос не показывает потерю пакетов, DNS-балансировку, корпоративный proxy, особенности браузерного хранилища, региональные маршруты и политику реального центра сертификации. Код 504 не называет зависимость, а 404 не доказывает одинаковую настройку всех регионов. Для этих выводов нужны согласованные логи и доступные сетевые наблюдения.

\n

Если TLS не завершился, остановите HTTP-проверку. Не подставляйте Host, не включайте --insecure как постоянный режим и не меняйте таймауты приложения. Если TLS успешен, но серверный лог не знает request id, не объявляйте origin источником ответа: сначала установите доверенную границу сопоставления. Если очиститель оставил неизвестное поле, не публикуйте запись.

\n

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

\n

Диагностика готова, когда запись содержит проверенный этап остановки, безопасные входные данные, наблюдаемый результат и один повторяемый тест. Для TLS это hostname, SAN, цепочка и срок действия; для HTTP — метод, путь, статус, выбранные заголовки и связь с логом. Исправление готово, когда тот же тест с обычной проверкой сертификата и тем же контрактом даёт ожидаемый результат, а отрицательный путь остаётся объяснимым: неизвестный путь возвращает согласованный 404, а повтор небезопасного метода не запускается автоматически.

\n

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

" + "contentHtml": "

Пользователь видит в браузере «не удаётся подключиться», а мониторинг показывает 504. Инженер меняет таймаут в приложении, повторяет запрос и получает тот же результат. Иногда он добавляет --insecure, видит ответ и считает проблему решённой. Цена такой ошибки — потерянное время, ослабленная проверка сертификата и повтор запроса, который для POST может создать вторую операцию.

\n

У сетевого сбоя есть граница. Он возникает при разрешении имени, установке TCP-соединения, TLS-рукопожатии, передаче HTTP или обработке маршрута приложением. Код из браузера не называет границу. Поэтому проверяйте этапы по порядку и записывайте только подтверждённые факты.

\n

Тезис: сначала установите, где остановился запрос

\n

HTTP-статус появляется только после того, как клиент получил HTTP-ответ. Если TLS завершился ошибкой, приложение не могло вернуть 404 или 503. Если запрос дошёл до доверенного входа и получил 404, сначала проверяйте путь и маршрут этой точки, а не цепочку сертификата. Такой ответ ещё не доказывает, что его сформировал origin. Один и тот же текст ошибки в интерфейсе может скрывать разные этапы.

\n
Граница запроса и допустимый вывод
ЭтапЧто подтвержденоЧего это не подтверждает
DNSИмя разрешилось в адресПорт принимает соединение
TCPСоединение с адресом установленоСертификат подходит имени
TLSКанал и проверка имени завершилисьМаршрут приложения существует
HTTPПолучены статус и заголовкиОтвет сформировало origin-приложение
ПриложениеЛог связывает запрос с handlerПроблем нет у посредника или клиента
\n

Эта граница защищает расследование от скачка к удобной гипотезе. Статус 504 обычно означает, что компонент, который отвечает клиенту, не дождался другого компонента. Он не доказывает, что origin недоступен: причиной может быть маршрут, лимит соединений, балансировщик или промежуточный proxy. Проверяйте того, кто сформировал статус.

\n
\"Цикл
Сначала остаётся безопасный факт, затем выбирается граница проверки. Гипотеза меняется только после нового наблюдения.
\n

Механизм: что проверяет каждый слой

\n

DNS отвечает на вопрос «какой адрес связан с именем». Запишите имя и выбранный адрес. Если имя разрешается в несколько адресов, один успешный ответ не объясняет поведение остальных. Зафиксируйте также тип записи и момент проверки. Не делайте из DNS-ответа вывод о доступности сервиса.

\n

TCP отвечает на вопрос «принимает ли адрес соединение на порту». Отказ соединения и таймаут описывают разное наблюдение на этом участке, но не называют причину: фильтр, маршрут и перегруженный узел могут проявляться по-разному. Балансировщик может принять TCP и не передать запрос дальше.

\n

TLS добавляет защищённый канал, а клиент при проверке сертификата сопоставляет hostname с именами в Subject Alternative Name и проверяет цепочку доверия и срок действия. Сертификат может быть действующим, но выпущенным для другого имени. Подмена URL на IP часто ломает именно эту проверку. Заголовок Host не исправит ошибку: до HTTP клиент ещё не дошёл.

\n

Флаг --insecure полезен только как ограниченный учебный эксперимент, который показывает, что сервер способен отправить байты. Он отключает проверку сертификата и не является исправлением. После него вернитесь к обычной валидации и не переносите результат в критерий доступности.

\n

HTTP сообщает метод, путь, статус, заголовки и тело. Смотрите на Retry-After, Location, Allow, Cache-Control, Age и Via, если они относятся к вопросу. Один заголовок не доказывает источник ответа: proxy может его добавить, удалить или переписать. Сопоставляйте ответ с логом доверенного входа по request id.

\n

Учебный пример: отделяем запрос от его результата

\n

Ниже — самостоятельный локальный пример без сети. Сервер возвращает безопасный идентификатор, метод и путь. Код демонстрирует форму HTTP-обмена; он не показывает работу CDN, TLS, балансировщика или production-сервиса.

\n
import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  response.writeHead(request.url === '/health' ? 200 : 404, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001'\n  });\n  response.end(JSON.stringify({ method: request.method, path: request.url }));\n});\n\nserver.listen(0, '127.0.0.1', async () => {\n  const { port } = server.address();\n  for (const path of ['/health', '/missing']) {\n    const response = await fetch(`http://127.0.0.1:${port}${path}`);\n    console.log(response.status, response.headers.get('x-request-id'));\n  }\n  server.close();\n});
\n

В учебном запуске /health возвращает 200, а /missing — 404. Это проверяет только локальный HTTP-контракт. Идентификатор local-001 задан вручную, поэтому он не является доказательством доверенного происхождения в настоящей системе.

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Ошибка до HTTP-статусаDNS, TCP или TLSСравнить этап и текст ошибки клиентаИсправлять имя, порт или сертификат на подтверждённом этапе
504 от proxyТаймаут ожидания upstreamСопоставить request id, длительность и лог proxyПроверить маршрут, лимит и upstream; не увеличивать таймаут вслепую
404 после успешного TLSПуть, метод или версия APIСверить метод, нормализованный путь и лог handlerИсправить контракт или маршрутизацию
401Аутентификация не принятаПосмотреть challenge и безопасный класс credentialsПроверить выдачу и область токена; секрет не копировать
403Доступ запрещён правиломПроверить policy и origin запросаИсправить право или объяснить отказ; не подменять его повтором
503 с Retry-AfterВременная недоступность сервераСверить зависимость, лимит и семантику методаПовторять только идемпотентную операцию с лимитом
\n

Безопасная запись результата

\n

Полный вывод curl -v удобен для диагностики, но может содержать Authorization, cookie, токены в query и непубличные имена. Очищайте вывод до копирования в issue или чат. Сохраняйте hostname, порт, метод, путь без секретных параметров, этап, статус, длительность, размер ответа и безопасный request id. Время пишите вместе с часовым поясом, длительность — с единицей измерения.

\n
function redactNetworkOutput(text) {\n  return text\n    .replace(/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1[masked]')\n    .replace(/(Cookie:\\s*)[^\\n]+/gi, '$1[masked]')\n    .replace(/([?&](?:token|secret|signature)=)[^&\\s]+/gi, '$1[masked]');\n}\n\nconst sample = 'GET /health?token=abc HTTP/1.1\\nAuthorization: Bearer abc\\nCookie: sid=xyz';\nconsole.log(redactNetworkOutput(sample));
\n

Это учебный санитайзер текстовой строки. Он показывает три известных формата и не обнаруживает неизвестные секреты, JSON-поля, бинарные данные или нестандартные заголовки. Перед передачей всё равно просмотрите результат. Для постоянной диагностики надёжнее allowlist структурированных полей, чем маскирование произвольного текста.

\n

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

\n
  1. Зафиксируйте URL, метод, время, режим proxy и безопасный идентификатор. Уберите Authorization, cookie и персональные query-параметры.
  2. Проверьте DNS и адрес назначения отдельно от приложения. Сохраните выбранный адрес, код ошибки и длительность.
  3. Проверьте TCP-порт. Не называйте сервис доступным только потому, что имя разрешилось.
  4. Для HTTPS проверьте hostname, SAN, цепочку доверия и срок действия сертификата обычным клиентом.
  5. После успешного TLS снимите HTTP-статус и нужные заголовки. Сравните ответ с origin и кэшем, если между ними есть посредник.
  6. Сопоставьте request id с логом доверенного входа и handler. Причину формулируйте только на уровне, подтверждённом наблюдением.
  7. Выберите один следующий тест с ожидаемым результатом. Для POST отдельно проверьте идемпотентность и ключ операции до любого повтора.
\n

Ограничения и отрицательный путь

\n

Один локальный запрос не показывает потерю пакетов, DNS-балансировку, корпоративный proxy, особенности браузерного хранилища, региональные маршруты и политику реального центра сертификации. Код 504 не называет зависимость, а 404 не доказывает одинаковую настройку всех регионов. Для этих выводов нужны согласованные логи и доступные сетевые наблюдения.

\n

Если TLS не завершился, остановите HTTP-проверку. Не подставляйте Host, не включайте --insecure как постоянный режим и не меняйте таймауты приложения. Если TLS успешен, но серверный лог не знает request id, не объявляйте origin источником ответа: сначала установите доверенную границу сопоставления. Если очиститель оставил неизвестное поле, не публикуйте запись.

\n

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

\n

Диагностика готова, когда запись содержит проверенный этап остановки, безопасные входные данные, наблюдаемый результат и один повторяемый тест. Для TLS это hostname, SAN, цепочка и срок действия; для HTTP — метод, путь, статус, выбранные заголовки и связь с логом. Исправление готово, когда тот же тест с обычной проверкой сертификата и тем же контрактом даёт ожидаемый результат, а отрицательный путь остаётся объяснимым: неизвестный путь возвращает согласованный 404, а повтор небезопасного метода не запускается автоматически.

\n

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

" }