Files
progcode/editorial/agent-rewrites/016.json
T

8 lines
21 KiB
JSON
Raw 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": "Разбираем лавину повторных запросов при сбое зависимости: какие поля сохранить, когда остановиться и почему timeout записи нельзя считать доказательством неуспеха.",
"contentHtml": "<p>Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.</p>\n<p>Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.</p>\n<p>Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принять решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.</p>\n<h2>Механизм отказа</h2>\n<p>Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый <code>operationId</code>. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать: <code>requestId</code> помогает найти один сетевой вызов, а <code>operationId</code> связывает весь пользовательский сценарий. Если записать только финальное «503», расследование потеряет порядок событий.</p>\n<p>Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние <code>deadline_exceeded</code>; когда достигнут лимит попыток, это другое состояние.</p>\n<p>Причина и действие тоже различаются. <code>503</code>, <code>429</code>, timeout, ошибка DNS и отмена запроса — причины. <code>retry</code>, <code>return</code>, <code>check_state</code>, <code>attempts_exhausted</code> и <code>cancel</code> — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.</p>\n<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>req-02</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> или <code>errorClass</code></td><td><code>503</code>, <code>timeout</code></td><td>Отделить ответ сервера от исключения</td></tr><tr><td><code>remainingMs</code></td><td><code>180</code></td><td>Понять, сколько времени оставалось</td></tr><tr><td><code>decision</code></td><td><code>retry</code></td><td>Зафиксировать решение клиента</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg\" alt=\"Цикл обработки ответа: попытка, запись причины, проверка deadline и решение о повторе или возврате\" loading=\"lazy\" /><figcaption>Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.</figcaption></figure>\n<h2>Что именно можно повторять</h2>\n<p>HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации: серверный обработчик и его побочные эффекты остаются частью проверки.</p>\n<p><code>GET</code> для чтения обычно можно повторить после временного <code>503</code>, если клиент соблюдает общий лимит и учитывает <code>Retry-After</code>. <code>PUT</code> может быть безопасен, когда ключ ресурса задаёт всё состояние операции. <code>DELETE</code> требует правила для повторного <code>404</code>. <code>POST</code> нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия, проверка параметров и сохранённый результат.</p>\n<p>Статус сам по себе тоже не даёт разрешения на retry. <code>503</code> обычно означает временную недоступность, но ответ посредника мог появиться после того, как upstream уже применил запись. <code>429</code> требует учесть ограничение сервера, а ошибка DNS, отмена пользователем и ошибка валидации не должны попадать в один список с временным отказом.</p>\n<h2>Воспроизводимый пример</h2>\n<p>Учебный пример ниже намеренно консервативен. Он не обращается в сеть, а получает заранее заданный массив ответов. Это позволяет воспроизвести решение клиента и отдельно увидеть разницу между последней попыткой и исчерпанным deadline. Пример повторяет только <code>GET</code> со статусом <code>503</code>; он не доказывает поведение конкретной HTTP-библиотеки.</p>\n<pre><code>function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n const events = [];\n const attemptLimit = Math.min(maxAttempts, responses.length);\n\n for (const [index, result] of responses.slice(0, attemptLimit).entries()) {\n const attempt = index + 1;\n const remainingMs = Math.max(0, deadlineMs - index * 120);\n const isLastAttempt = attempt === attemptLimit;\n const canRetry = method === 'GET' &amp;&amp; result.status === 503 &amp;&amp; remainingMs &gt; 0 &amp;&amp; !isLastAttempt;\n const decision = remainingMs === 0\n ? 'deadline_exceeded'\n : canRetry\n ? 'retry'\n : method === 'POST' &amp;&amp; result.status === 'timeout'\n ? 'check_state'\n : isLastAttempt &amp;&amp; result.status === 503\n ? 'attempts_exhausted'\n : 'return';\n\n events.push({ operationId, attempt, method, status: result.status, remainingMs, decision });\n\n if (decision !== 'retry') {\n const finalResult = decision === 'deadline_exceeded'\n ? { status: 'deadline_exceeded' }\n : decision === 'attempts_exhausted'\n ? { status: 'attempts_exhausted' }\n : decision === 'check_state'\n ? { status: 'unknown_result' }\n : result;\n return { result: finalResult, events };\n }\n }\n\n return { result: { status: 'attempts_exhausted' }, events };\n}\n\nconst example = runBoundedRetries({\n operationId: 'op-42',\n method: 'GET',\n responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});\n\nconsole.assert(example.result.status === 200);\nconsole.assert(example.events.map((event) =&gt; event.decision).join(',') === 'retry,retry,return');</code></pre>\n<p>В этом наборе клиент создаёт три события и возвращает <code>200</code>. Последняя попытка не получает решение <code>retry</code>, потому что дальше идти нельзя. Если заменить третий ответ на <code>503</code>, результатом станет <code>attempts_exhausted</code>, а не ошибочно названный <code>deadline_exceeded</code>. Если заменить метод на <code>POST</code> и ответ на <code>timeout</code>, клиент перейдёт в <code>unknown_result</code> и не создаст второй вызов.</p>\n<p>Проверки <code>console.assert</code> фиксируют итог и порядок решений. Они не заменяют тест реального клиента: в production нужно измерять монотонное время, обрабатывать сетевые исключения, учитывать <code>Retry-After</code>, добавлять backoff с jitter и передавать отдельный <code>requestId</code> для каждой попытки.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Резкий рост запросов после 503</td><td>Нет общего deadline или backoff</td><td>Сравнить attempt и remainingMs</td><td>Ограничить бюджет, добавить задержку и jitter</td></tr><tr><td>Один пользователь получил две записи</td><td>POST повторили после timeout</td><td>Сопоставить operationId на сервере</td><td>Остановить retry, ввести ключ и запрос состояния</td></tr><tr><td>В логах только «failed»</td><td>Причина и decision слиты</td><td>Найти status/errorClass и decision</td><td>Сделать перечисление причин и действий</td></tr><tr><td>Клиент ждёт дольше SLA</td><td>Timeout задан на попытку, не на операцию</td><td>Проверить остаток времени перед вызовом</td><td>Передавать общий deadline вниз по стеку</td></tr><tr><td>После 429 нагрузка не падает</td><td>Клиент игнорирует ограничение сервера</td><td>Проверить Retry-After и частоту попыток</td><td>Снизить темп и завершать попытку по политике лимита</td></tr><tr><td>Нельзя связать клиентский и серверный след</td><td>Идентификатор меняется при retry</td><td>Сопоставить operationId и requestId</td><td>Сохранить идентификатор операции, запросу дать новый номер</td></tr></tbody></table>\n<h2>Неизвестный результат записи</h2>\n<p>Если ответ потерялся после записи, новый <code>POST</code> не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.</p>\n<p>Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Повтор с тем же ключом и теми же параметрами должен вернуть сохранённый результат по правилам API. Тот же ключ с другим телом должен завершаться конфликтом до нового побочного эффекта, иначе старый результат можно ошибочно выдать за результат новой команды.</p>\n<p>Логи помогают расследованию, но не делают повтор безопасным. Записывайте метод, endpoint без секретных параметров, обезличенный ключ операции, requestId, номер попытки, статус, класс ошибки, остаток времени и решение. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.</li><li>Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.</li><li>Разделить timeout отдельного вызова и общий deadline операции.</li><li>Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток, backoff и действие при исчерпании времени.</li><li>Добавить событие попытки с requestId, attempt, status или errorClass, remainingMs и decision.</li><li>Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.</li><li>Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.</li><li>Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.</li><li>Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.</li></ol>\n<h2>Ограничения</h2>\n<p>Локальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.</p>\n<p>Ни RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.</p>\n<p>Учебная функция не моделирует два процесса, атомарность базы, частичную запись, истечение TTL ключа или повтор после восстановления. Поэтому она годится для проверки ветвления и названий состояний, но не для обещаний о доступности или времени ответа. Производственные числа получают из наблюдений конкретной системы.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после временных отказов видны попытки с <code>decision=retry</code>, а затем успешный возврат, <code>attempts_exhausted</code> или честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.</p>\n<p>Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 9110: HTTP Semantics, раздел об идемпотентных методах</a> — официальная спецификация семантики методов, статусов и идемпотентности. Она не выбирает retry-политику конкретного API.</li><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — официальное описание сигналов наблюдаемости и их назначения. Оно не подтверждает поля из этого учебного события и не заменяет локальный telemetry-контракт.</li></ul>"
}