KernelRanch
返回文章列表

Node.js fetch 超时:用 AbortSignal 把响应体也纳入截止时间

2026-11-12 #nodejs#fetch#timeout#reliability

接口很快返回了 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-abortsignalAbortSignal.timeout() 和 AbortSignal.any()
Node.js HTTP 文档https://nodejs.org/docs/latest-v24.x/api/http.htmlflushHeaders()、响应 close 与 writableFinished
WHATWG Fetch 标准https://fetch.spec.whatwg.org/#fetch-methodfetch 与响应体的取消语义

核验说明

  • 在 Node.js v24.19.0 本地 HTTP/1.1 环境运行实验,断言两个请求都收到 200,并分别检查响应体完成与连接提前关闭。
  • 300 毫秒和 1.2 秒是人为设置的复现参数。实验覆盖本机直连,部署经过代理和多级上游时需在各层确认取消信号是否继续传播。