8 lines
21 KiB
JSON
8 lines
21 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 и результат. Эти сущности нельзя смешивать: <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' && result.status === 503 && remainingMs > 0 && !isLastAttempt;\n const decision = remainingMs === 0\n ? 'deadline_exceeded'\n : canRetry\n ? 'retry'\n : method === 'POST' && result.status === 'timeout'\n ? 'check_state'\n : isLastAttempt && 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) => 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>"
|
||
}
|