DarkRiDDeR14 мин

Повтор запроса без двойного действия: timeout, 503 и безопасный retry

НадёжностьHTTP

Клиент получил timeout и повторил запрос, а пользователь увидел две созданные заявки. Цена ошибки — повторить побочный эффект, который сервер уже выполнил, но ответ потерялся по дороге. Обратная ситуация тоже опасна: не повторить безопасный GET после временного 503 и показать лишний сбой.

Нужны три явных ограничения: общий deadline, число попыток и список методов, для которых повтор допустим. Учебный локальный сервер дважды отвечает 503, затем возвращает 200. Клиент показывает порядок попыток и завершает работу в пределах заданного времени.

Сначала решаем, что можно повторить

RFC 9110 называет идемпотентным запрос, повтор которого имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это свойство операции, а не магия клиента: GET, HEAD, PUT и DELETE имеют другую семантику, чем обычный POST. Сервер может отдельно предоставить идемпотency-key для создания ресурса, но это нужно подтвердить его контрактом.

Матрица решения о повторе
МетодПример действияПовтор после timeoutУсловие
GETпрочитать каталогобычно допустимлимит и deadline
HEADпроверить ресурсобычно допустимответ не нужен в теле
PUTзаписать состояние по ключувозможенсервер сохраняет идемпотентность
DELETEудалить по идентификаторувозможенповторный 404 трактуется контрактом
POSTсоздать заказне по умолчаниюidempotency-key и правила сервера
Дерево решения для повторения HTTP-запроса: timeout, статус, метод, deadline и конечный ответ.
Диаграмма сначала проверяет deadline и метод, затем статус. Ошибка не превращается в повтор автоматически.

Общий deadline важнее числа попыток

Три попытки по 500 миллисекунд могут занять больше секунды, если между ними стоят задержки. Пользователь и вызывающая система ждут не количество попыток, а завершение операции. Поэтому задаём общий deadline, а на каждой итерации считаем оставшееся время. Если его недостаточно для следующего запроса, возвращаем timeout.

Задержка между попытками должна быть ограниченной и лучше иметь случайное рассеивание в многоклиентской системе. Но jitter не исправляет неправильную семантику. Сначала решается «можно ли повторять», затем «сколько времени отдать», и только потом выбираются backoff и случайная добавка.

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

Вход функции requestWithRetry — URL, метод и параметры времени. Сервер хранит счётчик только внутри процесса: первые два ответа — 503, третий — 200. Ожидаемый результат — attempts: 3 и status: 200. На локальном loopback нет внешней зависимости.

import { createServer } from 'node:http';

let calls = 0;
const server = createServer((request, response) => {
  calls += 1;
  if (calls < 3) {
    response.writeHead(503, { 'retry-after': '0' });
    response.end('busy');
    return;
  }
  response.end('ready');
});

async function requestWithRetry(url, { method = 'GET', maxAttempts = 3, deadlineMs = 1000 } = {}) {
  if (!['GET', 'HEAD', 'PUT', 'DELETE'].includes(method)) throw new Error('method is not retryable');
  const deadline = Date.now() + deadlineMs;
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const remaining = deadline - Date.now();
    if (remaining <= 0) throw new Error('deadline reached');
    const response = await fetch(url, { method, signal: AbortSignal.timeout(remaining) });
    if (response.ok) return { attempts: attempt, status: response.status };
  }
  throw new Error('deadline reached');
}

server.listen(0, '127.0.0.1', async function retryDemo() {
  const port = server.address().port;
  console.log(await requestWithRetry('http://127.0.0.1:' + port));
  server.close();
});

Результат — { attempts: 3, status: 200 }. В коде есть намеренное ограничение: он не повторяет POST и не разбирает Retry-After. Для учебного стенда этого достаточно, чтобы увидеть связь между методом и повтором. В библиотеке нужно добавить AbortSignal, deadline, backoff, обработку сетевого исключения и лог безопасных попыток.

Что означает 503

503 — ответ сервера, а не доказательство, что повтор сработает. Заголовок Retry-After может указать время ожидания, но его значение нужно проверить как число секунд или дату. Если сервис за прокси возвращает 503 от посредника, повтор может увеличить нагрузку на origin. Логируйте источник ответа, если архитектура позволяет отличить proxy от приложения.

Для timeout сложнее: запрос мог быть принят и выполнен, а клиент потерял ответ. Для чтения это обычно приемлемо, для записи — нет без ключа дедупликации. Поэтому error class должна хранить не только «сетевой сбой», но и метод, request id, idempotency key и достигнутый этап.

Порядок внедрения

  1. Составить таблицу методов и побочных эффектов конкретного API.
  2. Ввести общий deadline и передавать его во все сетевые вызовы.
  3. Разрешить retry только для подтверждённых безопасных или идемпотентных операций.
  4. Ограничить число попыток, задержку и суммарное время ожидания.
  5. Сохранить попытки, статусы и тип ошибки без токенов и тела с персональными данными.
  6. Проверить отдельным тестом timeout после принятия записи и ответ 503 от посредника.

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

Локальный сервер не моделирует потерю ответа после выполнения записи, балансировку и лимиты зависимостей. Список retryable-методов — не универсальная политика: API может ограничить повтор иначе. QUIC управляет транспортом, но не делает прикладной POST идемпотентным.

Следующий шаг — добавить к API контракт idempotency key для операций создания и проверить его на одинаковом ключе. Для чтения подключите bounded retry с общим deadline. Критерий готовности простой: повтор не выходит за время, не скрывает причину и не создаёт второй побочный эффект.

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

  • 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-клиента и не описывает поведение конкретной библиотеки.