Node.js请求遇到503怎么重试?先解析Retry-After,再做有上限退避

13 阅读6分钟

接口返回HTTP 503时,最简单的写法通常是捕获错误后立即再请求一次。

但在代理IP、API网关和多级上游环境中,这种“失败就重试”很容易放大故障。

更稳妥的重试逻辑至少要回答四个问题:

  1. 当前请求方法是否适合自动重试?
  2. 响应有没有提供Retry-After?
  3. 最多允许重试几次、等待多久?
  4. 哪些状态应该停止并交还上层处理?

下面使用Node.js内置fetch实现一个范围有限的503重试器。

Retry-After不只有秒数

RFC 9110规定,Retry-After可以是非负整数秒数,也可以是HTTP日期。RFC 9110

下面两种格式都合法:

Retry-After: 120
Retry-After: Fri, 06 Nov 2026 09:30:00 GMT

如果代码只使用Number(header),遇到日期格式就会得到NaN,随后可能立即重试。

先写一个兼容两种格式的解析函数:

function parseRetryAfter(value, now = Date.now()) {
  if (!value) return null;

  const seconds = Number(value);

  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }

  const retryAt = Date.parse(value);

  if (Number.isNaN(retryAt)) {
    return null;
  }

  return Math.max(0, retryAt - now);
}

返回值统一使用毫秒。无法识别时返回null,交给退避策略处理。

一个有边界的fetch重试器

下面的实现默认只自动重试GET和HEAD,并且只处理429和503。

它不会自动重试POST,也不会把403、401等权限问题当成临时故障。

// bounded-fetch.mjs
import { setTimeout as sleep } from 'node:timers/promises';

const AUTO_RETRY_METHODS = new Set(['GET', 'HEAD']);
const RETRYABLE_STATUS = new Set([429, 503]);

function parseRetryAfter(value, now = Date.now()) {
  if (!value) return null;

  const seconds = Number(value);

  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }

  const retryAt = Date.parse(value);

  if (Number.isNaN(retryAt)) {
    return null;
  }

  return Math.max(0, retryAt - now);
}

function backoffWithJitter(attempt, baseMs, maxMs) {
  const exponential = Math.min(
    maxMs,
    baseMs * 2 ** attempt
  );

  const jitter = Math.floor(
    Math.random() * Math.max(1, exponential * 0.2)
  );

  return exponential + jitter;
}

function safeUrl(rawUrl) {
  const url = new URL(rawUrl);
  return `${url.origin}${url.pathname}`;
}

export async function fetchWithBoundedRetry(
  rawUrl,
  options = {}
) {
  const {
    method = 'GET',
    headers,
    body,
    maxRetries = 2,
    baseDelayMs = 1000,
    maxBackoffMs = 8000,
    maxServerWaitMs = 30000,
    requestTimeoutMs = 10000
  } = options;

  const normalizedMethod = method.toUpperCase();

  const canAutoRetry =
    AUTO_RETRY_METHODS.has(normalizedMethod);

  for (let attempt = 0; ; attempt += 1) {
    const startedAt = Date.now();

    try {
      const response = await fetch(rawUrl, {
        method: normalizedMethod,
        headers,
        body,
        redirect: 'follow',
        signal: AbortSignal.timeout(requestTimeoutMs)
      });

      const retryableStatus =
        RETRYABLE_STATUS.has(response.status);

      const retryAfterMs = parseRetryAfter(
        response.headers.get('retry-after')
      );

      console.log({
        url: safeUrl(rawUrl),
        attempt,
        status: response.status,
        finalUrl: safeUrl(response.url),
        retryAfterMs,
        durationMs: Date.now() - startedAt
      });

      if (
        !retryableStatus ||
        !canAutoRetry ||
        attempt >= maxRetries
      ) {
        return response;
      }

      /*
       * 服务端要求等待的时间超出当前任务窗口时,
       * 不擅自缩短等待,也不继续请求。
       */
      if (
        retryAfterMs !== null &&
        retryAfterMs > maxServerWaitMs
      ) {
        return response;
      }

      const waitMs =
        retryAfterMs ??
        backoffWithJitter(
          attempt,
          baseDelayMs,
          maxBackoffMs
        );

      await response.body?.cancel();
      await sleep(waitMs);
    } catch (error) {
      const retryableNetworkError =
        canAutoRetry &&
        attempt < maxRetries &&
        error?.name !== 'AbortError' &&
        error?.name !== 'TimeoutError';

      console.error({
        url: safeUrl(rawUrl),
        attempt,
        errorName: error?.name,
        message: error?.message,
        durationMs: Date.now() - startedAt
      });

      if (!retryableNetworkError) {
        throw error;
      }

      const waitMs = backoffWithJitter(
        attempt,
        baseDelayMs,
        maxBackoffMs
      );

      await sleep(waitMs);
    }
  }
}

调用示例

import {
  fetchWithBoundedRetry
} from './bounded-fetch.mjs';

const response = await fetchWithBoundedRetry(
  'https://example.com/api/status',
  {
    method: 'GET',
    maxRetries: 2,
    requestTimeoutMs: 10000,
    maxServerWaitMs: 30000
  }
);

if (!response.ok) {
  console.error('请求未通过', {
    status: response.status,
    retryAfter:
      response.headers.get('retry-after')
  });

  process.exitCode = 1;
} else {
  const data = await response.json();
  console.log(data);
}

这里有一个容易忽略的点:函数返回503响应,不代表函数失效。

当重试次数用完、请求方法不适合自动重试,或者服务端要求的等待时间超出当前任务窗口时,正确动作就是把响应交还调用方,让上层决定稍后调度、报警或转人工处理。

Node.js当前文档中提供了全局fetch及AbortSignal.timeout();实际使用前仍应核对项目的Node.js版本。Node.js全局对象文档

为什么默认不自动重试POST

POST可能产生创建订单、提交表单、扣减库存或写入数据等副作用。

客户端收到超时,不等于服务端没有执行请求。如果此时自动重试,可能产生重复写入。

RFC 9110明确提醒:除非客户端知道请求语义实际具有幂等性,或者能够确认原请求没有被应用,否则不应自动重试非幂等请求。

如果业务确实需要安全重试POST,应由接口设计提供幂等键、请求唯一标识或服务端去重机制,而不是简单地把POST加入重试方法列表。

429和503为什么要分开记录

429表示请求过多,通常与限流规则相关;503表示服务当前无法处理请求,可能与过载或维护相关。

两者都可能携带Retry-After,但处理结论不应写成同一句“换代理IP再试”。RFC 6585

429可能按照账号、Cookie、令牌、资源或服务器集群计数,更换IP未必会重置限制。

连续切换地址尝试规避限制,也可能违反任务边界或平台规则。

503则要继续确认响应来源。它可能来自目标服务器、CDN、网关或代理服务。

来源不明时,应该保存响应头和请求标识,而不是不断扩大重试范围。

为什么需要随机抖动

如果大量任务同时收到503,并按照固定的1秒、2秒、4秒重试,它们可能再次在相同时间集中请求。

示例在指数退避基础上加入了少量随机抖动,让不同任务的再次请求时间稍微错开。

但抖动不是增加重试次数的理由。次数上限、总等待窗口和停止条件仍然必须保留。

日志中应该记录什么

至少记录:

  • 脱敏URL;
  • 请求方法;
  • 尝试序号;
  • HTTP状态;
  • Retry-After解析结果;
  • 最终URL;
  • 单次耗时;
  • 错误类型;
  • 最终是成功、返回失败响应,还是抛出网络错误。

不要记录代理密码、Authorization、完整Cookie或包含敏感参数的URL。

如果请求通过代理执行,还应在受控日志中记录线路或会话标识,但不要把凭据写入普通应用日志。

静态IP解决不了错误的重试设计

静态IP能够在连续任务中减少出口地址变化这一项变量,方便对照多次请求。

但它不能解决目标服务过载、错误的请求方法、重复POST、限流、无上限重试或不合理的超时配置。

VPS是运行Node.js程序的服务器环境,并不自动等于代理服务。部署到VPS后,依然要明确实际出口、代理配置和任务边界。

总结

Node.js处理503时,不要把重试写成无限循环,也不要看到失败就立即切换IP。

一个可控的重试器应该:

  1. 解析Retry-After的秒数和日期格式;
  2. 默认只自动重试适合的请求方法;
  3. 设置次数、退避、最长等待和单次超时;
  4. 区分429、503、权限错误和网络异常;
  5. 超出任务窗口时返回结果,由上层决定下一步。

重试的目标不是让每次请求最终都变成200,而是在服务暂时不可用时,以可解释、可停止的方式恢复有限任务。