{ "index": 16, "slug": "editorial-2027-07-field-reliability-capstone", "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", "excerpt": "Разбираем лавину повторных запросов при сбое зависимости: какие поля сохранить, когда остановиться и почему timeout записи нельзя считать доказательством неуспеха.", "contentHtml": "
Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.
\nЦена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.
\nТезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принять решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.
\nОдна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать: requestId помогает найти один сетевой вызов, а operationId связывает весь пользовательский сценарий. Если записать только финальное «503», расследование потеряет порядок событий.
Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded; когда достигнут лимит попыток, это другое состояние.
Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, check_state, attempts_exhausted и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.
| Поле | Пример | Зачем |
|---|---|---|
operationId | op-42 | Связать попытки одной операции |
requestId | req-02 | Найти одну сетевую попытку |
attempt | 2 | Увидеть порядок и число вызовов |
method | GET | Проверить семантику повтора |
status или errorClass | 503, timeout | Отделить ответ сервера от исключения |
remainingMs | 180 | Понять, сколько времени оставалось |
decision | retry | Зафиксировать решение клиента |
HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации: серверный обработчик и его побочные эффекты остаются частью проверки.
\nGET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и учитывает Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия, проверка параметров и сохранённый результат.
Статус сам по себе тоже не даёт разрешения на retry. 503 обычно означает временную недоступность, но ответ посредника мог появиться после того, как upstream уже применил запись. 429 требует учесть ограничение сервера, а ошибка DNS, отмена пользователем и ошибка валидации не должны попадать в один список с временным отказом.
Учебный пример ниже намеренно консервативен. Он не обращается в сеть, а получает заранее заданный массив ответов. Это позволяет воспроизвести решение клиента и отдельно увидеть разницу между последней попыткой и исчерпанным deadline. Пример повторяет только GET со статусом 503; он не доказывает поведение конкретной HTTP-библиотеки.
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');\nВ этом наборе клиент создаёт три события и возвращает 200. Последняя попытка не получает решение retry, потому что дальше идти нельзя. Если заменить третий ответ на 503, результатом станет attempts_exhausted, а не ошибочно названный deadline_exceeded. Если заменить метод на POST и ответ на timeout, клиент перейдёт в unknown_result и не создаст второй вызов.
Проверки console.assert фиксируют итог и порядок решений. Они не заменяют тест реального клиента: в production нужно измерять монотонное время, обрабатывать сетевые исключения, учитывать Retry-After, добавлять backoff с jitter и передавать отдельный requestId для каждой попытки.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Резкий рост запросов после 503 | Нет общего deadline или backoff | Сравнить attempt и remainingMs | Ограничить бюджет, добавить задержку и jitter |
| Один пользователь получил две записи | POST повторили после timeout | Сопоставить operationId на сервере | Остановить retry, ввести ключ и запрос состояния |
| В логах только «failed» | Причина и decision слиты | Найти status/errorClass и decision | Сделать перечисление причин и действий |
| Клиент ждёт дольше SLA | Timeout задан на попытку, не на операцию | Проверить остаток времени перед вызовом | Передавать общий deadline вниз по стеку |
| После 429 нагрузка не падает | Клиент игнорирует ограничение сервера | Проверить Retry-After и частоту попыток | Снизить темп и завершать попытку по политике лимита |
| Нельзя связать клиентский и серверный след | Идентификатор меняется при retry | Сопоставить operationId и requestId | Сохранить идентификатор операции, запросу дать новый номер |
Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.
Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Повтор с тем же ключом и теми же параметрами должен вернуть сохранённый результат по правилам API. Тот же ключ с другим телом должен завершаться конфликтом до нового побочного эффекта, иначе старый результат можно ошибочно выдать за результат новой команды.
\nЛоги помогают расследованию, но не делают повтор безопасным. Записывайте метод, endpoint без секретных параметров, обезличенный ключ операции, requestId, номер попытки, статус, класс ошибки, остаток времени и решение. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины.
\nЛокальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.
\nНи RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.
\nУчебная функция не моделирует два процесса, атомарность базы, частичную запись, истечение TTL ключа или повтор после восстановления. Поэтому она годится для проверки ветвления и названий состояний, но не для обещаний о доступности или времени ответа. Производственные числа получают из наблюдений конкретной системы.
\nИзменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после временных отказов видны попытки с decision=retry, а затем успешный возврат, attempts_exhausted или честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.
Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.
\n