Node.js 20+:用 AbortSignal.timeout 给 fetch 设置真正的超时

136 次浏览8 条回复

用 Promise.race 包一层计时器,只会让调用方先返回,原来的 fetch 仍可能继续占着连接。Node.js 20+ 可以直接把 AbortSignal.timeout() 传给 fetch,超时后会取消请求。

环境:Node.js 20+;Linux、macOS 或 Windows;只用内置 API。保存为 timeout-demo.mjs:

import { createServer } from 'node:http';

const server = createServer((_request, response) => {
  setTimeout(() => response.end('late response'), 500);
});

server.listen(0, '127.0.0.1', async () => {
  const address = server.address();
  const url = `http://127.0.0.1:${address.port}`;

  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(100),
    });
    console.log(await response.text());
    process.exitCode = 1;
  } catch (error) {
    if (error.name !== 'TimeoutError') throw error;
    console.log('request timed out');
  } finally {
    server.close();
  }
});

运行 node timeout-demo.mjs,会输出 request timed out。这里的服务故意延迟 500 毫秒,客户端在 100 毫秒后中止。手动调用 AbortController.abort() 时常见的是 AbortError,超时信号则是 TimeoutError;实际代码最好按 name 区分,不要匹配错误文案。

再补一个常见场景:调用方本身也可能取消请求时,Node.js 20.3+ 可以用 AbortSignal.any() 把两个信号合起来,不用额外维护计时器:

async function getJson(url, { signal } = {}) {
  const timeout = AbortSignal.timeout(3000);
  const combined = signal
    ? AbortSignal.any([signal, timeout])
    : timeout;

  const response = await fetch(url, { signal: combined });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

这样超时仍会抛 TimeoutError,调用方执行 controller.abort() 则通常是 AbortError,同一个 catch 里可以继续按 name 区分。

还有个重试时容易踩的点:AbortSignal 是一次性的,超时后会一直保持 aborted,不能把同一个 timeout signal 复用到下一次尝试。每轮都新建一个比较稳:

async function readTextWithRetry(url) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch(url, {
        signal: AbortSignal.timeout(1000),
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.text();
    } catch (error) {
      if (error.name !== 'TimeoutError' || attempt === 2) throw error;
    }
  }
}

这里把 response.text() 也放在超时范围内,避免只限制等响应头、读取响应体却没有期限。

这个超时还有个容易误解的边界:中止的是客户端这次 fetch,不代表服务端一定停下处理。请求如果已经到达,服务端可能在客户端收到 TimeoutError 后继续完成写入。于是超时后自动重试 GET 通常还好,有副作用的 POST 不能只靠重试次数,最好带服务端可识别的幂等键并缓存结果;否则第一次其实成功、响应只是没回来时,第二次可能重复执行。

再补个边界:这个超时不是硬实时截止。AbortSignal.timeout(100) 到点后也要等事件循环有机会处理;如果主线程正被一段同步 CPU 计算卡住,fetch 不会在第 100 毫秒准时抛错。它适合限制网络等待,但不能给同步计算兜底,重 CPU 工作还是要拆分或放进 worker_threads。另外 timeout signal 最好在调用 fetch 前再创建,避免前面的排队或准备工作先消耗掉预算。

重试时还要留意总预算:每轮都用 AbortSignal.timeout(1000),三次请求加退避可能远超调用方预期。Node.js 20.3+ 可以再设一个总超时,并和每轮超时组合:

const total = AbortSignal.timeout(2500);

for (let attempt = 0; attempt < 3; attempt++) {
  const perAttempt = AbortSignal.timeout(800);
  try {
    return await fetch(url, {
      signal: AbortSignal.any([total, perAttempt]),
    });
  } catch (error) {
    if (total.aborted || error.name !== 'TimeoutError' || attempt === 2) {
      throw error;
    }
  }
}

两个超时都会抛 TimeoutError,所以不能只看错误名;先检查 total.aborted,才能判断是整次调用已没预算,还是这一轮可以继续。实际加退避等待时,也要让等待响应同一个总信号。

lunarLv1#5

重试时还要留意总预算:每轮都用 AbortSignal.timeout(1000),三次请求加退避可能远超调用方预期。Node.js 20.3+ 可以再设一个总超时,并和每轮超时组合:

const total = AbortSignal.timeout(2500);

for (let attempt = 0; attempt < 3; attempt++) {
  const perAttempt = AbortSignal.timeout(800);
  try {
    return await fetch(url, {
      signal: AbortSignal.any([total, perAttempt]),
    });
  } catch (error) {
    if (total.aborted || error.name !== 'TimeoutError' || attempt === 2) {
      throw error;
    }
  }
}

两个超时都会抛 TimeoutError,所以不能只看错误名;先检查 total.aborted,才能判断是整次调用已没预算,还是这一轮可以继续。实际加退避等待时,也要让等待响应同一个总信号。

退避等待可以直接用 Node 内置的 timers/promises,这样不用自己挂定时器:

import { setTimeout as delay } from 'node:timers/promises';

await delay(200, undefined, { signal: total });

环境同样是 Node.js 20+。总信号到期时,等待会立刻以 AbortError 结束;外层 catch 里先看 total.aborted 再决定是否重试即可。这样总预算不只约束 fetch,也把两次请求之间的退避算进去。

lunarLv1#5

重试时还要留意总预算:每轮都用 AbortSignal.timeout(1000),三次请求加退避可能远超调用方预期。Node.js 20.3+ 可以再设一个总超时,并和每轮超时组合:

const total = AbortSignal.timeout(2500);

for (let attempt = 0; attempt < 3; attempt++) {
  const perAttempt = AbortSignal.timeout(800);
  try {
    return await fetch(url, {
      signal: AbortSignal.any([total, perAttempt]),
    });
  } catch (error) {
    if (total.aborted || error.name !== 'TimeoutError' || attempt === 2) {
      throw error;
    }
  }
}

两个超时都会抛 TimeoutError,所以不能只看错误名;先检查 total.aborted,才能判断是整次调用已没预算,还是这一轮可以继续。实际加退避等待时,也要让等待响应同一个总信号。

还可以用 reason 的对象身份区分两个超时,不必等到 catch 时只看哪个信号处于 aborted。AbortSignal.any() 会保留最先触发信号的 reason:

const total = AbortSignal.timeout(2500);
const attempt = AbortSignal.timeout(800);
const combined = AbortSignal.any([total, attempt]);

try {
  return await fetch(url, { signal: combined });
} catch (error) {
  if (combined.aborted && error === combined.reason) {
    const scope = combined.reason === total.reason ? 'total' : 'attempt';
    console.error(`${scope} timeout`);
  }
  throw error;
}

环境是 Node.js 20.3+。这样即使进入 catch 时两个 signal 都已经超时,也能按最先传给组合信号的那个 reason 判断来源;非中止类错误仍原样抛出。

这个补法挺实用,不过我会再多留一句:AbortSignal.any() 里的 reason 更适合拿来做临时分流,不太适合当成长期稳定的业务语义。真正决定要不要继续重试,还是先看总预算那个 signal 有没有 aborted。不然两个超时最后都落成 TimeoutError,日志里很容易看着像同一类问题。