故障排查阅读约 11 分钟

Node.js fetch 忽略 HTTPS_PROXY:一行修复

Node 内置 fetch 会悄悄跳过 HTTP_PROXY 和 HTTPS_PROXY,导致请求以 UND_ERR_CONNECT_TIMEOUT 超时,而 curl 正常。本文说明原因及各 Node 版本的修复方法。

本页内容

你的脚本设置了 HTTPS_PROXY,调用 fetch(),等了十秒,然后以这样的错误退出:

code
node:internal/modules/run_main:107
    triggerUncaughtException(
    ^

[TypeError: fetch failed] {
  [cause]: ConnectTimeoutError: Connect Timeout Error (attempted address: shop.example.de:443, timeout: 10000ms)
      at onConnectTimeout (node:internal/deps/undici/undici:1991:23)
      at Immediate._onImmediate (node:internal/deps/undici/undici:1972:11)
      at process.processImmediate (node:internal/timers:574:21) {
    code: 'UND_ERR_CONNECT_TIMEOUT'
  }
}

Node.js v24.21.0

而在同一个 shell 中、使用同一个 HTTPS_PROXY 运行的 curl 却能顺利通过:

sh
curl -sS -o /dev/null -w '%{http_code}\n' https://shop.example.de/p/espressomuehle-k2
# 200

代理本身没有任何问题。除非你明确告诉它,否则 Node 内置的 fetch 不会读取 HTTP_PROXY 或 HTTPS_PROXY,所以它尝试直接连接店铺。在 Node 22.21 或更高版本以及 Node 24 及更高版本上,简短的修复是:

sh
NODE_USE_ENV_PROXY=1 node price-check.mjs

在更早的版本上(包括 Node 20),你需要改用 undici dispatcher。下面会介绍这两种方法,以及与之相关的陷阱。本页的每一条命令和输出都是在 2026 年 10 月 2 日针对一个会记录日志的测试代理运行得到的,所用的 Node 版本列在测试方法中。

超时的脚本

贯穿全文的示例是一个小任务:每小时通过住宅代理检查一家德国网店上某件商品的价格。shop.example.de 代替真实的店铺:

js
// price-check.mjs: log one product's price every hour.
const PRODUCT_URL = 'https://shop.example.de/p/espressomuehle-k2';
const ONE_HOUR = 60 * 60 * 1000;

async function checkPrice() {
  const res = await fetch(PRODUCT_URL, { signal: AbortSignal.timeout(30_000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const html = await res.text();
  const price = html.match(/itemprop="price" content="([\d.]+)"/)?.[1];
  console.log(new Date().toISOString(), price ? `${price} EUR` : 'price not found');
}

await checkPrice();
setInterval(() => checkPrice().catch((err) => console.error(err)), ONE_HOUR);

代理来自环境变量,这正是 curl、Python Requests 和大多数 CLI 工具所期望的方式。proxy.example.net、USERNAME 和 PASSWORD 是你的服务商网关和凭据的占位符;其中的保留字符要做百分号编码,并且要从密钥管理器中加载真实值,而不是把它们输入到 shell 历史中:

sh
export HTTPS_PROXY='http://USERNAME:PASSWORD@proxy.example.net:8080'
node price-check.mjs

在测试的每个 Node 版本上(从 20.20.2 到 26.10.0),这次运行都从未联系过代理。它在大约 10.6 秒后以上面的错误失败。Node 20 和 22 打印的是同样的 [cause],但它位于一行带堆栈跟踪的 TypeError: fetch failed 之下,而不是方括号形式。

为什么 Node 的 fetch 会忽略 HTTPS_PROXY

Node 内置的 fetch 就是 undici。它的默认 dispatcher 会直接连接 URL 所指定的任何主机。只有在你显式启用时,才会读取代理变量:PR #57165 于 2025 年以 NODE_USE_ENV_PROXY 为开关加入了这项支持,其说明把这种显式启用称为第一步,默认开启则留待以后。

所以请求直接发往 shop.example.de:443。在只有代理才能访问互联网的网络中,这次连接尝试得不到任何应答,undici 在 10 秒的连接超时之后放弃,报出 UND_ERR_CONNECT_TIMEOUT。读一下消息中的地址:attempted address: shop.example.de:443 指的是店铺,所以代理从未被使用。如果消息里写的是你的代理主机,那就是代理本身无法到达,这是另一个问题;修复 Node.js 代理下的 fetch failed 错误 解读了这种情况以及其他 cause 字符串。

在允许直连流量的地方,根本不会出现错误。在测试中,没有显式启用时,对一台本机可以直接访问的主机发起的 fetch 返回了 200,而代理没有任何记录。对价格检查来说,这意味着店铺看到的是你服务器自己的地址,而不是代理的地址。

一行修复:NODE_USE_ENV_PROXY=1 或 --use-env-proxy

为进程打开 Node 内置的代理支持:

sh
NODE_USE_ENV_PROXY=1 node price-check.mjs
# 2026-10-02T12:35:44.149Z 49.90 EUR

命令行标志的作用相同,而且在 NODE_OPTIONS 中也有效:

sh
node --use-env-proxy price-check.mjs
NODE_OPTIONS=--use-env-proxy node price-check.mjs

使用其中任何一个,每个支持它的已测试版本都向代理发送了 CONNECT shop.example.de:443,带上了 URL 中的凭据,并打印出价格。这两个开关是在不同版本中加入的:

显式启用方式加入版本(Node.js 文档)测试结果
NODE_USE_ENV_PROXY=1v24.0.0、v22.21.0在 22.21.0、22.23.3、24.0.0、24.4.1、24.5.0、24.21.0 和 26.10.0 上走了代理。在 20.20.2 和 22.20.0 上被悄悄忽略,像之前一样超时
--use-env-proxyv24.5.0、v22.21.0在 22.21.0、22.23.3、24.5.0、24.21.0 和 26.10.0 上走了代理。Node 20.20.2、22.20.0、24.0.0 和 24.4.1 拒绝启动:bad option: --use-env-proxy
NODE_OPTIONS=--use-env-proxy列在 NODE_OPTIONS 允许的选项之中与该标志相同。更早的版本会以 --use-env-proxy is not allowed in NODE_OPTIONS 退出

企业网络配置指南补充说,从 v22.21.0 和 v24.5.0 起,同一个开关也会让 node:http 和 node:https 请求走代理。这与测试一致:https.get 在 22.21.0 和 24.5.0 上走了代理,而在只覆盖 fetch 的 24.0.0 和 24.4.1 上仍然直连。

测试中踩过的坑,免得你再踩:

  • 在 Node 启动之前设置变量。 Node 在启动时读取它们。一个自己给 process.env.HTTPS_PROXY 赋值、然后调用 fetch 的脚本,在每个版本上都直连了。
  • 代理 URL 中要保留 scheme。 使用 HTTPS_PROXY=127.0.0.1:3128 时,进程在运行任何代码之前就以 TypeError: Invalid URL(ERR_INVALID_URL)退出。带凭据但没有 scheme 时,它以 Invalid URL protocol 错误(UND_ERR_INVALID_ARG)退出。
  • 为 https:// URL 设置 HTTPS_PROXY。 只设置了 HTTP_PROXY 时,在 22.23.3、24.21.0 和 26.10.0 上,fetch 访问 HTTPS 店铺时仍然使用了它,但 https.get 直连了。HTTPS_PROXY 对两者都有效。
  • 把显式启用放在命令行上,而不是放在 --env-file 里。 Node 会从 .env 文件中读取 HTTPS_PROXY,但同一文件中的 NODE_USE_ENV_PROXY=1 在 22.21.0、22.23.3、24.5.0、24.21.0 和 26.10.0 上都不起作用。只有在 24.0.0 和 24.4.1 上,它写在文件里才有效。代理放在 .env 中、标志放在命令行上时,每个具备该标志的版本都走了代理:
sh
node --env-file=.env --use-env-proxy price-check.mjs
  • 在 Node 22 上会看到一条警告。 22.21.0 和 22.23.3 打印了 [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.,但请求仍然经过了代理。
  • 407 是凭据问题,不是这个问题。 密码错误时,往下两层的错误是 Proxy response (407) !== 200 when HTTP Tunneling。修复代理错误 407 介绍了这种情况。

Node 20 及更早版本:让 fetch 经由 undici dispatcher

Node 20 没有内置的显式启用选项,而且根据 Node.js 发布时间表,它已于 2026 年 4 月 30 日结束生命周期。22.20 及更早版本同样没有内置的显式启用选项。升级才是真正的修复。在那之前,从 npm 安装 undici,并把它的 EnvHttpProxyAgent 设为全局 dispatcher。它和内置支持一样,读取 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY,大小写均可:

sh
npm install undici@7
js
// proxy-setup.mjs: send the built-in fetch through HTTP(S)_PROXY on older Node.
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';

setGlobalDispatcher(new EnvHttpProxyAgent());

在脚本之前加载它,这样 price-check.mjs 就无需改动:

sh
node --import ./proxy-setup.mjs price-check.mjs

使用 undici 7.30.0 时,这种方式在全部九个已测试版本上(从 20.20.2 到 26.10.0)都走了代理。如果你只想让某一次调用走代理,就把 agent 作为按请求的 dispatcher 传入:

js
import { EnvHttpProxyAgent } from 'undici';

const dispatcher = new EnvHttpProxyAgent();
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { dispatcher });
console.log(res.status);

ProxyAgent 接受一个代理 URL,并让每个请求都经过它。与 EnvHttpProxyAgent 不同,它不读取 NO_PROXY:

js
import { ProxyAgent } from 'undici';

const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { dispatcher });
console.log(res.status);

在每个已测试版本上,两者都经由代理打印出 200,凭据取自 URL。

选择 undici 主版本时要小心。根据其 package.json,undici 7 要求 Node 20.18.1 或更高版本。如果改用 undici 6.29.0,三种接入方式在 Node 20、22 和 24 上都能工作,但在 26.10.0 上不行:全局 dispatcher 被忽略,请求直连;按请求传入的 undici 6 dispatcher 则以 UND_ERR_INVALID_ARG 失败。undici 8 的 dispatcher 在 Node 22 和 24 上有相反的问题,这记录在 fetch failed 指南中,那里解释了这种版本错配。在 Node 22.21 及更高版本、以及 24 及更高版本上,NODE_USE_ENV_PROXY=1 完全不需要任何依赖。

一个常见的弯路:https-proxy-agent

搜索 Node 代理,你会找到 https-proxy-agent。它是供 node:http、node:https 以及基于它们构建的库使用的 agent。内置的 fetch 没有 agent 选项,传入了也会被悄悄忽略:

js
import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { agent });
console.log(res.status);

在任何已测试版本上都没有警告。请求直连,并在大约 10.6 秒后以 UND_ERR_CONNECT_TIMEOUT 失败,就好像根本没有这个 agent 一样。同一个 agent(https-proxy-agent 9.1.0)用在它该用的地方是有效的。下面两段代码在全部九个版本上都经由代理打印出 200:

js
import https from 'node:https';
import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
https.get('https://shop.example.de/p/espressomuehle-k2', { agent }, (res) => {
  console.log(res.statusCode);
  res.resume();
});
js
import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { agent });
console.log(res.status);

第二段使用的是 node-fetch 包(3.3.2),而不是内置的 fetch。如果你用的是内置版本,请使用 NODE_USE_ENV_PROXY 或 undici dispatcher。

NO_PROXY,以及空的小写变量陷阱

一旦显式启用,NO_PROXY 就列出不经过代理的主机。运行价格检查的主机旁边通常还有一些不应经过付费住宅代理的东西,例如本地数据库 API 或健康检查。在测试中,没有设置 NO_PROXY 时,连发往 http://127.0.0.1 的请求也走了代理。

下面是 fetch 在不同 NO_PROXY 取值下访问 https://shop.example.de/ 所走的路由,测试环境为设置了 NODE_USE_ENV_PROXY=1 的 Node 22.23.3、24.21.0 和 26.10.0,以及使用上面 undici 7.30.0 设置的 Node 20.20.2:

NO_PROXYshop.example.de 的路由
localhost,127.0.0.1代理
shop.example.de、.example.de 或 *.example.de直连
shop.example.de:443直连
shop.example.de:8443代理(端口不匹配)
*直连
example.de在 20(undici 7.30.0)、24.21.0 和 26.10.0 上直连;在 22.23.3 上走代理

Node 的 HTTP 文档把 example.com 描述为精确的主机匹配。https.get 在 24.21.0 和 26.10.0 上遵循了这一点并走了代理,而同样版本上的 fetch 则把 example.de 视为覆盖了 shop.example.de。nodejs/node#65616 跟踪这一差异。名称按书写形式比较,所以 NO_PROXY=localhost 并没有豁免 http://127.0.0.1。列出你确切指的主机;两者都需要时,同时写上 localhost 和 127.0.0.1。

Node 的文档说,大小写两种变量都设置时,小写的优先。当小写变量已设置但为空时,情况就变得古怪了,例如 shell 配置文件里遗留的一行 export https_proxy=,或者 CI、容器配置中的一个空值。nodejs/node#66202 报告说,fetch 和 http.request() 对空值的理解不同。下面的脚本用两种方式发送同一个请求:

js
// which-route.mjs: send the same request with https.get and with fetch.
import https from 'node:https';

const url = 'https://shop.example.de/p/espressomuehle-k2';
const viaGet = await new Promise((resolve) => {
  const req = https.get(url, { timeout: 15_000 }, (res) => resolve(res.statusCode));
  req.on('timeout', () => req.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));
  req.on('error', (err) => resolve(err.code));
});
const viaFetch = await fetch(url, { signal: AbortSignal.timeout(15_000) })
  .then((res) => res.status, (err) => err.cause?.code ?? err.name);
console.log({ viaGet, viaFetch });

在测试网络中,200 表示请求经过了代理,超时则表示请求直连了。在 22.21.0、22.23.3、24.5.0、24.21.0 和 26.10.0 上设置 NODE_USE_ENV_PROXY=1 时:

环境https.getfetch
设置了 HTTPS_PROXY代理(200)代理(200)
设置了 HTTPS_PROXY,https_proxy=''代理(200)直连(UND_ERR_CONNECT_TIMEOUT)
设置了 HTTPS_PROXY,no_proxy='',NO_PROXY='*'直连(ETIMEDOUT)代理(200)

fetch 把空的小写变量视为已设置,所以 https_proxy='' 会为它关闭代理,no_proxy='' 会抵消 NO_PROXY。https.get 把空值视为未设置,并回退到大写变量。Node 20 上 undici 7.30.0 的 EnvHttpProxyAgent 表现得和 fetch 一样。该议题中的纯 HTTP 版本也以同样的方式复现了。截至 2026 年 10 月 2 日,该议题仍未关闭,而提议的修复未经合并就被关闭了,所以两种理解都不要依赖。应当改为取消设置这些空变量。下面的命令会打印进程能看到哪些代理变量,但不打印它们的值:

sh
node -e "for (const k of ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY', 'no_proxy', 'NO_PROXY']) console.log(k, process.env[k] === undefined ? 'unset' : process.env[k] === '' ? 'EMPTY' : 'set')"
unset https_proxy no_proxy

在与任务相同的环境中运行这项检查,例如 cron 条目、systemd unit 或容器,而不只是在你的终端里。关于 curl、Python 和 Node 各自如何读取这些变量,请参阅代理环境变量。

哪个 Node 版本用哪种修复

Node.js捆绑的 undici(已测试版本)用什么
20.x(已结束生命周期)6.24.1(20.20.2)通过 --import ./proxy-setup.mjs 使用 undici 7 的 EnvHttpProxyAgent,然后升级
22.0 到 22.206.21.2(22.20.0)undici 7 的 dispatcher,或者更新到最新的 22.x
22.21 及更高版本6.22.0 到 6.28.1(22.21.0、22.23.3)NODE_USE_ENV_PROXY=1 或 --use-env-proxy
24.0 到 24.47.8.0 到 7.11.0(24.0.0、24.4.1)NODE_USE_ENV_PROXY=1;该标志尚不存在
24.5 及更高版本7.12.0 到 7.29.1(24.5.0、24.21.0)NODE_USE_ENV_PROXY=1 或 --use-env-proxy
26.x8.10.2(26.10.0)NODE_USE_ENV_PROXY=1 或 --use-env-proxy

Node 18 及更早版本,以及奇数版本线,均未测试。用 node -v 检查你的版本,并且要使用任务实际运行的同一个二进制文件。

价格检查的完整流程

脚本就是开头的那个文件。只有启动方式不同。在 Node 22.21 或更高版本,或 24 及更高版本上:

sh
export HTTPS_PROXY='http://USERNAME:PASSWORD@proxy.example.net:8080'
export NO_PROXY='localhost,127.0.0.1'
NODE_USE_ENV_PROXY=1 node price-check.mjs
# 2026-10-02T13:20:12.538Z 49.90 EUR

在 Node 20 上,安装 undici@7 并把 proxy-setup.mjs 放在脚本旁边:

sh
node --import ./proxy-setup.mjs price-check.mjs
# 2026-10-02T13:20:11.398Z 49.90 EUR

进程会保持运行,setInterval 每小时重复一次检查。在测试中,代理为这次检查记录了一条经过认证的 CONNECT shop.example.de:443,而店铺收到的请求不带 Proxy-Authorization 请求头。仅凭一个 200 并不能证明路由:请在服务商的控制台或请求日志中确认,或者让脚本访问一次你控制的、会报告调用方 IP 地址的端点。

测试方法

2026 年 10 月 2 日,在一台 Ubuntu VPS(linux-x64)上,使用官方 Node.js tarball v20.20.2、v22.20.0、v22.21.0、v22.23.3、v24.0.0、v24.4.1、v24.5.0、v24.21.0 和 v26.10.0,每个都对照其公布的 SHA-256 做了校验。npm 包:undici 7.30.0 和 6.29.0、https-proxy-agent 9.1.0 和 node-fetch 3.3.2。curl 8.18.0。

每个用例都作为独立进程,在单独的 Linux 网络命名空间中运行。在那里,shop.example.de 解析为 203.0.113.10(一个保留给文档使用的地址),发往非回环地址的每个数据包都会被丢弃,所以直接连接只会超时。通往店铺的唯一路由是一个回环正向代理,它要求 Basic 认证,并记录每一个 CONNECT 和转发的请求。在它后面,一台本地 HTTPS 服务器以一张由一次性 CA 签发的证书代表 shop.example.de 应答;进程通过 NODE_EXTRA_CA_CERTS 信任该 CA,curl 则通过 CURL_CA_BUNDLE 信任它。测试凭据是虚构的,上文用占位符替换了它们。只有当代理为该主机记录到一条经过认证的请求时,一条路由才算作“代理”。

测试没有覆盖 macOS 或 Windows(其环境变量名不区分大小写)、HTTPS 或 SOCKS 代理、真实服务商的网络、Docker,以及 Node 18、23 和 25。计时数据来自该命名空间,并不能说明真实代理的速度。

相关指南

ipvolt 是一项面向开发者的代理服务,目前尚未开放;加入早期访问名单,开放时你会收到一封邮件。

参考来源与延伸阅读

本指南参考的技术资料。请以你所安装版本的文档以及代理服务商支持的配置为准。