如何设计一个具备“自动指数退避”重试逻辑的 API 轮询请求网关

先说一个核心结论:构建一个具备指数退避能力的重试网关,其精髓远不止“多试几次”。真正的价值在于,当系统压力过大时,它能引导失败的请求主动“退让”,释放资源,从而有效避免连锁雪崩。实现这一点的关键,在于退避策略必须包含三个要素:随机抖动、最大重试次数限制,以及超时与熔断的双重保险。
为什么 setTimeout 简单累加会把后端压垮
一个常见的误区是,将重试延迟简单地写成 retryDelay = base * 2 ** attempt,然后直接调用 setTimeout。这种做法会带来一个致命问题:所有客户端将在完全相同的时刻发起重试。想象一下,所有请求的第三次重试都在800毫秒后同时触发,这就形成了一场“重试风暴”。尤其在服务短暂故障后恢复的瞬间,大量请求如潮水般涌来,很可能直接将刚刚喘过气来的后端再次击穿。
那么,正确的实操姿势是什么?
- 必须引入随机抖动(Jitter):加入一个类似
Math.random() * 0.3的随机因子。例如,将延迟公式调整为retryDelay = base * Math.pow(2, attempt) * (1 + Math.random() * 0.3),让重试时间点变得参差不齐。 - 硬性限制最大退避时间:比如设定上限为60000毫秒,防止某次重试因为计算延迟过长(例如卡在5分钟后)而失去意义。
- 重试前检查熔断器:在每次尝试前,先判断全局熔断状态。如果熔断器已开启,则应立即放弃,抛出类似
new Error("CIRCUIT_OPEN")的错误,避免无谓的请求。
fetch 请求中嵌入退避逻辑的最小可行实现
实现时,切忌将其封装成一个完全不可控的黑盒函数。务必保留对 signal、headers 以及响应体处理方式的控制权。下面这段代码提供了一个可直接集成到现有请求工具中的最小可行方案:
async function pollWithBackoff(url, options = {}, { base = 1000, maxRetries = 5 } = {}) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000);
const res = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
lastError = err;
if (attempt === maxRetries) break;
const jitter = 1 + Math.random() * 0.3;
const delay = Math.min(base * Math.pow(2, attempt) * jitter, 60000);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
这里有几个细节需要特别注意:
- 每次重试都需新建
AbortController:否则,前一次请求的 abort 操作可能会意外中断后续的重试请求。 - 超时是“单次尝试”级别的:代码中的
timeout控制的是单次fetch的超时,而非整个轮询过程的总时长。 - 返回值处理:示例中直接返回
await res.json()是为了让上层调用方能便捷地使用数据。如果需要进行流式处理或自定义解析,则应将原始的Response对象传递出去。
如何判断该重试 vs 该放弃(4xx/5xx 分类处理)
并非所有错误都值得用指数退避去重试。对 401 Unauthorized(未授权)或 404 Not Found(资源不存在)这类错误进行盲目重试,纯粹是浪费资源。真正需要退避策略出马的,是像 503 Service Una vailable(服务不可用)、429 Too Many Requests(请求过多)、网络连接拒绝或超时这类暂时性故障。
具体该如何操作呢?
- 明确重试范围:仅对网络错误(如
TypeError)、5xx 服务器错误以及明确包含重试提示的响应(例如带有Retry-After头部)启用退避逻辑。 - 4xx 错误的特殊处理:在4xx客户端错误中,通常只特殊处理
408 Request Timeout和429。其他4xx错误应直接视为失败,无需重试。 - 尊重
Retry-After头部:如果响应中包含Retry-After头部,应优先采用其建议的等待时间,但同样建议叠加一个随机抖动,以防止所有客户端再次同步。 - 记录重试日志:将每次重试的尝试次数(
attempt)、HTTP状态码(status)和实际延迟(delay)记录到日志中,这对于后期排查是否误判了错误类型至关重要。
最后,一个最容易被忽略的要点是退避策略与业务语义的耦合问题。举个例子,在轮询订单状态时,如果重试3次后返回的状态仍是“处理中”,接下来该怎么办?是继续等待还是通知用户?退避逻辑只负责管理请求的节奏和时机,而像“多久才算超时”这类业务决策,必须由上层应用来决定——网关不应该越俎代庖。
