В журнале часто остаётся только «request failed». Цена такой записи — не понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Без номера попытки и общего deadline команда увеличивает retry, не видя, что каждый новый запрос уже съедает остаток времени.
Соберём компактное событие попытки и локальный исполнитель, который возвращает результат или ошибку с причиной. Входы фиксированы: метод, endpoint без секретов, лимит попыток и deadline. Выход — список событий и итог. Пример учебный и не подключается к журналу, сети или системе наблюдаемости.
Одно событие — одна попытка
Не смешивайте в одну строку попытку и операцию. Операция имеет устойчивый идентификатор, попытка — порядковый номер, начало, длительность, статус и причину. deadlineRemainingMs показывает, сколько времени оставалось перед вызовом. Вместе эти поля позволяют увидеть, где именно закончился бюджет времени.
| Поле | Пример | Зачем |
|---|---|---|
| operationId | op-42 | связать попытки одного действия |
| attempt | 2 | видеть число вызовов |
| method | GET | проверить семантику повтора |
| status | 503 или timeout | отличить ответ от исключения |
| remainingMs | 180 | увидеть границу времени |
| decision | retry или return | зафиксировать действие клиента |
Учебный исполнитель с фиксированными ответами
Функция принимает массив заранее заданных результатов. Вход не содержит внешних данных: это позволяет проверить порядок событий и границу попыток. Ожидаемый результат — после двух 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, return | deadline слишком мал или вызов завис | разделить 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. Небольшая разница в двух полях предотвращает самый дорогой вид автоматизма — повтор действия, о результате которого клиент уже не знает.
Порядок внедрения события
- Выбрать operationId и правило его жизненного цикла.
- Добавить attempt, method, status или errorClass и оставшееся время.
- Разделить decision от причины и ограничить перечисление значений.
- Удалить query-секреты, cookie, тело и персональные поля до записи.
- Проверить наборы GET/503, GET/timeout, POST/503 и успешный первый вызов.
- Считать распределение попыток и долю завершений по 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-клиента и не описывает поведение конкретной библиотеки.