8 lines
27 KiB
JSON
8 lines
27 KiB
JSON
{
|
||
"index": 16,
|
||
"slug": "editorial-2027-07-field-reliability-capstone",
|
||
"title": "Когда retry превращается в аварию: как связать попытку, deadline и результат",
|
||
"excerpt": "Собираем контракт событий попытки: как связать operationId и requestId, отделить timeout от ответа и остановиться по deadline без слепого повтора.",
|
||
"contentHtml": "<p>В журнале часто остаётся только «request failed». По такой записи нельзя понять, был ли это timeout первой попытки, ответ <code>503</code> второй или отказ от повтора операции записи. Команда видит шум, но не видит последовательность, которая его создала.</p>\n<p>Цена ошибки — лишняя нагрузка для чтения и повторный побочный эффект для записи: второй заказ, два письма или повторное списание. Timeout добавляет неопределённость: сервер мог не получить запрос, мог его отклонить или уже применить, пока ответ потерялся. Поэтому строка «повторить при ошибке» недостаточна.</p>\n<p><strong>Тезис:</strong> событие попытки должно связывать одну логическую операцию с конкретным сетевым вызовом, остатком общего времени и решением клиента. Тогда следующий retry можно проверить по полям, а не восстанавливать по догадкам.</p>\n<h2>Событие должно описывать одну попытку</h2>\n<p>Одна пользовательская операция может породить несколько HTTP-запросов. <code>operationId</code> создаётся на границе операции и остаётся прежним. <code>requestId</code> относится к одной сетевой попытке и меняется при retry. Поле <code>attempt</code> показывает порядок. Если генерировать все три идентификатора внутри низкоуровневого клиента, расследование потеряет связь между повторениями.</p>\n<p>Ниже — минимальный локальный контракт. Его имена не являются готовыми семантическими соглашениями OpenTelemetry: команда должна согласовать типы, срок хранения, доступ и правила очистки отдельно. Важно сохранить смысл полей: ответ сервера не смешивается с исключением клиента, а причина не смешивается с действием.</p>\n<div class=\"table-scroll\"><table><caption>Минимальный контракт события попытки</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Пример</th><th scope=\"col\">Что проверяет</th></tr></thead><tbody><tr><td><code>operationId</code></td><td><code>op-42</code></td><td>Все попытки относятся к одной операции</td></tr><tr><td><code>requestId</code></td><td><code>op-42/attempt-2</code></td><td>Конкретный сетевой вызов и его след</td></tr><tr><td><code>attempt</code></td><td><code>2</code></td><td>Порядок и фактическое число вызовов</td></tr><tr><td><code>method</code></td><td><code>GET</code></td><td>Применимость политики повтора</td></tr><tr><td><code>status</code></td><td><code>503</code> или <code>null</code></td><td>Ответ получен или его нет</td></tr><tr><td><code>errorClass</code></td><td><code>timeout</code> или <code>null</code></td><td>Класс ошибки транспорта или клиента</td></tr><tr><td><code>elapsedMs</code></td><td><code>80</code></td><td>Сколько заняла попытка</td></tr><tr><td><code>remainingMs</code></td><td><code>280</code></td><td>Сколько общего бюджета было до вызова</td></tr><tr><td><code>decision</code></td><td><code>retry</code></td><td>Какое действие выбрал клиент</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg\" alt=\"Цикл обработки ответа: operationId связывает попытки, событие сохраняет статус или timeout, остаток времени и decision\" loading=\"lazy\" /><figcaption>Попытка оставляет проверяемое событие до следующего вызова. Новый requestId не разрывает operationId, а неизвестный результат записи ведёт к проверке состояния.</figcaption></figure>\n<h2>Не смешиваем ответ, timeout и решение</h2>\n<p><code>503</code> — это полученный HTTP-ответ. Он сообщает о недоступности сервиса в момент запроса, но не выбирает политику конкретного клиента. <code>timeout</code> — отсутствие ответа в отведённое время. Это не доказательство, что сервер не выполнил операцию. <code>decision=retry</code> — третье измерение: это уже решение вызывающей стороны, а не свойство ответа.</p>\n<p>Для HTTP/3 транспорт может сообщить о состоянии соединения или потока, но прикладной протокол отдельно определяет смысл данных и ошибок. Поэтому закрытый поток не превращается автоматически в «заказ не создан». В событии нужно сохранить границу знания: что увидел клиент и какой результат остался неизвестным.</p>\n<div class=\"table-scroll\"><table><caption>Что известно после разных исходов</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Что известно</th><th scope=\"col\">Чего нельзя утверждать</th><th scope=\"col\">Следующее действие</th></tr></thead><tbody><tr><td><code>200</code> после <code>GET</code></td><td>Ответ получен, попытка завершилась</td><td>Что следующая попытка тоже нужна</td><td>Вернуть ответ и закрыть операцию</td></tr><tr><td><code>503</code> после <code>GET</code></td><td>Сервис ответил временной недоступностью</td><td>Что повтор безопасен для любого метода</td><td>Проверить метод, бюджет и лимит попыток</td></tr><tr><td><code>timeout</code> после <code>GET</code></td><td>Клиент не получил ответ вовремя</td><td>Что запрос не был принят сервером</td><td>Рассмотреть ограниченный повтор чтения</td></tr><tr><td><code>timeout</code> после <code>POST</code></td><td>Результат прикладной записи неизвестен</td><td>Что повтор создаст только одну запись</td><td>Проверить состояние по ключу операции</td></tr><tr><td>Deadline исчерпан до вызова</td><td>Новая попытка не начиналась</td><td>Что зависимость получила этот вызов</td><td>Вернуть terminal reason без сетевого запроса</td></tr></tbody></table></div>\n<p>Для записи безопасное действие после timeout — не новый <code>POST</code>, а запрос состояния по согласованному ключу или ручное разбирательство. Такой переход можно назвать <code>check_state</code>. Он не утверждает успех или неуспех: он сохраняет неизвестный результат до отдельной проверки.</p>\n<h2>Учебный исполнитель с фиксированным timeline</h2>\n<p>Пример ниже не открывает сеть. Массив <code>observations</code> заранее задаёт ответы и длительность, поэтому любой инженер может повторить последовательность событий на одной машине. Время здесь виртуальное: фиксированная задержка <code>backoffMs</code> нужна для демонстрации бюджета, а не является настройкой production-клиента.</p>\n<pre><code>function decideAttempt({ method, status, errorClass, remainingAfterMs, attemptsLeft, backoffMs }) {\n if (status >= 200 && status < 300) return 'return';\n if (errorClass === 'timeout' && method !== 'GET') return 'check_state';\n if (method === 'GET' && (status === 503 || errorClass === 'timeout')) {\n if (remainingAfterMs <= backoffMs) return 'deadline_exceeded';\n return attemptsLeft > 0 ? 'retry' : 'max_attempts';\n }\n return 'stop';\n}\n\nfunction runRetry({\n operationId,\n method,\n observations,\n maxAttempts = 3,\n deadlineMs = 400,\n backoffMs = 40,\n}) {\n let spentMs = 0;\n const events = [];\n const totalAttempts = Math.min(maxAttempts, observations.length);\n\n for (let index = 0; index < totalAttempts; index += 1) {\n const observation = observations[index];\n const remainingMs = Math.max(0, deadlineMs - spentMs);\n\n if (remainingMs === 0) {\n return { result: { status: 'deadline_exceeded' }, events };\n }\n\n const elapsedMs = Math.min(observation.elapsedMs, remainingMs);\n const timedOut =\n observation.errorClass === 'timeout' ||\n observation.elapsedMs > remainingMs;\n const status = timedOut ? null : observation.status ?? null;\n const errorClass = timedOut ? 'timeout' : observation.errorClass ?? null;\n const remainingAfterMs = remainingMs - elapsedMs;\n const decision = decideAttempt({\n method,\n status,\n errorClass,\n remainingAfterMs,\n attemptsLeft: totalAttempts - index - 1,\n backoffMs,\n });\n\n events.push({\n operationId,\n requestId: `${operationId}/attempt-${index + 1}`,\n attempt: index + 1,\n method,\n status,\n errorClass,\n elapsedMs,\n remainingMs,\n decision,\n });\n\n if (decision === 'retry') {\n spentMs += elapsedMs + backoffMs;\n continue;\n }\n\n return {\n result: decision === 'return' ? { status } : { status: decision },\n events,\n };\n }\n\n return { result: { status: 'max_attempts' }, events };\n}\n\nconst read = runRetry({\n operationId: 'op-42',\n method: 'GET',\n observations: [\n { status: 503, elapsedMs: 80 },\n { status: 503, elapsedMs: 80 },\n { status: 200, elapsedMs: 50 },\n ],\n});\n\nconst write = runRetry({\n operationId: 'op-43',\n method: 'POST',\n observations: [{ errorClass: 'timeout', elapsedMs: 120 }],\n});\n\nconsole.log(read.events, read.result, write.events[0].decision);\n// retry, retry, return; 200; check_state</code></pre>\n<p>Для чтения пример создаёт три события. Остаток бюджета перед попытками равен <code>400</code>, <code>280</code> и <code>160</code> миллисекундам; решения — <code>retry</code>, <code>retry</code>, <code>return</code>. Итоговый статус — <code>200</code>. Для записи timeout даёт <code>check_state</code>, поэтому функция не запускает второй сетевой вызов.</p>\n<p>Это узкая проверка порядка. В ней нет DNS, пула соединений, реального clock, конкурентных клиентов, server-side дедупликации и доставки событий. Именно поэтому результат примера нельзя называть измерением доступности или доказательством безопасности конкретного API.</p>\n<h2>Deadline — часть события, а не настройка цикла</h2>\n<p>Локальный timeout ограничивает одну попытку, а deadline ограничивает всю операцию. Если каждая из трёх попыток получает по 400 миллисекунд, вызывающий код может ждать больше секунды. Правильная схема передаёт вниз один общий момент окончания и вычисляет <code>remainingMs</code> перед каждым вызовом:</p>\n<p><code>remainingMs = deadlineMonotonic - monotonicNow</code></p>\n<p>Время бюджета измеряют монотонными часами. Wall-clock пригоден для корреляции записей между узлами, но скачок системных часов не должен внезапно разрешить ещё одну сетевую попытку. В событии полезно хранить и <code>elapsedMs</code>, и остаток до вызова: одно показывает стоимость действия, другое — доступный запас.</p>\n<p>Заголовок <code>Retry-After</code> нужно сохранять отдельным полем, например <code>retryAfterMs</code>. Сервер может подсказать задержку после <code>503</code>, но клиент всё равно ограничивает её своим deadline, лимитом попыток и политикой метода. Подсказка о времени ожидания не превращает небезопасный <code>POST</code> в идемпотентную операцию.</p>\n<p><code>maxAttempts</code> и deadline отвечают на разные вопросы. Первый ограничивает количество вызовов, второй — время всей операции. Поэтому в терминальном событии стоит различать <code>max_attempts</code> и <code>deadline_exceeded</code>. Иначе команда начнёт увеличивать число попыток, когда на самом деле зависимость отвечает слишком медленно.</p>\n<h2>Как читать последовательность событий</h2>\n<p>В нормальном расследовании сначала группируем записи по <code>operationId</code>, затем сортируем по <code>attempt</code> или времени начала. Внутри одной группы <code>requestId</code> должен быть уникальным для попытки. Если встречаются два события с одним requestId, проверяем повторную доставку логов; если меняется operationId, проверяем место создания идентификатора.</p>\n<p>Последовательность <code>503 → 503 → 200</code> означает три наблюдаемых ответа, но не «сервис был полностью недоступен». Она подтверждает только ответы конкретной операции. Последовательность <code>503 → deadline_exceeded</code> означает, что клиент остановился после первой попытки; это не новый ответ зависимости.</p>\n<p>Отдельно считаем решения. Доля <code>retry</code> показывает поведение клиента, а доля <code>timeout</code> — наблюдаемый класс отказа. Не складывайте их в одну метрику ошибок: один timeout может привести к <code>check_state</code>, а один <code>503</code> — к безопасному ограниченному retry. Разные причины требуют разных действий.</p>\n<p>Для наблюдаемости полезно разделить сигналы. Trace связывает путь запроса, log хранит конкретное событие, metric показывает распределение попыток и долю завершений по deadline. <code>operationId</code> и <code>requestId</code> не стоит бездумно превращать в labels метрики: высокая кардинальность сделает график дорогим и малоинформативным.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностическая матрица для retry-событий</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Везде только <code>request failed</code></td><td>Статус, причина и решение слиты в строку</td><td>Найти status/errorClass/decision для одной операции</td><td>Разделить поля и ограничить их словарь</td></tr><tr><td><code>remainingMs</code> растёт после retry</td><td>Смешаны wall-clock и monotonic clock</td><td>Сверить бюджет, elapsedMs и точку начала операции</td><td>Считать deadline одной монотонной шкалой</td></tr><tr><td>Есть <code>decision=retry</code>, но следующего вызова нет</td><td>Backoff или лимит попыток съел остаток бюджета</td><td>Проверить terminal reason и попытки после события</td><td>Записывать deadline/max_attempts отдельно</td></tr><tr><td>Одна операция получила два operationId</td><td>Идентификатор создаётся внутри retry-обёртки</td><td>Сопоставить входной запрос и все requestId</td><td>Создавать operationId до первого вызова</td></tr><tr><td><code>503</code> повторяется для каждого метода</td><td>Политика смотрит только на статус</td><td>Сравнить method и доменный эффект</td><td>Запретить повтор без доказанной идемпотентности</td></tr><tr><td>В событии виден полный URL</td><td>Логируется вход без очистки</td><td>Проверить query, cookie, токены и тело</td><td>Нормализовать endpoint и удалить секреты</td></tr></tbody></table></div>\n<h2>Порядок внедрения</h2>\n<ol><li><strong>Назовите операцию.</strong> Зафиксируйте доменное действие, побочный эффект и владельца решения. HTTP-метод остаётся подсказкой, а не полной политикой.</li><li><strong>Разделите идентификаторы.</strong> Создайте устойчивый <code>operationId</code> до первого вызова, выдавайте новый <code>requestId</code> каждой попытке и увеличивайте <code>attempt</code>.</li><li><strong>Опишите конечные значения.</strong> Заранее перечислите допустимые <code>status</code>, <code>errorClass</code>, <code>decision</code> и terminal reason. Не заменяйте их свободным сообщением.</li><li><strong>Задайте общий бюджет.</strong> Передавайте deadline вниз по стеку, отдельно ограничьте timeout вызова и включите backoff в тот же бюджет.</li><li><strong>Сохраняйте событие.</strong> Записывайте статус или класс ошибки, длительность, остаток времени и решение. В одном событии не должно быть одновременно фиктивного статуса и придуманной причины.</li><li><strong>Проверьте неизвестный результат.</strong> Для timeout записи используйте ключ операции и read-state endpoint либо ручной маршрут. Новый POST без такой проверки запрещён.</li><li><strong>Очистите данные.</strong> Уберите query-секреты, cookie, токены, тело ответа и персональные идентификаторы до отправки log или trace.</li><li><strong>Прогоните отрицательные пути.</strong> Проверьте успешный первый вызов, два <code>503</code> и <code>200</code>, исчерпание deadline, timeout GET и timeout POST. Проверяйте события и число реальных вызовов.</li><li><strong>Считайте последствия.</strong> Отдельно наблюдайте распределение попыток, долю timeout, terminal reason и нагрузку зависимости. Не меняйте политику по одной записи.</li></ol>\n<h2>Ограничения</h2>\n<p>Фикстура использует заранее записанный timeline и не моделирует сетевую очередь, балансировщик, потерю логов, рестарт процесса, конкуренцию и реальные серверные побочные эффекты. В production время нужно брать из монотонного clock, а задержку — из согласованной политики зависимости. Фиксированные 40 миллисекунд в коде не являются универсальным backoff.</p>\n<p>RFC описывает свойства HTTP-методов и транспортные границы, но не знает доменный эффект вашего endpoint. Даже идемпотентный метод может иметь неожиданные побочные действия из-за реализации. И наоборот, <code>POST</code> может получить отдельный idempotency-key контракт, но его срок, область уникальности, проверку тела и хранение результата должен доказать сервер.</p>\n<p>Событие повышает наблюдаемость, но не подтверждает, что зависимость применила запись. Trace и log могут быть неполными, metric не заменяет конкретную попытку. Для платежа, заказа или уведомления владельцу операции нужен отдельный способ проверить состояние после неопределённого исхода.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если фиксированный набор входов даёт одинаковую последовательность событий при повторном запуске. Для чтения <code>503 → 503 → 200</code> должны сохраниться один <code>operationId</code>, три разных <code>requestId</code>, номера попыток 1–3, остаток <code>400 → 280 → 160</code> и решения <code>retry → retry → return</code>. Итогом должен быть полученный <code>200</code>, а не синтетический успех после исчерпания бюджета.</p>\n<p>Для timeout GET проверяем ограничение числа вызовов и отдельный terminal reason. Для timeout POST проверяем один побочный вызов, решение <code>check_state</code> и отсутствие второго <code>POST</code>. В каждом событии есть либо <code>status</code>, либо <code>errorClass</code>, есть <code>remainingMs</code> и <code>decision</code>, а секреты не попадают в log или trace.</p>\n<p>Этот критерий не обещает доступность зависимости и не доказывает корректность её транзакции. Он проверяет границу, которой управляет клиент: ограниченный retry не превращается в лавину, а неизвестный результат записи не превращается в новый побочный эффект.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 9110: HTTP Semantics</a> — разделы 9.2.2, 10.2.3 и 15.6.4 задают свойства идемпотентных методов, смысл <code>Retry-After</code> и статус <code>503 Service Unavailable</code>. RFC не выбирает retry-политику конкретного API и не доказывает отсутствие побочного эффекта.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9000.html\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport</a> — описывает транспортные соединения и потоки, оставляя прикладному протоколу смысл данных и application errors. RFC не подтверждает результат бизнес-операции после закрытия потока.</li><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — официально разделяет traces, metrics и logs. Эта страница не задаёт имена локальных полей события, правила retry или политику хранения идентификаторов.</li></ul>"
|
||
}
|