# 修复 Node.js 代理下的 fetch failed 错误

Source: https://ipvolt.com/zh/guides/fix-node-fetch-failed-proxy
Markdown: https://ipvolt.com/zh/guides/fix-node-fetch-failed-proxy.md
Language: zh-CN

[ipvolt 首页](https://ipvolt.com/zh.md) / [指南](https://ipvolt.com/zh/guides.md) / 修复 Node.js 代理下的 fetch failed 错误

故障排查
审校于: 2026-09-24
发布于: 2026-09-24
阅读约 22 分钟
作者： ipvolt

解读代理后的 Node.js TypeError: fetch failed：打印 err.cause 链，修复被忽略的 HTTPS_PROXY 或 undici 版本错配，并读懂代理的 403、407 或 502。

`TypeError: fetch failed` 本身并不是那个错误。它是 Node.js 内置 `fetch`（undici）包在每一个网络层失败外面的包装。原因在 `err.cause` 里，有时还要再深一层。在代理后面，这些原因分为四组：

- **代理根本没被使用。** 内置 `fetch` 会忽略 `HTTPS_PROXY`，除非你用 `NODE_USE_ENV_PROXY=1`、`--use-env-proxy` 或 `http.setGlobalProxyFromEnv()` 显式启用；而 Node 26 会忽略由 undici 5、6 或 7.27.0 之前的 7 设置的全局 dispatcher。你会看到 `getaddrinfo ENOTFOUND`、一个写着目标站点地址的连接错误，或者一个代理从未记录过的正常响应。
- **undici 8 前后的版本错配。** 按请求传给 Node 26 `fetch` 的 undici 5 或 6 dispatcher，或者传给 Node 22 或 24 `fetch` 的 undici 8 dispatcher，会在任何字节到达代理之前以 `invalid onError method` 或 `invalid onRequestStart method` 失败。
- **代理拒绝了请求。** `Proxy response (NNN) !== 200 when HTTP Tunneling` 位于下面两层，带着代理的状态码，例如 403、407、429 或 502。在 undici 8.7 及更高版本上，纯 `http://` URL 的表现有所不同。
- **代理无法到达或出了故障。** 写着代理地址的 `ECONNREFUSED`、`ETIMEDOUT`、`UND_ERR_CONNECT_TIMEOUT` 或 `ENOTFOUND`，`UND_ERR_PRX_CONN`、`ECONNRESET`、某个 TLS 错误，或者根本没有错误。

打印整条链，在下面的表格中找到最深处的那个字符串，然后应用该层次的修复。除了标注为「据报告」的那一行之外，表中的每个错误字符串都是在 1,965 个回环实验用例中观察到的；这些用例由 ipvolt 于 2026 年 9 月 23 日和 24 日运行，使用 Node.js v22.23.3、v24.21.0 和 v26.10.0，以及 npm undici 5.29.0 到 8.11.0。

来自目标站点的 4xx 或 5xx 永远不会产生 `fetch failed`；它会 resolve 为一个 `Response`。浏览器的 `TypeError: Failed to fetch` 是另一个错误，针对的是 JavaScript 无法看到细节的网络和 CORS 失败。

## 「TypeError: fetch failed」在 Node.js 中的含义

undici 用 `new TypeError('fetch failed', { cause: response.error })` 来 reject 网络错误。[同一行代码](https://github.com/nodejs/undici/blob/v8.10.2/lib/web/fetch/index.js#L272)出现在 Node 22、24 和 26 各自捆绑的第一个和最新的 undici 中（六个版本，从 6.11.1 到 8.10.2）。cause 可以是带有稳定 `code` 的 undici 错误（例如 `UND_ERR_INVALID_ARG`）、Node.js 系统错误（例如 `ECONNREFUSED`），或者一个消息为 `Request was cancelled.` 的 `DOMException`，它把真正的错误再往下包了一层，实验中每一次被拒绝的 CONNECT 都是这样。你自己的 `AbortSignal.timeout()` 是例外：它以一个裸的 `TimeoutError` reject，没有 `fetch failed` 包装，也没有 cause。

## 打印完整的 cause 链

几种常见的错误记录方式都会在到达最深处的字符串之前停下。用实验中的 407 代理在三个运行时上测试，`err.message` 只显示 `fetch failed`，`err.cause.message` 停在 `Request was cancelled.`，而 `JSON.stringify(err)` 打印出 `{}`。`console.log(err)` 确实到达了 407，但带着堆栈跟踪，而且不会遮蔽任何内容。改用 [print-cause.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/print-cause.mjs) 遍历这条链，实验中的每个用例都是用这个打印器记录的：

```js
// print-cause.mjs: walk err.cause and report name, code and message at each depth.
// Reads only name, code, message, cause and an AggregateError's errors (never headers,
// bodies or stacks). Masks URL userinfo, Bearer tokens and key:value or key=value pairs
// whose key names a credential. Regex masking is not exhaustive: review before sharing.
const KEY = String.raw`[\w-]*(?:authorization|cookie|token|secret|passw(?:or)?d|api[-_]?key)[\w-]*`;
const mask = (s) => String(s)
  .replace(/\/\/[^/\s]*@/g, '//***@')
  .replace(/\bBearer\s+[\w.~+/-]+=*/gi, 'Bearer ***')
  .replace(new RegExp(String.raw`(["']?\b${KEY}["']?\s*[:=]\s*)(["']?)(?:(?:Basic|Bearer|Digest)\s+)?[^\s"'&,;]+`, 'gi'), '$1$2***');

export function causeChain(err, maxDepth = 10) {
  const chain = [];
  for (let e = err, depth = 0; e != null && depth < maxDepth; e = e.cause, depth++) {
    const entry = { depth, name: e.name ?? typeof e, code: typeof e.code === 'string' ? e.code : undefined, message: mask(e.message ?? e).slice(0, 300) };
    if (Array.isArray(e.errors)) entry.errors = e.errors.slice(0, 5).map((x) => `${x?.code ?? x?.name}: ${mask(x?.message ?? x).slice(0, 200)}`);
    chain.push(entry);
  }
  return chain;
}

export function printCauseChain(err, log = console.error) {
  for (const { depth, name, code, message, errors } of causeChain(err)) {
    log(`${'  '.repeat(depth)}[${depth}] ${name}${code ? ` (${code})` : ''}: ${message}${errors ? ` [${errors.join('; ')}]` : ''}`);
  }
}
```
在 `catch` 块中调用它，例如 `try { await fetch(url) } catch (err) { printCauseChain(err); process.exitCode = 1; }`。不要重新抛出 `err` 然后放任它不被捕获：那样 Node 会自己打印整个错误，包括未遮蔽的 `[cause]`，实验用一个一次性密码确认了这一点。下面是实验中的 407 代理在 Node v26.10.0 上、设置了 `NODE_USE_ENV_PROXY=1` 时的输出：

```text
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (407) !== 200 when HTTP Tunneling
```

分享输出之前先读一遍：遮蔽规则没有匹配到的内容，例如前面没有 scheme 的 `user:password@host`，会原样通过。

如果你的代码要根据错误分支，请匹配 `err.code`，而不是用 `instanceof`。undici 的[错误参考](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Errors.md)推荐这样做，因为「捆绑的（全局）dispatcher 可能来自与你直接导入的那个不同的 undici 版本」。对于 `UND_ERR_INVALID_ARG`，还要检查消息：在实验中它既可能表示版本错配，也可能表示纯 `http://` URL 上的代理 407。

## 查找你的错误：字符串、深度、层次与修复

深度 0 是 `TypeError: fetch failed`，除非某一行另有说明，否则每个字符串都位于深度 1。「代理无记录」表示实验代理没有看到这次 fetch 的任何连接。

### 在到达代理之前失败

| 你看到的内容 | 层次 | 修复 |
| --- | --- | --- |
| `invalid onError method`（`UND_ERR_INVALID_ARG`），代理无记录 | 交给 Node 26 `fetch` 的 undici 5 或 6 dispatcher | 改用 undici 7 或 8 的 dispatcher、undici 自带的 `fetch`，或者不传 dispatcher 并加上 `NODE_USE_ENV_PROXY=1` |
| `invalid onRequestStart method`（`UND_ERR_INVALID_ARG`），代理无记录 | 交给 Node 22 或 24 `fetch` 的 undici 8 dispatcher | `Dispatcher1Wrapper`、undici 8 自带的 `fetch` 或 `setGlobalDispatcher()`，或者 undici 7 的 agent |
| 写着目标站点的 `getaddrinfo ENOTFOUND`，代理无记录 | 代理根本没被使用；直连的 DNS 查询失败了 | 打开环境代理的显式启用选项。对于全局 dispatcher，把 undici 升级到 7.27.0+ 或 8.0.1+ |
| 写着代理主机的 `getaddrinfo ENOTFOUND` | 代理的主机名在这台机器上无法解析 | 检查代理 URL 中的主机，以及本应解析它的 DNS 或 VPN |
| 带目标站点地址的 `connect ECONNREFUSED` 或 `connect ETIMEDOUT`，代理无记录 | 代理根本没被使用；直连失败了 | 同上 |
| 列出目标站点地址的 `Connect Timeout Error`（`UND_ERR_CONNECT_TIMEOUT`），据 [undici#4960](https://github.com/nodejs/undici/issues/4960) 报告 | 同样的问题，由 undici 的连接超时报告 | 同上 |
| 没有错误且响应正常，代理无记录 | 代理被悄悄绕过了 | 同上 |
| Node 26 上的 `Setting the TLS ServerName to an IP address is not permitted`（`ERR_INVALID_ARG_VALUE`） | 带 IP 地址的 `https://` 代理 URL，Node 25 及更高版本拒绝这种写法 | 明文 HTTP 代理用 `http://`，或者使用 HTTPS 代理证书所覆盖的主机名 |

### 在代理处或代理之后失败

| 你看到的内容 | 层次 | 修复 |
| --- | --- | --- |
| 深度 1 `Request was cancelled.` 之下的深度 2 `Proxy response (NNN) !== 200 when HTTP Tunneling` | 代理以状态 NNN 拒绝了 CONNECT | 读 NNN：407 是凭据问题（[修复代理错误 407](/zh/guides/fix-proxy-error-407)），403 是它的策略，429 是它的速率限制，502 到 504 是代理处或代理之后出了问题 |
| `Proxy Authentication Required (407)`（`UND_ERR_INVALID_ARG`） | undici 8.7 或更高版本不经 CONNECT 转发的纯 `http://` URL 收到了 407 | 同样的凭据修复；这个 `UND_ERR_INVALID_ARG` 不是版本错配 |
| 没有错误，但 `response.status` 是 403、429、502 或 503，而源站从未记录该请求 | 转发的 `http://` URL 收到了代理自己的错误（undici 8.7 及更高版本） | 在响应体和响应头中查找代理的特征 |
| 带代理地址的 `connect ECONNREFUSED` | 代理的主机和端口上没有任何监听 | 检查主机、端口，以及代理是否在运行 |
| 消息为空、每个 IP 协议族各有一个被拒绝地址的 `AggregateError`（`ECONNREFUSED`） | 同上，针对 `localhost` 之类的代理主机名 | 同上 |
| 带代理地址的 `connect ETIMEDOUT` | 与代理的 TCP 握手从未完成 | 检查到代理的路由和防火墙 |
| 带代理地址的 `Connect Timeout Error`（`UND_ERR_CONNECT_TIMEOUT`） | 同样的故障，由 undici 的连接超时报告 | 同上 |
| `ERR_SSL_WRONG_VERSION_NUMBER`，消息中含 `wrong version number` | 代理 URL 写的是 `https://`，但代理讲的是明文 HTTP | 在代理 URL 中使用 `http://` |
| 深度 2 `other side closed` 之上的 `Proxy Connection failed`（`UND_ERR_PRX_CONN`） | 代理没有应答 CONNECT 就关闭了连接（undici 8.6 及更高版本） | 检查端口、协议，以及代理是否支持 CONNECT |
| `http://` URL 的 `other side closed`（`UND_ERR_SOCKET`） | 代理没有应答就关闭了一个转发的请求（undici 8.7 及更高版本） | 检查端口和协议，然后查看代理的日志 |
| 没有错误，fetch 永远不结束，而代理记录到一个接一个的 CONNECT | undici 8.5 及更早版本上的同一故障：重连循环 | 调用方的期限会把它变成 `TimeoutError`；undici 8.6 或更高版本会把它变成 `UND_ERR_PRX_CONN` |
| `Client network socket disconnected before secure TLS connection was established`（`ECONNRESET`） | 代理应答了 200，随后断开了隧道 | 往代理之后看：它的上游或目标站点 |
| `SELF_SIGNED_CERT_IN_CHAIN` 或 `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | 做 TLS 检查的代理出示了一个 Node 不信任的 CA 签发的证书 | 用 `NODE_EXTRA_CA_CERTS` 信任该 CA；如果它已在操作系统信任库中，则用 `--use-system-ca` |
| 大约五分钟没有错误，然后是深度 1 `Headers Timeout Error`（`UND_ERR_HEADERS_TIMEOUT`） | 代理接受了 CONNECT 但从未应答 | 设置你自己的期限 |
| 深度 0 `TimeoutError: The operation was aborted due to timeout`，没有 cause | 你的 `AbortSignal.timeout()` 触发了 | 在代理日志中查看卡住的阶段 |

在每一个真正走到注入代理行为的用例中（主矩阵 449 个，检查套件 803 个），实验仅凭字符串和代理计数推导出的层次都与该行为一致。

## 设置了 HTTPS_PROXY 但 fetch 忽略它（NODE_USE_ENV_PROXY）

设置了 `HTTPS_PROXY` 但没有显式启用时，实验中的三个运行时都直接连接，`HTTP_PROXY` 配合 `http://` URL 的表现也一样；不经代理也能到达的目标站点返回 200，而代理没有任何记录。Node 的[启动代码](https://github.com/nodejs/node/blob/v26.10.0/lib/internal/process/pre_execution.js#L314-L333)只有在显式启用打开、并且设置了 `HTTP_PROXY`、`HTTPS_PROXY`、`http_proxy` 或 `https_proxy` 之一时才会安装代理 dispatcher。`ALL_PROXY` 不算数。

| 显式启用方式 | 文档记载自 | 实验：v22.23.3 / v24.21.0 / v26.10.0 |
| --- | --- | --- |
| [`NODE_USE_ENV_PROXY=1`](https://nodejs.org/api/cli.html#node_use_env_proxy1) | v24.0.0、v22.21.0 | 三者都走了代理 |
| [`node --use-env-proxy`](https://nodejs.org/api/cli.html#--use-env-proxy) | v24.5.0、v22.21.0 | 三者都走了代理 |
| [`http.setGlobalProxyFromEnv()`](https://nodejs.org/api/http.html#httpsetglobalproxyfromenvproxyenv) | v24.14.0、v25.4.0 | 在 v22.23.3 上不是函数；另外两个走了代理 |
| npm undici `setGlobalDispatcher(new EnvHttpProxyAgent())` | 6.28.1 以及实验中的每个 undici 7 和 8 都有导出 | 走了代理，但 v26.10.0 搭配 6.28.1、7.16.0 或 7.26.0 时直连 |

- 更早的版本没有这个显式启用选项；在那些版本上改用 dispatcher 或 undici 自带的 `fetch`。
- 要带上 scheme。显式启用打开后，像 `127.0.0.1:<port>` 或 `localhost:<port>` 这样没有 `http://` 的值会让三个运行时在启动时就以 `TypeError: Invalid URL` 或 `Invalid URL protocol` 退出，任何应用代码都还没运行。文档只记载了 `http://` 和 `https://` 代理 URL；SOCKS5 在 Node 的[代理跟踪议题](https://github.com/nodejs/node/issues/57872)中仍是未决项。
- 按 CLI 文档的说法，Node 是「在启动期间」解析这些变量的，所以之后在代码中设置 `process.env.HTTPS_PROXY` 没有用。`http.setGlobalProxyFromEnv()` 是运行时的替代方案。
- 在 v22.23.3 上，显式启用会打印 `[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental`，这是一个稳定性提示，不是错误。
- 没有设置 `NO_PROXY` 时，连 `https://127.0.0.1` 也走了代理。`NO_PROXY` 豁免哪些内容因客户端而异；见 [NO_PROXY 匹配矩阵](/zh/blog/no-proxy-matching-tested)。
- 按请求传入的 `dispatcher` 会为它的那次请求接管显式启用的设置：把 `HTTPS_PROXY` 指向一个死端口、把 agent 指向可用的代理时，运行的每个请求都用了 agent。

在代理日志中，undici 8.7 及更高版本（包括 Node 26.5 及更高版本的环境代理）上的纯 `http://` URL 不显示 CONNECT，只显示绝对形式的请求本身，例如 `GET http://origin.test/…`。curl 和 Python 中的同名变量见[代理环境变量](/zh/guides/proxy-environment-variables)；显式的 `ProxyAgent` 见[在 Node.js fetch 中使用代理](/zh/guides/nodejs-fetch-proxy)。

## invalid onError method：Node 26 上的旧版 undici dispatcher

[undici 8.0.0](https://github.com/nodejs/undici/releases/tag/v8.0.0) 移除了旧版 handler 包装器并重命名了 handler 回调，例如把 `onError` 改为 `onResponseError`（[迁移指南](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/migrating-from-v7-to-v8.md)）。每个 Node 26 版本捆绑的都是 undici 8，所以它的 `fetch` 交给 dispatcher 的 handler 只有新的回调。在 undici 6 中，第一个失败的检查（例如 `invalid onConnect method`）会走到 [dispatch 错误路径](https://github.com/nodejs/undici/blob/v6.28.1/lib/dispatcher/dispatcher-base.js#L168-L196)，它调用 `handler.onError`；这个方法已经不存在了，所以 undici 抛出 `invalid onError method`，原始消息随之丢失。

作为 `dispatcher` 传给 v26.10.0 `fetch` 的 npm undici 5.29.0 和 6.28.1 `ProxyAgent` 在全部 18 个用例中都失败了，发生在任何连接之前，加上 `NODE_USE_ENV_PROXY=1` 也没有帮助：

```text
[0] TypeError: fetch failed
  [1] InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onError method
```

[xen-orchestra#10411](https://github.com/vatesfr/xen-orchestra/issues/10411) 报告了把 undici 6.28.1 的 `EnvHttpProxyAgent` 传给 Node 26 `fetch` 时出现的 `UND_ERR_INVALID_ARG`。

全局路线失败得更悄无声息。在 v26.10.0 上，来自 npm undici 5.29.0（搭配 `ProxyAgent`；该版本没有 `EnvHttpProxyAgent`）、6.28.1、7.16.0 或 7.26.0（搭配 `ProxyAgent` 或 `EnvHttpProxyAgent`）的 `setGlobalDispatcher()` 对内置 `fetch` 没有任何效果。请求直连，最终以 `ENOTFOUND` 或零次 CONNECT 的 200 结束，而同样的代码在 v22.23.3 和 v24.21.0 上走了代理。这些版本和其他检查过 `lib/global.js` 的 7.27.0 之前的 undici 7 标签（7.0.0、7.10.0 和 7.25.0）一样，只把全局 dispatcher 存在 `Symbol.for('undici.globalDispatcher.1')` 之下，而 v26.10.0 的 `fetch` 忽略了它。undici 7.27.0 于 2026-06-01 随 [PR #5319](https://github.com/nodejs/undici/pull/5319) 发布，同时写入 `.1` 和 `.2`，7.29.1 和 8.11.0 也是如此；用这三个版本，全局 dispatcher 在三个运行时上都走了代理。undici 6.28.1 的 `EnvHttpProxyAgent` 在 v26.10.0 上仍然打印了它的实验性警告，所以这个警告并不能证明 agent 正在被使用。

## invalid onRequestStart method：Node 22 或 24 上的 undici 8 dispatcher

按请求传给 v22.23.3 或 v24.21.0 内置 `fetch` 的 npm undici 8.11.0 `ProxyAgent` 在全部 18 个用例中都失败了，深度 1 是 `InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onRequestStart method`，同样发生在任何连接之前。Node 22 和 24 的 `fetch` 构建的是旧版 handler，而 undici 8 会[拒绝任何](https://github.com/nodejs/undici/blob/v8.10.2/lib/core/util.js#L567-L605)没有 `onRequestStart` 的 handler。[shadcn-vue#1959](https://github.com/unovue/shadcn-vue/issues/1959) 报告了一个在 Node 24 上捆绑 undici 8.10.2 的 CLI 出现的同一字符串。

在 v22.23.3 和 v24.21.0 上，用 `Dispatcher1Wrapper`（undici 8 为旧版使用者提供的、有文档记载的桥接）包装 agent 后到达了代理：

```js
import { Dispatcher1Wrapper, ProxyAgent } from 'undici'; // undici 8
const dispatcher = new Dispatcher1Wrapper(new ProxyAgent(proxyUrl));
const response = await fetch(url, { dispatcher });
```

来自 undici 8.11.0 的 `setGlobalDispatcher(new ProxyAgent(proxyUrl))` 也到达了代理，它还会在内置 `fetch` 读取的旧版槽位中存一份包装过的副本。undici 8.0.0 没有这么做，所以在 Node 24 和 25 上内置 `fetch` 忽略了它；[PR #4962](https://github.com/nodejs/undici/pull/4962) 于 2026-04-03 在 8.0.1 中修复了这一点。

## 选择修复方案：让 undici 与 process.versions.undici 匹配

先用 `node -p process.versions.undici` 检查运行时（[Node.js 文档](https://nodejs.org/api/globals.html#custom-dispatcher)）。根据每个发布标签处的 [Node 源码树](https://github.com/nodejs/node/blob/v26.10.0/src/undici_version.h)，每个 22.x 版本捆绑 undici 6，每个 24.x 版本捆绑 7，每个 26.x 版本捆绑 8。在实验中，来自 npm undici 5.29.0 或 6.28.1 的按请求 `dispatcher` 在 v22.23.3 和 v24.21.0 上到达了代理，但在 v26.10.0 上失败；来自 undici 7.16.0、7.26.0、7.27.0 或 7.29.1 的在三者上都到达了；来自 8.11.0 的只在 v26.10.0 上到达。修复方案及各自的代价如下：

1. **使用 undici 自带的 `fetch` 和它自己的 dispatcher。** 在实验中，四个主要 npm 版本在每个运行时上都能用。代价：多一个依赖，而且 `FormData` 之类的 body 类必须来自同一个包（[undici 文档](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/undici-vs-builtin-fetch.md)）。
2. **放弃自定义 dispatcher，使用运行时的显式启用选项。** 在每个具备该选项的运行时上它都走了代理，但它是进程级的，只覆盖 HTTP(S) 代理，而且 `NO_PROXY` 跟随捆绑的 undici。[Corepack 0.35.0](https://github.com/nodejs/corepack/releases/tag/v0.35.0) 走的就是这条路。
3. **匹配主版本。** 与 `process.versions.undici` 主版本相同的 npm undici 在三个运行时上都到达了代理。按请求传入的 undici 7 agent 也被三者全部接受，但那是一个快照，不是对未来 Node 版本线的承诺。
4. **来自 undici 7.27.0 或更高 7.x、或 8.0.1 及更高版本的 `setGlobalDispatcher()`。** 用 7.27.0、7.29.1 和 8.11.0，它在三个运行时上都走了代理，对进程中的每次 `fetch` 生效。在 Node 26 上用 undici 5、6 或 7.27.0 之前的 7 时它会被忽略，所以把 undici 升级到 7.27.0+ 或 8.0.1+。优先选最新的 7.x：2026 年 9 月 24 日，`npm audit` 也标记了 7.27.0。
5. **`Dispatcher1Wrapper`（undici 8）。** 它在三个运行时上按请求走了代理。
6. **[`install()`](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/GlobalInstallation.md)（undici 7.11.0 及更高版本）。** 它把 `globalThis.fetch` 和相关全局对象替换为 npm 副本。仅有文档记载；实验没有运行它。

## Proxy response (NNN) !== 200 when HTTP Tunneling

当代理对 CONNECT 的应答不是 200 时，代理的状态码只出现在深度 2：

```text
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (403) !== 200 when HTTP Tunneling
```

在实验中，每一种到达以 403、407、429、502 或 503 应答 CONNECT 的代理的接入方式都产生了带该状态码的这条链，每个状态码 49 个 `https://` 用例。按照 [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.6)，任何非 2xx 的应答都意味着隧道从未建立。undici 会[丢弃](https://github.com/nodejs/undici/blob/v8.10.2/lib/dispatcher/proxy-agent.js#L229-L233)应答的其余部分，包括 `Retry-After` 和响应体。要看到它们，通过同一个代理运行 curl，例如 `curl -v -x http://PROXY_HOST:PORT https://DESTINATION/ -o /dev/null`。针对实验中的 429 代理，curl 8.7.1 和 8.22.0 打印出了它的 `Retry-After: 30`。[curl 基线](/zh/guides/curl-proxy-setup)介绍了这套设置。

| 状态码 | 代理在说什么 | 去哪里查 |
| --- | --- | --- |
| 407 | 它需要代理凭据 | [修复代理错误 407](/zh/guides/fix-proxy-error-407) |
| 403 | 它的策略拒绝该请求；RFC 9110 要求代理把 CONNECT 限制在已知端口或允许的目标 | 代理针对这个目标站点和端口的允许规则 |
| 429 | 速率限制；单凭状态码看不出是谁的限制（[RFC 6585](https://www.rfc-editor.org/rfc/rfc6585#section-4)） | 放慢速度；用 `curl -v` 读取 `Retry-After` |
| 502 或 504 | 它的上游返回了错误的应答，或者没有及时应答 | 在预算内重试；从代理一侧检查目标站点 |
| 503 | 它暂时无法处理请求 | 稍后重试，遵守任何 `Retry-After` |

undici 对任何非 200 状态码都构造同样的消息。[代理错误详解](/zh/blog/proxy-status-codes-407-429-502)更深入地介绍了每个状态码。

纯 `http://` URL 的表现取决于 agent 属于哪个 undici。undici 8.6 及更早版本（包括 Node 22、24 和 26.0 到 26.4 的环境代理）发送 `CONNECT origin.test:80`，并以同样的链失败（在实验中，每条 undici 7.29.1 及更早的路径，每个状态码 33 个用例）。undici 8.7 及更高版本（包括 Node 26.5 及更高版本的环境代理）不经 CONNECT 直接转发请求本身（[PR #5116](https://github.com/nodejs/undici/pull/5116)）。在这些转发的请求中（每个状态码 16 个用例），407 以 `InvalidArgumentError (UND_ERR_INVALID_ARG): Proxy Authentication Required (407)` 的形式出现在下一层，与版本错配使用的是同一个 code。403、429、502 或 503 则完全不抛出：`fetch` 以代理的状态码和响应体 resolve，而源站从未看到该请求。

## ECONNREFUSED、ETIMEDOUT 或 ENOTFOUND：这是谁的地址？

后面跟着代理 IP 和端口的 `connect ECONNREFUSED`（49 个用例）表示那里没有任何监听。对于 `localhost` 之类的代理主机名，Node 会逐个尝试每个地址，改为报告一个消息为空的 `AggregateError`（49 个用例）：

```text
[0] TypeError: fetch failed
  [1] AggregateError (ECONNREFUSED):  [ECONNREFUSED: connect ECONNREFUSED ::1:<proxy-port>; ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:<proxy-port>]
```

如果地址是目标站点的，说明请求直连了：设置了 `HTTPS_PROXY` 但没有显式启用时，一个拒绝连接的目标站点在三个运行时上都产生了 `connect ECONNREFUSED 127.0.0.1:<dest-port>`。

`getaddrinfo ENOTFOUND` 也是同样的道理：读主机名。用代理 URL `http://proxy.invalid:3128`（一个永远不会解析的保留名称）时，每一种使用代理的接入方式都以深度 1 `getaddrinfo ENOTFOUND proxy.invalid` 失败（49 个用例），形状与绕过代理时的 `getaddrinfo ENOTFOUND origin.test` 相同。

后面跟着代理地址的 `connect ETIMEDOUT`（77 个用例）表示与代理的 TCP 握手从未完成；实验使用了一个 accept 队列已满的监听器。macOS 通常在大约 7.8 秒后放弃，早于 undici 的默认连接超时。被绕过的代理之后一个从不接受连接的目标站点产生了带目标站点地址的同一字符串。

## UND_ERR_CONNECT_TIMEOUT：是代理还是目标站点？

undici 的 `connectTimeout` 默认为 10 秒（[Client 文档](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Client.md)），而在 macOS 回环上通常是操作系统的超时先到。因此已发布的运行结果只在 undici 8.11.0 agent 设置了 `connectTimeout: 3000` 时显示 `UND_ERR_CONNECT_TIMEOUT`，大约 3.5 秒后出现（13 个用例）：

```text
[0] TypeError: fetch failed
  [1] ConnectTimeoutError (UND_ERR_CONNECT_TIMEOUT): Connect Timeout Error (attempted address: 127.0.0.1:<proxy-port>, timeout: 3000ms)
```

给了同一选项的 undici 5.29.0、6.28.1 和 7.29.1 `ProxyAgent` 仍然止于操作系统超时（28 个用例），因为它们只用 `proxyTls` 选项来建立代理连接。在 Linux 或真实网络上，undici 的 10 秒计时器可能更常胜出；实验没有测试这一点。undici 8.10.2 和 8.11.0 在主机名的每个地址都失败且其中一个超时时也会报告 `Connect Timeout Error`，不论哪个计时器先触发，消息中带配置的超时值，深度 2 是 `AggregateError`（[connect.js](https://github.com/nodejs/undici/blob/v8.10.2/lib/core/connect.js#L167-L190)）；实验使用的是单一地址，从未产生这种形式。

读消息中的地址。代理的地址表示代理无法到达；目标站点的地址表示请求根本没用代理，就像 [undici#4960](https://github.com/nodejs/undici/issues/4960) 中消息列出了六个目标站点地址那样。undici 6 及更高版本会打印 `attempted address:` 或 `attempted addresses:`；undici 5 只打印一个不带地址的裸 `Connect Timeout Error`，那就改为对照代理的日志。期限与重试预算见[代理超时排查](/zh/guides/proxy-timeout-troubleshooting)。

## ERR_SSL_WRONG_VERSION_NUMBER：给明文代理写了 https:// 代理 URL

明文 HTTP 正向代理照样在 CONNECT 隧道内承载 `https://` 目标站点，但以 `https://` 开头的代理 URL 会让客户端对代理本身发起 TLS。在实验中，明文 HTTP 代理对这次握手回复了 HTTP 400，每一种到达它的接入方式都以深度 1 `Error (ERR_SSL_WRONG_VERSION_NUMBER)` 和一条包含 `SSL routines:tls_validate_record_header:wrong version number` 的 OpenSSL 消息失败（85 个用例）。

`https://` 代理 URL 中写的是 IP 地址时，v26.10.0 上的 13 种接入方式在连接之前就以深度 1 `TypeError (ERR_INVALID_ARG_VALUE): The property 'options.servername' Setting the TLS ServerName to an IP address is not permitted.. Received '127.0.0.1'` 失败。Node 在 v25.0.0 中把 IP 地址形式的 TLS 服务器名变成了错误（[DEP0123](https://nodejs.org/api/deprecations.html#dep0123-setting-the-tls-servername-to-an-ip-address)）。两者的修复都是在代理 URL 中使用 `http://`，除非代理确实在那个端口上提供 TLS。

## UND_ERR_PRX_CONN：代理在应答 CONNECT 之前关闭了连接

有一个测试夹具接受 TCP 连接、读取 CONNECT，然后不回复就关闭。结果取决于代理 agent 属于哪个 undici：

- **undici 8.6 及更高版本**（在实验中，每个到达代理的 npm 8.11.0 agent 和 v26.10.0 的内置环境代理）：深度 1 `ProxyConnectionError (UND_ERR_PRX_CONN): Proxy Connection failed`，之下是深度 2 `SocketError (UND_ERR_SOCKET): other side closed`，在一次 CONNECT 之后（16 个用例）。
- **undici 8.5 及更早版本**（在实验中，每个到达代理的 npm 5、6 和 7 agent 以及 Node 22 和 24 的内置环境代理）：没有错误。客户端立即重连，直到测试框架在 25 次 CONNECT 时把它停下（33 个用例）。

从应用的角度看，这个循环就像挂起：`fetch` 永远不会结束。使用 `AbortSignal.timeout(2000)` 且不主动停止时，这些循环的客户端在 2 秒后以一个裸 `TimeoutError` reject，而在已发布的运行结果中，代理为这单个请求记录了 18,600 到 20,548 次 CONNECT（9 个用例，在回环上）。

对于纯 `http://` URL，undici 8.7 及更高版本不发送 CONNECT，所以同一故障表现为深度 1 `SocketError (UND_ERR_SOCKET): other side closed`（16 个用例），而 undici 7 及更早版本像上面那样循环（33 个用例）。

[PR #5441](https://github.com/nodejs/undici/pull/5441) 添加了 `ProxyConnectionError`，让这些请求失败而不是循环（[议题 #3897](https://github.com/nodejs/undici/issues/3897)）。这个类最早随 undici 8.6.0 发布，尽管 8.11.0 的错误参考把它标为 v8.10.1；v26.5.0 是第一个捆绑的 undici 包含它的 Node 26 版本。

## CONNECT 200 之后的 ECONNRESET：隧道被断开

当代理应答 200、随后在发送任何隧道字节之前关闭时，每一种到达它的接入方式（49 个用例）都在深度 1 报告了 `Client network socket disconnected before secure TLS connection was established`（`ECONNRESET`）。CONNECT 成功了，所以要往代理的大门之后看：它的上游、出口、目标站点，或者是代理断开了隧道。PR #5441 只覆盖隧道建立阶段，所以这种情况不会变成 `UND_ERR_PRX_CONN`。

## TLS 检查代理背后的 SELF_SIGNED_CERT_IN_CHAIN

在一个 CA 不被 Node 信任的 TLS 检查代理背后，这条链以证书错误结尾。实验中的替身对 CONNECT 应答 200，然后用一个一次性 CA 为目标站点出示自己的证书。每一种到达它的接入方式在三个运行时上都在深度 1 失败：代理连同其 CA 证书一起发送时是 `self-signed certificate in certificate chain`（`SELF_SIGNED_CERT_IN_CHAIN`）（49 个用例），只发送自己的证书时是 `unable to verify the first certificate`（`UNABLE_TO_VERIFY_LEAF_SIGNATURE`）（49 个用例）。

把 [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile) 指向一个包含该 CA 的文件修复了全部 98 个用例；Node 只在进程启动时读取它。如果该 CA 已经在操作系统的信任库中，Node 的 [`--use-system-ca`](https://nodejs.org/api/cli.html#--use-system-ca) 标志（自 v22.15.0 和 v23.8.0 起有文档记载）或 `NODE_USE_SYSTEM_CA=1`（自 v22.19.0 和 v24.6.0 起）会让 Node 也信任该信任库（[企业网络配置](https://nodejs.org/learn/http/enterprise-network-configuration)）；实验没有测试这些。不要用 `NODE_TLS_REJECT_UNAUTHORIZED=0` 关闭验证。

## 五分钟没有任何错误：设置你自己的期限

接受 CONNECT 但从不应答的代理在测试框架的 15 秒限制内没有产生任何错误（49 个用例）。在已发布的长时运行中，这 49 个用例全部在 301 到 302 秒后以 `HeadersTimeoutError (UND_ERR_HEADERS_TIMEOUT): Headers Timeout Error` 失败，这与 undici 文档记载的 300 秒 `headersTimeout` 默认值一致。使用 `signal: AbortSignal.timeout(2000)` 时，同样的接入方式在大约 2 秒后以一个裸 `TimeoutError` reject。给每个经代理的请求都传一个 signal。

## 当出错的代码是你没有编写的工具时

CLI、SDK 和 MCP 服务器经常自带 undici，并把它的 agent 交给内置 `fetch`，或者用 `setGlobalDispatcher()` 安装它。任何一方变动都会出现版本错配：Node 26 上的旧工具，或者在 Node 22 或 24 上采用了 undici 8 的工具。

1. 用该工具使用的同一个 `node` 二进制运行 `node -p process.versions.undici`。
2. 用 `npm explain undici` 找到工具的各个副本。在实验中，对于通过 npm 别名（`npm:undici@…`）安装的副本，`npm ls undici` 打印 `(empty)`，而 `npm explain` 列出了它们。编译进工具打包产物的副本两者都不显示；查看工具的变更日志或议题跟踪。
3. 先升级工具。如果它的文档支持 Node 的显式启用选项，使用 `NODE_USE_ENV_PROXY=1`。否则，临时在一个接受其副本的 Node 版本线上运行它（见上面的修复方案），并把打印出的链报告给上游。

以下是 ipvolt 没有复现的报告示例：

- [vercel/vercel#17629](https://github.com/vercel/vercel/issues/17629)，截至 2026-09-23 仍未关闭：Vercel CLI 捆绑的 undici 5.29.0 在设置了代理变量时于 Node 26.8.2 上以 `invalid onError method` 失败，在 Node 24.21.0 上正常。
- [nodejs/corepack#834](https://github.com/nodejs/corepack/issues/834)：捆绑的 undici 6 `ProxyAgent` 在 Node 26 上失败；Corepack 0.35.0 通过要求 `NODE_USE_ENV_PROXY=1` 修复了它。
- [openclaw#155840](https://github.com/openclaw/openclaw/issues/155840)：把 undici 8 dispatcher 传给某个依赖的 undici 7 WebSocket 时出现 `invalid onRequestStart method`；版本错配不限于 `fetch`。

## 方法、限制与下载

ipvolt 于 2026 年 9 月 23 日和 24 日在 macOS 15.7.4（arm64）上本地运行了这些检查，使用官方 Node.js v22.23.3、v24.21.0 和 v26.10.0 的 tarball，以及 npm undici 5.29.0、6.28.1、7.29.1 和 8.11.0，另加用于全局 dispatcher 检查的 7.16.0、7.26.0 和 7.27.0。每个用例在自己的子进程中运行一次 `fetch`，经过一个只有单一行为、会计数连接和请求的回环代理。大多数用例请求的是保留名称 `origin.test`（[RFC 6761](https://www.rfc-editor.org/rfc/rfc6761.html#section-6.2)），只有实验代理能解析它。主矩阵有 591 个用例，附加套件 282 个（`http://` URL、`localhost` 代理 URL、无法到达的目标站点），检查套件 1,029 个；另一次单独的 63 用例运行让从不应答的代理跑了 330 秒。[README](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/README.md) 包含完整方法、各套件结果和运行历史。

仅从最终归档出发、按照 README 进行的一次干净重跑，对全部 591 个主矩阵、282 个附加、1,029 个检查和 63 个长时运行用例给出了与已发布结果相同的分类。预计重跑会在少数握手永不完成的用例上有所不同，那里操作系统和 undici 的连接超时在竞争；如果 loop-deadline 检查连续运行，也会有差异：一个循环的用例会留下数千个处于 TIME_WAIT 状态的套接字，因此下一个用例在端口大约 40 秒后释放之前可能记录到零次 CONNECT。

实验没有覆盖 Linux 或真实网络、真实的 HTTPS 或 SOCKS 代理、接受凭据的代理、504、`NO_PROXY` 匹配、`http.request`、Deno、Bun 或真实的第三方工具。结果展示的是这些 Node.js 和 undici 版本如何报告每种注入的行为，而不是任何代理服务商的表现。lockfile 有意固定了旧的 undici 版本，`npm ci` 会把它们报告为高危漏洞；不要把这些固定版本复制到应用中。

所有文件都在 `https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/` 之下：

- [fix-node-fetch-failed-proxy-lab.zip](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/fix-node-fetch-failed-proxy-lab.zip) 包含下面的全部文件。
- [README.md](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/README.md) 说明如何运行实验和解读用例；[print-cause.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/print-cause.mjs) 是打印器。
- [run-matrix.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/run-matrix.mjs)、[run-checks.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/run-checks.mjs)、[cell.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/cell.mjs)、[cause-forms.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/cause-forms.mjs) 和 [compare.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/compare.mjs) 是测试框架。
- [get-runtimes.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/get-runtimes.mjs)、[runtimes.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/runtimes.json)、[package.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/package.json) 和 [package-lock.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/package-lock.json) 固定运行时和包。
- [results.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results.json)、[results.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results.csv)、[results-extra.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-extra.json)、[results-extra.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-extra.csv)、[results-long-hang.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-long-hang.json)、[results-long-hang.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-long-hang.csv)、[results-checks.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-checks.json) 和 [results-checks.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-checks.csv) 保存每个用例的 cause 链和代理计数。

要复现结果，解压归档并按照 README 操作：`npm ci`、`node get-runtimes.mjs`，然后运行每个套件，并用 `node compare.mjs` 对照已发布文件的副本。不需要代理账户。

如果你想在 ipvolt 开放访问时收到通知，请[加入早期访问名单](https://ipvolt.com/#waitlist-closing)。开放时只发一封邮件。别无其他。

## 参考来源与延伸阅读

- [Node.js HTTP: Built-in Proxy Support and http.setGlobalProxyFromEnv()](https://nodejs.org/api/http.html#built-in-proxy-support)
- [Node.js CLI: NODE_USE_ENV_PROXY=1 and --use-env-proxy](https://nodejs.org/api/cli.html#node_use_env_proxy1)
- [Node.js globals: fetch with a custom dispatcher and process.versions.undici](https://nodejs.org/api/globals.html#custom-dispatcher)
- [Node.js Learn: Enterprise network configuration](https://nodejs.org/learn/http/enterprise-network-configuration)
- [Node.js deprecations: DEP0123, setting the TLS ServerName to an IP address](https://nodejs.org/api/deprecations.html#dep0123-setting-the-tls-servername-to-an-ip-address)
- [undici 8.11.0: Errors reference](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Errors.md)
- [undici 8.11.0: ProxyAgent (CONNECT for https://, forwarding for http://)](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/ProxyAgent.md)
- [undici: Migrating from undici 7 to 8](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/migrating-from-v7-to-v8.md)
- [undici: Undici module vs. Node.js built-in fetch](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/undici-vs-builtin-fetch.md)
- [undici PR #4962: mirror the legacy global dispatcher for built-in fetch (v8.0.1)](https://github.com/nodejs/undici/pull/4962)
- [undici PR #5319: setGlobalDispatcher() writes both global-dispatcher slots, for Node 26 (v7.27.0)](https://github.com/nodejs/undici/pull/5319)
- [undici PR #5116: auto-detect HTTP proxy tunneling (v8.7.0)](https://github.com/nodejs/undici/pull/5116)
- [undici PR #5441: fail instead of looping when the proxy closes during CONNECT setup (UND_ERR_PRX_CONN, v8.6.0)](https://github.com/nodejs/undici/pull/5441)
- [RFC 9110: CONNECT and status codes 403, 407, 502, 503 and 504](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.6)
- [RFC 6585: section 4, 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585#section-4)

## 相关指南

- [在 Node.js fetch 中使用代理](https://ipvolt.com/zh/guides/nodejs-fetch-proxy.md)
- [代理环境变量：HTTP_PROXY 与 NO_PROXY](https://ipvolt.com/zh/guides/proxy-environment-variables.md)
- [一次一个阶段地排查代理超时](https://ipvolt.com/zh/guides/proxy-timeout-troubleshooting.md)

## 关于 ipvolt

示例使用通用的代理设置，并附上原始技术文档链接。具体产品的行为请向你的服务商确认。ipvolt 仍在开发中。

[阅读英文原文](https://ipvolt.com/guides/fix-node-fetch-failed-proxy.md)

## 了解何时开放体验。

ipvolt · 开发中

我们正在为开发者和数据团队打造代理基础设施。加入意向名单，ipvolt 就绪时第一时间收到通知。

开放体验时仅发一封通知邮件，不发送其他邮件。

[申请抢先体验](https://ipvolt.com/zh/guides/fix-node-fetch-failed-proxy#waitlist-closing)

[隐私政策](https://ipvolt.com/privacy)

