Files
progcode/editorial/agent-rewrites/016.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
19 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 и результат. Эти сущности нельзя смешивать. Если записать только финальное «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>fail</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>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>Учебный пример ниже намеренно консервативен. Он повторяет только <code>GET</code> со статусом <code>503</code>. Массив ответов заменяет сеть, поэтому код не доказывает поведение конкретной библиотеки и не описывает production-систему.</p>\n<pre><code>function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n const events = [];\n\n for (let i = 0; i &lt; Math.min(maxAttempts, responses.length); i += 1) {\n const result = responses[i];\n const remainingMs = Math.max(0, deadlineMs - i * 120);\n const retryable = method === 'GET' &amp;&amp; result.status === 503;\n const decision = remainingMs === 0 ? 'fail' : retryable ? 'retry' : 'return';\n\n events.push({\n operationId,\n attempt: i + 1,\n method,\n status: result.status,\n remainingMs,\n decision\n });\n\n if (decision !== 'retry') {\n return { result: decision === 'fail' ? { status: 'deadline_exceeded' } : result, events };\n }\n }\n\n return { result: { status: 'deadline_exceeded' }, events };\n}\n\nrunBoundedRetries({\n operationId: 'op-42',\n method: 'GET',\n responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});</code></pre>\n<p>В этом учебном наборе клиент создаёт три события и возвращает <code>200</code>. Если заменить метод на <code>POST</code>, первый <code>503</code> получит решение <code>return</code>. Такой результат не означает, что любой POST надо немедленно завершать. Он показывает отрицательный путь: без доказанной идемпотентности повтор запрещён.</p>\n<p>В настоящем клиенте есть ещё одна проверка. Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.</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>503</code> при остатке 380 миллисекунд. Клиент записал <code>decision=retry</code>, подождал ограниченный интервал и повторил запрос. Вторая попытка снова получила <code>503</code>. Осталось 120 миллисекунд, поэтому третья попытка допустима только после оценки её минимального времени выполнения. Если бюджет мал, клиент завершает операцию с <code>deadline_exceeded</code>, даже если в массиве есть следующий ответ.</p>\n<p>Теперь рассмотрим запись. Сервер мог принять запрос, но соединение оборвалось до ответа. Клиент записал <code>errorClass=timeout</code>, <code>decision=check_state</code> и сохранил operation key. Он не создаёт новую запись. Это медленнее, чем слепой retry, но цена неизвестного результата ниже цены дублирования побочного эффекта.</p>\n<p>Для логов достаточно безопасного endpoint без query-секретов, метода, статуса, класса ошибки, номера попытки, оставшегося времени и решения. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины. Поле наблюдаемости не должно становиться новым каналом утечки.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.</li><li>Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.</li><li>Разделить timeout отдельного вызова и общий deadline операции.</li><li>Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток и действие при исчерпании времени.</li><li>Добавить событие попытки с 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<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после двух временных отказов видны попытки 1 и 2 с <code>decision=retry</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\" 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>"
}