Node.js fetch 超时:用 AbortSignal 把响应体也纳入截止时间
接口很快返回了 200,调用方却在 response.json() 卡住。fetch() 可以在响应头到达后就完成,响应体仍在路上。把超时信号传给 fetch(),并在同一个预算里读取正文,才能覆盖这段等待。只给 Promise.race() 加计时器,会让调用方提前收到错误,底层请求仍可能继续。
1. 让上游先发响应头,再等 1.2 秒发正文
把下面的代码存为 fetch_deadline_lab.mjs,用 Node.js 24 执行 node fetch_deadline_lab.mjs。服务绑定本地回环地址,由系统选择空闲端口。实验分别记录客户端结果和服务端是否发完正文。
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
const observations = new Map();
const server = createServer((req, res) => {
const observe = observations.get(req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.flushHeaders();
const timer = setTimeout(() => res.end('{"ok":true}'), 1200);
res.once('close', () => {
clearTimeout(timer);
observe?.(res.writableFinished ? 'body finished' : 'closed before body finished');
});
});
function observeClose(path) {
const { promise, resolve } = Promise.withResolvers();
observations.set(path, resolve);
return promise;
}
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const origin = `http://127.0.0.1:${server.address().port}`;
try {
const raceClosed = observeClose('/race');
const raceResponse = await fetch(`${origin}/race`, { signal: AbortSignal.timeout(5000) });
assert.equal(raceResponse.status, 200);
const raceBody = raceResponse.json();
const raceTimeout = new Error('race deadline');
await assert.rejects(
Promise.race([raceBody, sleep(300).then(() => { throw raceTimeout; })]),
error => error === raceTimeout,
);
assert.deepEqual(await raceBody, { ok: true });
assert.equal(await raceClosed, 'body finished');
console.log('race: headers=200, caller timed out, server sent body');
const abortClosed = observeClose('/abort');
const signal = AbortSignal.timeout(300);
const abortResponse = await fetch(`${origin}/abort`, { signal });
assert.equal(abortResponse.status, 200);
await assert.rejects(abortResponse.json(), { name: 'TimeoutError' });
assert.equal(await abortClosed, 'closed before body finished');
console.log('abort: headers=200, body rejected, server saw early close');
} finally {
server.close();
server.closeAllConnections();
}
本地实测输出:
race: headers=200, caller timed out, server sent body
abort: headers=200, body rejected, server saw early close
第一组的 5 秒信号给演练兜底,触发的是 300 毫秒的 Promise.race。它超时后,原来的 json() 仍成功拿到正文。第二组的 300 毫秒信号在调用 fetch() 前创建。收到 200 后,正文读取仍受这个信号约束。服务端观察到提前关闭,close 回调清除了尚未运行的发送计时器。
2. 在服务调用里传递同一份预算
如果上层已经有请求取消信号,把它和本次上游调用的时间预算合并。调用方只在 await fetchJson(...) 返回后取得解析结果:
async function fetchJson(url, { timeoutMs, callerSignal }) {
const deadline = AbortSignal.timeout(timeoutMs);
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
const response = await fetch(url, { signal });
if (!response.ok) {
await response.body?.cancel();
throw new Error(`upstream HTTP ${response.status}`);
}
return await response.json();
}
| 观察到的情况 | 应用怎么处理 |
|---|---|
| 响应头尚未到达时超时 | 从 fetch() 捕获异常,记录上游、耗时和预算 |
| 收到状态码后正文读取超时 | 从 json() 捕获异常,诊断时同时记录已收到的上游状态码 |
| 上游返回非 2xx | 按 HTTP 错误处理。若无需错误正文,消费或取消正文流 |
| 上层主动取消 | 继续传播取消。上层请求已结束时停止安排后续重试 |
对同一次请求的多次上游尝试,共用整体截止时间,或在每次尝试前计算剩余预算。每次重新给足两秒,三次尝试就可能消耗接近六秒。AbortSignal.timeout() 依赖事件循环执行,CPU 密集型的同步解析不能靠它抢占。对于大响应,还要限制正文大小,或按流处理。
服务端收到断连后能否停止工作,取决于它是否把取消继续传给数据库和下游。已提交的写入仍然生效。需要重试写操作时,可参考 POST 超时后的幂等处理。
小结
把同一个信号传进请求,并在这次调用内读完正文。验证超时时同时看调用方异常和服务端连接状态,才能判断请求是否还在消耗资源。
数据来源与检索时间
检索时间 2026-11-12。
| 来源 | URL | 对应内容 |
|---|---|---|
| Node.js 24.19.0 全局对象文档 | https://nodejs.org/download/release/v24.19.0/docs/api/globals.html#class-abortsignal | AbortSignal.timeout() 和 AbortSignal.any() |
| Node.js HTTP 文档 | https://nodejs.org/docs/latest-v24.x/api/http.html | flushHeaders()、响应 close 与 writableFinished |
| WHATWG Fetch 标准 | https://fetch.spec.whatwg.org/#fetch-method | fetch 与响应体的取消语义 |
核验说明
- 在 Node.js v24.19.0 本地 HTTP/1.1 环境运行实验,断言两个请求都收到
200,并分别检查响应体完成与连接提前关闭。 - 300 毫秒和 1.2 秒是人为设置的复现参数。实验覆盖本机直连,部署经过代理和多级上游时需在各层确认取消信号是否继续传播。