8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"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 < 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' && 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>"
|
||
}
|