接口返回HTTP 503时,最简单的写法通常是捕获错误后立即再请求一次。
但在代理IP、API网关和多级上游环境中,这种"失败就重试"很容易放大故障。
更稳妥的重试逻辑至少要回答四个问题:
- 当前请求方法是否适合自动重试?
- 响应有没有提供
Retry-After? - 最多允许重试几次、等待多久?
- 哪些状态应该停止并交还上层处理?
下面使用Node.js内置fetch实现一个范围有限的503重试器。
Retry-After不只有秒数
RFC 9110规定,Retry-After可以是非负整数秒数,也可以是HTTP日期。RFC 9110
下面两种格式都合法:
makefile
Retry-After: 120
yaml
Retry-After: Fri, 06 Nov 2026 09:30:00 GMT
如果代码只使用Number(header),遇到日期格式就会得到NaN,随后可能立即重试。
先写一个兼容两种格式的解析函数:
javascript
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等权限问题当成临时故障。
ini
// 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);
}
}
}
调用示例
php
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。
一个可控的重试器应该:
- 解析Retry-After的秒数和日期格式;
- 默认只自动重试适合的请求方法;
- 设置次数、退避、最长等待和单次超时;
- 区分429、503、权限错误和网络异常;
- 超出任务窗口时返回结果,由上层决定下一步。
重试的目标不是让每次请求最终都变成200,而是在服务暂时不可用时,以可解释、可停止的方式恢复有限任务。