DarkRiDDeR14 мин

Лог повторов, который помогает расследовать сбой: attempt, deadline и причина

НадёжностьНаблюдаемость

В журнале часто остаётся только «request failed». Цена такой записи — не понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Без номера попытки и общего deadline команда увеличивает retry, не видя, что каждый новый запрос уже съедает остаток времени.

Соберём компактное событие попытки и локальный исполнитель, который возвращает результат или ошибку с причиной. Входы фиксированы: метод, endpoint без секретов, лимит попыток и deadline. Выход — список событий и итог. Пример учебный и не подключается к журналу, сети или системе наблюдаемости.

Одно событие — одна попытка

Не смешивайте в одну строку попытку и операцию. Операция имеет устойчивый идентификатор, попытка — порядковый номер, начало, длительность, статус и причину. deadlineRemainingMs показывает, сколько времени оставалось перед вызовом. Вместе эти поля позволяют увидеть, где именно закончился бюджет времени.

Минимальные поля retry-события
ПолеПримерЗачем
operationIdop-42связать попытки одного действия
attempt2видеть число вызовов
methodGETпроверить семантику повтора
status503 или timeoutотличить ответ от исключения
remainingMs180увидеть границу времени
decisionretry или returnзафиксировать действие клиента
Цикл обработки ответа: попытка, фиксация статуса, проверка deadline, решение о повторе или возврате.
Цикл сохраняет событие до следующего вызова. Конечный ответ и исчерпанное время становятся различимыми состояниями.

Учебный исполнитель с фиксированными ответами

Функция принимает массив заранее заданных результатов. Вход не содержит внешних данных: это позволяет проверить порядок событий и границу попыток. Ожидаемый результат — после двух 503 возвращается 200, а в событиях остаются номера 1, 2 и 3. В реальном клиенте массив заменяется сетевым вызовом, а формат события сохраняется.

function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {
  const events = [];
  for (let index = 0; index < Math.min(maxAttempts, responses.length); index += 1) {
    const result = responses[index];
    const retryable = method === 'GET' && result.status === 503;
    const remainingMs = Math.max(0, deadlineMs - index * 120);
    events.push({ operationId, attempt: index + 1, method, status: result.status, remainingMs, decision: retryable ? 'retry' : 'return' });
    if (remainingMs === 0) return { result: { status: 'deadline' }, events };
    if (!retryable) return { result, events };
  }
  return { result: { status: 'deadline' }, events };
}

console.log(runBoundedRetries({ operationId: 'op-42', method: 'GET', responses: [{ status: 503 }, { status: 503 }, { status: 200 }] }));

В выводе три события, а итоговый статус — 200. Если заменить метод на POST, первая запись сразу получит решение return. Это не утверждение о любом POST: исполнитель демонстрирует консервативную политику, которую нужно заменить контрактом конкретного endpoint. Название decision полезнее, чем свободная фраза в логе.

Причина и действие должны быть разными полями

Причина — timeout, 503, 429, DNS error или отмена. Действие — retry, return, fail или cancel. Если записать «retry из-за временной ошибки» одной строкой, невозможно посчитать, какой класс ответов создал нагрузку. Раздельные поля позволяют построить таблицу по методу и не смешивать сетевой отказ с бизнес-ошибкой.

Не сохраняйте тело ответа по умолчанию. Для диагностики обычно достаточно status, безопасного подтипа и размера. URL нормализуйте, удаляя query-секреты. Идентификатор операции не должен быть email, номером карты или токеном. Чем больше произвольного текста в событии, тем выше стоимость хранения и риск утечки.

Число попыток не равно надёжности

Что можно увидеть в событиях
НаборИнтерпретацияДействие
1 timeout, returndeadline слишком мал или вызов зависразделить connect/read timeout
3 × 503, deadlineответ не восстановился за времяпроверить зависимость и backoff
1 × 429, returnсработал rate limitпрочитать Retry-After и снизить темп
POST, 503, returnповтор запрещён политикойпроверить состояние по operation key

Исчерпанное время — отдельный результат

Если последняя попытка закончилась на deadline, это не то же самое, что ответ 503. Сервер мог принять запрос, а клиент не успел дождаться ответа. В событии сохраняйте lastAttempt и lastKnownStatus, но не превращайте неизвестное состояние в «операция не выполнена». Для чтения можно вернуть контролируемую ошибку, для записи — запросить состояние по ключу операции.

Такой вывод меняет следующий шаг. Для timeout чтения проверяем границы времени и зависимость. Для timeout записи сначала ищем безопасный способ узнать состояние, а не запускаем ещё один POST. Небольшая разница в двух полях предотвращает самый дорогой вид автоматизма — повтор действия, о результате которого клиент уже не знает.

Порядок внедрения события

  1. Выбрать operationId и правило его жизненного цикла.
  2. Добавить attempt, method, status или errorClass и оставшееся время.
  3. Разделить decision от причины и ограничить перечисление значений.
  4. Удалить query-секреты, cookie, тело и персональные поля до записи.
  5. Проверить наборы GET/503, GET/timeout, POST/503 и успешный первый вызов.
  6. Считать распределение попыток и долю завершений по deadline, не меняя логику по одному шумному сообщению.

Ограничения и следующий шаг

Локальный исполнитель не показывает распределённую доставку логов, часы разных узлов и повтор после потери ответа. Поля события должны быть согласованы между клиентом и сервером, иначе operationId распадётся на несколько имён. Наблюдаемость не делает retry безопасным — она только показывает, что он делает.

Следующий шаг — подключить эти поля к одному безопасному GET и построить отчёт по p95 попыток и доле timeout. Для записи сначала добавьте operation key и проверку состояния. Только после этого увеличивайте число повторов или задержку.

Проверяемые источники

  • RFC 9110 — HTTP Semantics — IETF Standards Track, June 2022. Задаёт семантику безопасных и идемпотентных методов, статусов и поля Retry-After. Граница применимости: Не выбирает политику повтора для конкретного API и не гарантирует отсутствие побочных эффектов.
  • RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport — IETF Standards Track, May 2021. Показывает, что транспортное управление потоками и потерями отделено от прикладной семантики запроса. Граница применимости: Не является инструкцией по retry HTTP-клиента и не описывает поведение конкретной библиотеки.