Node.js 小记:fetch 的超时,别只管到响应头

老K 6 0
摘要:

服务端已经返回 200,JSON 正文却还没传完。我用两个本地慢接口,验证 Node.js fetch 的超时怎样覆盖响应头和正文读取,再把 HTTP 错误、解析失败分开处理。

[aru_2]最近几篇都在折腾脚本:防重复运行、保存状态文件。这次我换到 Node.js,补上请求接口时很容易漏掉的一步:超时到底管到哪里?

我想解决的场景很具体:服务端已经发回 200,但 JSON 正文还没传完。如果只等到 fetch() 返回就关掉计时器,后面的 response.json() 仍然可能继续等。

我在本机起了一个故意慢慢发数据的小服务,分别试“响应头慢”和“正文慢”,把复现代码和结果一起记下来。

Node.js 小记:fetch 的超时,别只管到响应头
本机实际测试结果生成的报告页面截图,6 项检查通过;不是线上监控或性能跑分。

fetch 返回了,正文不一定读完了

await fetch(url) 拿到的是 Response 对象。读取正文是接下来另一次异步操作,比如 await response.json()。所以我会把一次 JSON 请求看成两步:拿响应、读正文。

下面这段是我想避开的结构示意,url 代表调用方传入的接口地址:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 200);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer); // 此时正文可能还没有传完。
const data = await response.json();

如果服务端很快返回响应头,却把最后半截 JSON 留到 1.5 秒以后再发,200 毫秒的计时器就提前被清掉了。我用后面的 /slow-body 路径实际复现:正文最终完整返回,信号一直没有被取消。这不是正文突然变快,而是我设的超时已经失效。

先放一个只做 GET 的小函数

保存为 get-json.mjs。本轮在 macOS、Node.js v22.22.2 上运行,示例按 Node.js 22 环境写,使用内置 fetch,不需要安装第三方包。文件用 .mjs,可以直接使用 ES Module 语法。

export async function getJson(url, { timeoutMs = 3000 } = {}) {
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
    throw new RangeError('timeoutMs must be a positive integer');
  }
  const signal = AbortSignal.timeout(timeoutMs);
  try {
    const response = await fetch(url, { signal });
    if (!response.ok) {
      await response.body?.cancel();
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    // A timeout can happen while fetching headers or reading the body.
    if (signal.aborted) throw signal.reason;
    throw error;
  }
}

AbortSignal.timeout(timeoutMs) 返回一个到时会取消的信号。我把它交给 fetch,并且一直等到 response.json() 完成才返回。这里没有在拿到响应头后提前解除超时。

catch 里先看 signal.aborted,是因为读正文阶段发生取消时,底层抛出的错误名称可能与 fetch 阶段不同。这个函数只有自己的超时信号,因此取消后重新抛出 signal.reason,让调用方能统一识别超时;其他网络或 JSON 错误继续保留。

如果后面加了“用户主动取消”,就要再区分取消来源,不能把所有 AbortError 都算成超时。这个小函数目前只负责一次 GET,不包含重试、认证和响应结构校验。

本地造两个“慢接口”

下面保存为 demo-server.mjs。它只监听本机回环地址,避免为了测试去折腾真实接口。

import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';

export const server = createServer((request, response) => {
  const send = (status, text) => {
    response.writeHead(status, { 'Content-Type': 'application/json' });
    response.end(text);
  };
  if (request.url === '/ok') return send(200, '{"ok":true}');
  if (request.url === '/http-error') return send(503, '{"error":"busy"}');
  if (request.url === '/bad-json') return send(200, 'not json');
  let timer;
  if (request.url === '/slow-headers') {
    timer = setTimeout(() => send(200, '{"ok":true}'), 1500);
  } else if (request.url === '/slow-body') {
    response.writeHead(200, { 'Content-Type': 'application/json' });
    response.flushHeaders();
    response.write('{"ok":');
    timer = setTimeout(() => response.end('true}'), 1500);
  } else {
    return send(404, '{"error":"not found"}');
  }
  response.on('close', () => clearTimeout(timer));
});

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  server.listen(8787, '127.0.0.1', () => {
    console.log('Demo server: http://127.0.0.1:8787');
  });
}

/slow-headers 等 1.5 秒才发响应头;/slow-body 先把 200 响应头和半截 JSON 发出来,再等 1.5 秒补齐。后一个路径正好能检查超时有没有覆盖正文读取。

底部那段 import.meta.url 判断,只在直接执行这个文件时启动 8787 端口;测试程序导入它时,可以另外选空闲端口。

终端 A 启动服务,测试结束后按 Ctrl+C 关闭:

node demo-server.mjs

终端 B 新建 try-fetch.mjs,和前两个文件放在同一目录:

import { getJson } from './get-json.mjs';

const path = process.argv[2] ?? '/ok';
try {
  const data = await getJson(`http://127.0.0.1:8787${path}`, {
    timeoutMs: 300,
  });
  console.log(data);
} catch (error) {
  console.error(error.name, error.message);
  process.exitCode = 1;
}
node try-fetch.mjs /ok
node try-fetch.mjs /slow-headers
node try-fetch.mjs /slow-body
node try-fetch.mjs /http-error
node try-fetch.mjs /bad-json

正常路径能打印对象;两个慢路径都应该报告 TimeoutError;503 路径会得到 HTTP 503 错误;最后一个路径虽然返回 200,正文却不是合法 JSON,会在解析时抛 SyntaxError。后四条失败是这次演示预期的结果,进程会以非零状态结束。

HTTP 503 也要自己判断

fetch 收到 HTTP 错误状态,不会仅仅因为 404、503 就自动拒绝 Promise,所以函数里单独检查了 response.ok。我只想取成功的 JSON,遇到非成功状态便取消未读取的正文,再抛出带状态码的错误。

这个取舍会丢掉服务端的错误正文。如果业务需要其中的错误码或说明,应在同一个超时约束下读取、限制大小并妥善处理,而不是直接照搬取消正文这一步。

同样,200 也不保证内容符合我需要的数据结构。这里的 json() 只负责 JSON 解析,解析之后还要按业务验证字段。

我这轮具体跑了什么

测试服务和调用方都在本机运行,没有连接 WordPress 或其他生产接口。自动验证使用系统分配的空闲端口,慢接口延迟 1500 毫秒,超时检查设为 200 毫秒:

  • 正常 JSON 能读到完整对象。
  • 响应头迟到时,函数以 TimeoutError 失败。
  • 响应头已到但正文迟到时,也以 TimeoutError 失败。
  • HTTP 503 被识别为 HTTP 错误。
  • 200 配上无效 JSON,保留 SyntaxError。
  • timeoutMs 为 0 时,在发请求前拒绝参数并抛 RangeError。

6 项检查全部通过。配图是这些实际测试结果生成的本地报告页面截图,不是线上接口监控,也不是网络性能跑分。上面的手动命令用 300 毫秒,便于和 1500 毫秒的服务端延迟区分;并不是推荐所有接口都设成这个值。

超时以后,先别急着重发

这个函数只是 GET 演示。以后拿它的思路去处理发文章、创建订单之类的写请求时,我会先记住:客户端停止等待,不代表服务端撤销了已经收到的操作。

如果写入请求超时,应该先用业务标识、已有文章 ID 或后台查询确认结果,再决定要不要重试。给请求加了计时器,并不能顺便获得业务去重。

另外,这也不是硬实时计时器:事件循环阻塞时,取消信号可能晚些才被处理;超大正文的内存占用和解析耗时,还需要另外限制。这轮没有模拟 DNS、TLS 握手、断网和其他操作系统,结论只覆盖本地 HTTP 服务的这些路径。

我这次留下的检查顺序是:先看 HTTP 状态,再把正文读完;一次请求的等待边界,要从发起一直覆盖到需要的数据真正到手。

核对资料放在这里,方便以后回来查:

发表评论 取消回复
OwO 图片 链接 代码

分享