Files

8 lines
27 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &gt;= 200 &amp;&amp; status &lt; 300) return 'return';\n if (errorClass === 'timeout' &amp;&amp; method !== 'GET') return 'check_state';\n if (method === 'GET' &amp;&amp; (status === 503 || errorClass === 'timeout')) {\n if (remainingAfterMs &lt;= backoffMs) return 'deadline_exceeded';\n return attemptsLeft &gt; 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 &lt; 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 &gt; 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>"
}