---
title: "Node.js fetch 超时：用 AbortSignal 把响应体也纳入截止时间"
date: 2026-11-12
slug: node-fetch-abort-deadline
canonical: https://kernelranch.com/blog/2026/11/node-fetch-abort-deadline
markdown_url: https://kernelranch.com/blog/2026/11/node-fetch-abort-deadline.md
tags: ["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`。服务绑定本地回环地址，由系统选择空闲端口。实验分别记录客户端结果和服务端是否发完正文。

```javascript
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();
}
```

本地实测输出：

```text
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(...)` 返回后取得解析结果：

```javascript
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 超时后的幂等处理](/blog/2026/11/post-idempotency-key.md)。

## 小结

把同一个信号传进请求，并在这次调用内读完正文。验证超时时同时看调用方异常和服务端连接状态，才能判断请求是否还在消耗资源。

## 数据来源与检索时间

检索时间 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 秒是人为设置的复现参数。实验覆盖本机直连，部署经过代理和多级上游时需在各层确认取消信号是否继续传播。
