适用场景
本文适用于 Node.js 20.3 及以上版本使用原生 fetch 调用内部 API、第三方服务或网关的 TypeScript 项目。示例使用该版本提供的 AbortSignal.any();更早版本可以用等价的信号组合函数替代。典型现象是:业务层已经返回“请求超时”,但下游仍持续收到请求;并发升高后连接数、内存和事件循环延迟继续上涨;开启重试后,下游故障期间流量反而成倍增加。
问题通常不在“有没有 Promise.race”,而在于超时是否真正传播到了网络请求,以及重试是否受同一个总预算约束。
现象描述
很多项目用下面的方式实现超时:
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithFakeTimeout(url: string): Promise<Response> {
return Promise.race([
fetch(url),
delay(2_000).then(() => {
throw new Error("请求超时");
}),
]);
}
两秒后调用方确实收到了异常,但 Promise.race 只决定哪个 Promise 的结果先被采用,不会取消另一个 Promise。此时 fetch(url) 仍可能继续解析 DNS、建立连接、等待响应或下载响应体。
如果外层收到超时后立即重试,旧请求与新请求会同时存在。一次慢下游可能逐步放大为连接堆积、重复写入和级联故障。
可能原因
- 只用
Promise.race返回超时,没有通过AbortSignal取消真实请求; - 每次重试都重新获得完整超时时间,三次重试把两秒预算扩张成六秒以上;
- 不区分可重试错误,把参数错误、鉴权失败和业务冲突也加入重试;
- 重试没有退避和随机抖动,大量实例在同一时刻再次冲击下游;
- 调用方已经取消,但重试循环没有监听上游信号;
- POST 等非幂等写操作没有幂等键,超时重试造成重复写入。
排查思路
1. 同时记录“业务结束”和“下游结束”
为一次调用生成稳定的 request_id,在入口返回、每次尝试开始、请求取消和下游响应时记录结构化日志。如果入口已返回数秒后仍出现相同 request_id 的下游完成日志,说明取消没有传播。
建议关注这些字段:
| 字段 | 含义 | 判断重点 |
|---|---|---|
request_id |
一次业务调用的关联标识 | 是否出现入口结束后仍在执行的请求 |
attempt |
当前尝试次数 | 是否发生无边界重试 |
elapsed_ms |
从业务调用开始累计耗时 | 是否超过总预算 |
remaining_ms |
发起本次尝试前的剩余预算 | 是否每次重试都被重置 |
error_name |
异常类型 | 是否为 AbortError 或网络错误 |
status_code |
HTTP 状态码 | 是否只重试临时性失败 |
2. 检查连接与请求是否在超时后下降
在压测环境中让下游固定延迟 5 秒,而客户端预算设为 1 秒。停止流量后,活跃请求数应快速回落。如果业务错误数已经停止增长,但下游活跃请求或连接仍维持数秒,通常就是“返回超时但未取消请求”。
3. 检查重试放大倍数
统计入口请求数和下游请求数:
retry_amplification = downstream_request_total / incoming_request_total
如果下游故障时该值突然接近最大尝试次数,说明重试正在放大流量。还应按 status_code、error_name 和 attempt 分组,确认 400、401、403 等永久性错误没有进入重试。
实现方案:共享总预算并传播取消
下面的实现满足四个约束:调用方取消立即停止;所有尝试共享总预算;只重试明确的临时错误;退避等待本身也可以被取消。
type FetchJsonOptions = {
timeoutMs: number;
maxAttempts?: number;
signal?: AbortSignal;
};
class HttpStatusError extends Error {
constructor(
readonly status: number,
readonly responseBody: string,
) {
super(`下游返回异常状态: ${status}`);
this.name = "HttpStatusError";
}
}
function isRetryable(error: unknown): boolean {
if (error instanceof HttpStatusError) {
return error.status === 408 || error.status === 429 || error.status >= 500;
}
return error instanceof TypeError;
}
function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = (): void => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}
export async function fetchJson<T>(
url: string,
options: FetchJsonOptions,
): Promise<T> {
const maxAttempts = options.maxAttempts ?? 3;
const deadlineSignal = AbortSignal.timeout(options.timeoutMs);
const signal = options.signal
? AbortSignal.any([options.signal, deadlineSignal])
: deadlineSignal;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (signal.aborted) {
throw signal.reason;
}
try {
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
const body = (await response.text()).slice(0, 1_024);
throw new HttpStatusError(response.status, body);
}
return (await response.json()) as T;
} catch (error: unknown) {
lastError = error;
if (signal.aborted || !isRetryable(error) || attempt === maxAttempts) {
throw error;
}
const baseDelayMs = 100 * 2 ** (attempt - 1);
const jitterMs = Math.floor(Math.random() * 100);
await abortableDelay(baseDelayMs + jitterMs, signal);
}
}
throw lastError;
}
关键逻辑如下:
AbortSignal.timeout(timeoutMs)从整个函数开始计时,因此重试不会刷新总预算;AbortSignal.any()把上游取消和本地超时合并,任一信号触发都会终止fetch;abortableDelay()让退避等待也接受取消,避免请求取消后仍睡眠;- 只把网络层
TypeError、408、429 和 5xx 视为候选临时错误; - 错误响应体最多读取 1 KiB,既释放响应体,也避免异常响应占用过多内存。
response.json() as T 只提供编译期类型提示,不会校验外部数据。生产代码应在边界使用 Zod、Valibot 或项目已有的运行时校验器验证响应结构。
调用示例
上游 HTTP 请求断开时,应把对应的取消信号传入下游调用:
type UserProfile = {
id: string;
displayName: string;
};
const controller = new AbortController();
request.on("close", () => {
controller.abort(new Error("客户端连接已关闭"));
});
const profile = await fetchJson<UserProfile>(
"https://profile.internal.example/users/42",
{
timeoutMs: 2_000,
maxAttempts: 3,
signal: controller.signal,
},
);
不要把未经校验的用户输入直接拼进 URL。路径参数应使用 encodeURIComponent,查询参数应使用 URL 和 URLSearchParams 构造。
测试超时与取消
可以使用 Node.js 内置测试运行器建立一个故意慢响应的服务,验证请求会在预算内终止:
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "node:test";
test("达到总预算后取消真实请求", async (context) => {
const server = createServer((_request, response) => {
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" });
response.end('{"ok":true}');
}, 1_000);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
context.after(() => server.close());
const address = server.address();
assert(address && typeof address === "object");
const startedAt = performance.now();
await assert.rejects(
fetchJson(`http://127.0.0.1:${address.port}`, {
timeoutMs: 100,
maxAttempts: 3,
}),
(error: unknown) =>
error instanceof Error && error.name === "TimeoutError",
);
assert(performance.now() - startedAt < 500);
});
这项测试不只断言异常类型,还断言耗时没有随着重试次数线性增加。实际项目还应补充:上游主动取消、429 后成功、400 不重试、退避期间取消、响应 JSON 不合法等用例。
非幂等请求的额外约束
GET、HEAD 等只读请求通常更适合自动重试。订单创建、扣款、发券等写操作可能已经在下游成功,只是响应在网络中丢失。对这类请求必须同时满足:
- 业务协议支持幂等键,例如
Idempotency-Key; - 下游以“调用方 + 幂等键”建立唯一约束;
- 重试使用同一个幂等键,不能每次生成新值;
- 幂等结果保留时间覆盖客户端最大重试窗口。
没有幂等保障时,不要仅因为遇到超时就自动重试写请求。
预防措施
- 把超时定义为端到端总预算,并为 DNS、连接、TLS、首字节等阶段补充可观测指标;
- 复用 HTTP 客户端的底层连接池,不在每次请求前后人为销毁全局调度器;
- 对重试次数、退避时间和可重试状态码建立统一策略;
- 为下游调用设置并发上限,避免慢服务耗尽本服务资源;
- 记录取消来源,但不要记录 Authorization、Cookie、令牌或完整敏感响应体;
- 监控
attempt、remaining_ms、取消数量和重试放大倍数; - 在故障演练中验证:上游取消后,下游活跃请求和连接数能够及时回落。
总结
Promise.race 能让调用方更早得到结果,却不能自动停止底层网络操作。可靠的超时治理必须让 AbortSignal 贯穿入口、重试循环、退避等待和真实 fetch 请求,并让所有尝试共享一个总预算。再配合有限重试、指数退避、随机抖动和写操作幂等,才能避免一次下游变慢被放大成连接堆积与重复请求。
Discussion
评论