Node.js 设置代理

Node.js 的每个 HTTP 客户端配置代理的方式都不同,有的读环境变量 HTTP_PROXY,有的从来不读。本文按客户端分别给出一份可以直接保存运行的完整示例:内置 fetch 和 undici、axios、got,以及 SOCKS5。所有示例都用以 http:// 开头的代理地址访问 https:// 目标,这正是 Node4 代理的工作方式:到代理这一跳是明文 HTTP,用 CONNECT 升级成隧道。最后是每次接入都应该做的直连对比检查,防止流量在您不知道的情况下直接从本机发出。基础概念见快速入门,返回 407 见代理返回 407 怎么办。

准备工作

  • Node.js 版本要和 undici 的大版本对应,以 undici 在 npm 上声明的 engines 为准:undici 7 要求 Node.js 20.18.1 及以上,undici 8 要求 22.19.0 及以上,undici 6 要求 18.17 及以上。本文示例在 Node.js 24.14 上分别用 undici 7 和 undici 8 运行过,建议先装 undici 7(npm install undici@7),它和 Node.js 20、22、24 自带的 undici 都兼容。
  • 按所用客户端安装依赖:npm install undici@7;或 npm install axios http-proxy-agent https-proxy-agent;或 npm install got http-proxy-agent https-proxy-agent(got 16 要求 Node.js 22 及以上,且只提供 ES 模块);SOCKS5 再加 npm install socks-proxy-agent。
  • 示例文件以 .mjs 结尾,按 ES 模块(import)运行,不需要改 package.json。CommonJS 项目见文末“CommonJS 项目怎么改”,不同库的做法不同。
  • 控制台里的账号密码和代理地址。动态网关、住宅网关和 SOCKS5 需要对应的付费套餐,免费套餐只有固定 IP 的 HTTP(S)。免费套餐和固定 IP 产品把该 IP 所在行的地址和端口填进 HOST:PORT 即可运行全部 HTTP(S) 示例。
  • 把代理地址放在环境变量 NODE4_PROXY_URL 里(形式 http://USERNAME:PASSWORD@HOST:PORT),示例启动时读取,缺失就抛错。Node4 生成的用户名和密码只含字母、数字、下划线和连字符,可以原样写进地址。

操作步骤

  1. 内置 fetch 和 undici:ProxyAgent

    目的:让 fetch 走代理并拿到出口 IP。依赖:undici。Node 内置的 fetch 底层就是 undici,undici 的 ProxyAgent 是官方支持的代理方式。把下面的文件保存为 undici-fetch.mjs,设置 NODE4_PROXY_URL 后运行 node undici-fetch.mjs,应打印 200 和一个不是本机的 IP 地址。

    示例从 undici 导入 fetch,而不是用 Node 自带的。原因:内置 fetch 运行在 Node 自带的那份 undici 上,npm 装的 undici 大版本不同时,把它的 ProxyAgent 传给内置 fetch 可能失败。实测(2026-11-06,Node.js 24.14,自带 undici 7):装 undici 7 时内置 fetch 也能接受 dispatcher;装 undici 8 时内置 fetch 报 invalid onRequestStart method,而 undici 自己的 fetch 两种情况都正常。其他版本组合不一定相同,统一从 undici 导入 fetch 就不必逐个确认。dispatcher 也是 undici 的扩展而不是 fetch 标准的一部分,TypeScript 的 DOM 类型不认识它,undici 的 fetch 带正确的类型。

    只想让进程里所有 fetch 都走代理,用 setGlobalDispatcher(proxyAgent),之后不传 dispatcher 的 fetch 也走代理;这条全局路径同样受上面的版本对应关系约束。密码含特殊字符时,可以不写进地址,改用 token 选项直接给出请求头的值。没有 dispatcher 的裸 fetch 不读 HTTP_PROXY,除非 Node 以 NODE_USE_ENV_PROXY=1 或 --use-env-proxy 启动。用完调用 proxyAgent.close() 释放连接,否则脚本会等到空闲连接自己关闭才退出。

    字段填写内容
    预期输出200 203.0.113.7 这样的一行:状态码加出口 IP,IP 不是本机的公网 IP
    全局方式import { ProxyAgent, setGlobalDispatcher } from "undici"; setGlobalDispatcher(new ProxyAgent(proxyUrl));
    token 选项new ProxyAgent({ uri: "http://HOST:PORT", token: "Basic " + Buffer.from("USERNAME:PASSWORD").toString("base64") })
    失败时407:见下方链接的 407 排查;ECONNREFUSED 或超时:核对地址和端口,见连接排查;invalid onRequestStart method:确认 fetch 是从 undici 导入的,或把 undici 换成和 Node 自带版本相同的大版本
    undici-fetch.mjs
    // undici-fetch.mjs  (Node.js 20.18.1+, npm install undici@7)
    import { fetch, ProxyAgent } from "undici";
    
    const proxyUrl = process.env.NODE4_PROXY_URL; // http://USERNAME:PASSWORD@HOST:PORT
    if (!proxyUrl) throw new Error("NODE4_PROXY_URL is not set");
    
    const proxyAgent = new ProxyAgent(proxyUrl);
    const res = await fetch("https://api.ipify.org", {
      dispatcher: proxyAgent,
      signal: AbortSignal.timeout(30_000),
    });
    console.log(res.status, await res.text());
    await proxyAgent.close();
  2. axios:关掉自带的 proxy,换成显式 agent

    axios 自带的 proxy 选项在各大版本之间行为变过多次,而且用 http:// 代理访问 https:// 目标时长期存在不建隧道、直接发错请求类型的问题。改用一对显式 agent。保存为 axios-proxy.mjs 运行,应打印两组 200 和 IP:一次访问 https:// 目标(走 CONNECT 隧道),一次访问 http:// 目标(代理直接转发)。两个都查,是因为只给 httpsAgent 不给 httpAgent 时 http:// 的请求会直连出去,而只测 https:// 发现不了。

    proxy: false 做两件事:禁用自带的代理路径,同时阻止 axios 读取环境变量 HTTP_PROXY 和 HTTPS_PROXY,它默认会读。timeout 以毫秒计,必须设。SOCKS5 见第 4 步。

    字段填写内容
    预期输出200 <IP> 200 <IP>,两个 IP 都不是本机的;固定 IP 产品两个相同,动态网关两个可能不同
    失败时报 407 的 AxiosError:认证问题;EPROTO 或 ERR_SSL_WRONG_VERSION_NUMBER:代理地址写成了 https://,改回 http://
    axios-proxy.mjs
    // axios-proxy.mjs  (npm install axios http-proxy-agent https-proxy-agent)
    import axios from "axios";
    import { HttpProxyAgent } from "http-proxy-agent";
    import { HttpsProxyAgent } from "https-proxy-agent";
    
    const proxyUrl = process.env.NODE4_PROXY_URL; // http://USERNAME:PASSWORD@HOST:PORT
    if (!proxyUrl) throw new Error("NODE4_PROXY_URL is not set");
    
    const client = axios.create({
      proxy: false,
      httpAgent: new HttpProxyAgent(proxyUrl),
      httpsAgent: new HttpsProxyAgent(proxyUrl),
      timeout: 30_000,
    });
    const https = await client.get("https://api.ipify.org");
    const http = await client.get("http://api.ipify.org");
    console.log(https.status, https.data, http.status, http.data);
  3. got:agent 对象

    got 不自带代理支持,也不读环境变量,维护者的立场是代理属于 agent 层的事,所以配置方式稳定。got 16 要求 Node.js 22 及以上,而且只提供 ES 模块。保存为 got-proxy.mjs 运行,应打印一个不是本机的 IP。

    got 自带的重试在代理场景下有用:网络错误和可重试的状态码会按递增间隔重试,默认只重试幂等方法;用 retry.limit 限制次数,不要放开方法限制。按状态码重试时通常复用原来的连接,也就是原来的出口;网络错误后的重试要新建连接,出口可能变。

    字段填写内容
    预期输出一行 IP 地址,不是本机的公网 IP
    失败时ERR_REQUIRE_ESM 或 require() of ES Module:项目是 CommonJS,见文末问答;RequestError 带 407:认证问题;ECONNREFUSED:地址或端口不对
    got-proxy.mjs
    // got-proxy.mjs  (Node.js 22+, npm install got http-proxy-agent https-proxy-agent)
    import got from "got";
    import { HttpProxyAgent } from "http-proxy-agent";
    import { HttpsProxyAgent } from "https-proxy-agent";
    
    const proxyUrl = process.env.NODE4_PROXY_URL; // http://USERNAME:PASSWORD@HOST:PORT
    if (!proxyUrl) throw new Error("NODE4_PROXY_URL is not set");
    
    const client = got.extend({
      agent: { http: new HttpProxyAgent(proxyUrl), https: new HttpsProxyAgent(proxyUrl) },
      timeout: { request: 30_000 },
      retry: { limit: 2 },
    });
    console.log(await client("https://api.ipify.org").text());
  4. SOCKS5:socks-proxy-agent

    三个 agent 包的名字含义不同。http-proxy-agent 和 https-proxy-agent 的名字说的是目标 URL 的协议:前者用于 http:// 目标,后者用于 https:// 目标,两者要的代理地址都以 http:// 开头,因为 Node4 到代理这一跳是明文 HTTP。socks-proxy-agent 的名字说的则是代理协议:它和代理之间说 SOCKS,一个实例可以同时作为 httpAgent 和 httpsAgent。

    走 SOCKS5 时地址写 socks5h://(域名在出口解析),端口用该产品的 SOCKS5 端口,以控制台接入信息区域切换到 SOCKS5 后显示的为准。付费产品都支持 SOCKS5,免费套餐没有。下面的写法按 socks-proxy-agent 的文档,未在本文的测试环境中运行。

    字段填写内容
    失败时SOCKS5 认证失败没有 407 状态码,表现为连接被拒;先用同一产品的 HTTP 端口试一次定位问题
    socks-axios.mjs
    // socks-axios.mjs  (npm install axios socks-proxy-agent)
    import axios from "axios";
    import { SocksProxyAgent } from "socks-proxy-agent";
    
    const socksUrl = process.env.NODE4_SOCKS_URL; // socks5h://USERNAME:PASSWORD@HOST:SOCKS_PORT
    if (!socksUrl) throw new Error("NODE4_SOCKS_URL is not set");
    
    const agent = new SocksProxyAgent(socksUrl);
    const client = axios.create({ proxy: false, httpAgent: agent, httpsAgent: agent, timeout: 30_000 });
    const { data } = await client.get("https://api.ipify.org");
    console.log(data);
  5. agent 只建一次,并复用

    agent 在模块顶层创建一次。每个新 socket 都要经过到代理的 TCP 连接、CONNECT 往返和与目标的 TLS 握手,agent 存在的意义就是建一次隧道然后复用。在请求处理函数里 new 一个 agent,高负载下会泄漏 socket,表现为零散的 ECONNRESET 和超时。undici 的 ProxyAgent 会自动按源站做连接池,同样的规则:一个实例,共享。https-proxy-agent 接受标准的 http.Agent 选项,例如 keepAlive: true 和 maxSockets: 20。

    动态和住宅代理的用户请注意出口怎么分配:访问 HTTPS 网站时,每个 CONNECT 隧道使用一个出口 IP,隧道复用期间出口不变;SOCKS5 按连接分配出口;普通 HTTP 请求(http:// 目标)逐次选择出口,即使在同一个长连接上。所以 agent 复用隧道时 HTTPS 的出口不会变;要重新选择出口就新建连接(不复用 agent 或关闭 keepAlive),重新选择后仍可能分到之前的 IP。需要一段时间尽量保持同一出口时,在用户名后加 -session-<id>。详见动态代理 IP 不变怎么办。

哪些客户端读 HTTP_PROXY

HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 是约定,不是标准。内置 fetch:默认不读,Node 以 NODE_USE_ENV_PROXY=1 或 --use-env-proxy 启动时才读(两者在启动时读取,Node 24.14 已提供;请查您所用版本的发布说明,不要假定开着)。undici:需要主动用 EnvHttpProxyAgent 作为 dispatcher。axios:默认读,proxy: false 关闭。got:按其政策不读。已弃用的 request 包:读,很多旧经验来自它。

可靠的做法是把环境变量的处理放在程序边缘并显式化:自己读一个变量,缺了就在启动时抛错,用它建一个 agent,本文每个示例开头的两行就是这样做的。启动时抛错好过另一种结果:每一个请求都悄悄带着您自己机器的 IP 发出去。动态数据中心代理的 HTTP 网关是 gw-rotating_datacenter.node4.io:8083,可直接作为 HOST:PORT 使用,用户名后加 -country-us 等参数指定国家。

确认流量走了代理

每次接入都以同一个出口检查收尾:同一个 IP 回显地址直连请求一次、走代理请求两次,对比出口 IP。下面的脚本用 undici 的 fetch,直连传一个普通 Agent 作为 dispatcher,走代理传 ProxyAgent,三次请求都加 AbortSignal.timeout;走代理的两次分别访问 https:// 和 http:// 目标。每次响应先查状态码,再用 node:net 的 isIP 确认正文是一个 IP 地址:状态码不是 200、正文是错误提示、HTML 或空白,都直接抛错,不会被当成出口 IP。三个 IP 都拿到后,任何一次和直连相同就抛错 traffic is not leaving through the proxy;两个 agent 在 finally 里关闭,无论检查成败都不会留下未关闭的连接。这个检查只证明到 IP 回显服务这一条路走了代理,不代表某个目标网站一定接受该代理,也不代表之后的每个请求都用了同一份配置。

把它接进启动流程或 CI。它能抓住环境变量没有到达、axios 只设了 httpsAgent 没设 httpAgent(http:// 那次会暴露)、依赖升级改变了代理行为这几类问题。固定 IP 产品每次都应回显同一个地址;动态网关的两次代理请求回显不同地址是正常的。检查应该和程序用同一份配置来源(同一个环境变量、同一个 agent 工厂),单独写死一个代理地址的检查验证不了程序本身。

TLS 校验在您的进程里对目标进行,代理只从 CONNECT 行知道域名,看不到加密内容。所以 UNABLE_TO_VERIFY_LEAF_SIGNATURE 之类的错误说的是目标的 TLS 校验没过:目标配置有误、缺中间 CA,或者您网络里有设备在重签流量。NODE_TLS_REJECT_UNAUTHORIZED=0 会关掉整个进程的 TLS 校验,不要带它上线。

版本说明(最近验证 2026-11-06):以上 undici、axios、got 和对比检查四个示例在 Node.js 24.14.0 上通过本地代理运行通过,依赖版本 undici 7.30.2 和 8.12.4、axios 1.21.0、got 16.2.0、http-proxy-agent 和 https-proxy-agent 9.1.0;SOCKS5 示例和无头浏览器部分未在该环境验证。

verify-proxy.mjs
// verify-proxy.mjs  (npm install undici@7)
// Egress check: proves the route to an IP-echo service goes through the proxy.
// It does not prove that any other target accepts the proxy.
import { isIP } from "node:net";
import { Agent, fetch, ProxyAgent } from "undici";

const proxyUrl = process.env.NODE4_PROXY_URL;
if (!proxyUrl) throw new Error("NODE4_PROXY_URL is not set");

const direct = new Agent();
const proxied = new ProxyAgent(proxyUrl);

// Only HTTP 200 with an IP address as the whole body counts.
// An error page, an HTML page or an empty body is a failure, not an exit IP.
const egressIp = async (url, dispatcher) => {
  const res = await fetch(url, { dispatcher, signal: AbortSignal.timeout(30_000) });
  const body = (await res.text()).trim();
  if (res.status !== 200) throw new Error(`${url}: HTTP ${res.status} ${body.slice(0, 80)}`);
  if (!isIP(body)) throw new Error(`${url}: body is not an IP address: ${body.slice(0, 80)}`);
  return body;
};

try {
  const mine = await egressIp("https://api.ipify.org", direct);
  const viaHttps = await egressIp("https://api.ipify.org", proxied);
  const viaHttp = await egressIp("http://api.ipify.org", proxied);
  console.log({ mine, viaHttps, viaHttp });
  if (mine === viaHttps || mine === viaHttp) throw new Error("traffic is not leaving through the proxy");
} finally {
  await Promise.all([direct.close(), proxied.close()]);
}

常见问题

报 EPROTO、packet length too long 或 ERR_SSL_WRONG_VERSION_NUMBER?

代理地址写成了 https:// 开头,agent 对一个明文端口发起了 TLS 握手。改成 http://。https-proxy-agent 的名字指的是目标是 https,它要的代理地址仍是 http://。

axios 用了 proxy 选项,https 网站请求失败?

axios 自带的 proxy 选项对 https 目标不可靠。设 proxy: false,同时给 httpAgent 和 httpsAgent 两个显式 agent。只给 httpsAgent 的话,http:// 的 URL 会直连出去,用第 2 步示例里的两次请求能看出来。

fetch 传了 dispatcher 报 invalid onRequestStart method?

内置 fetch 和 npm 装的 undici 大版本不一致,实测的组合是 Node.js 24.14(自带 undici 7)加 npm 的 undici 8。两种改法:import { fetch } from "undici",用 undici 自己的 fetch;或者把 undici 装成和 Node 自带相同的大版本(node -p process.versions.undici 可以查看)。

CommonJS 项目怎么改?

按库分别处理,没有对所有库都成立的一句话。undici 提供 CommonJS 入口,const { fetch, ProxyAgent } = require("undici") 即可(Node.js 24.14 实测);axios、http-proxy-agent、https-proxy-agent 和 socks-proxy-agent 同样可以 require。got 16 只提供 ES 模块:在 Node.js 24.14 上 require("got") 已能直接加载(实测),更早的 Node.js 22 版本请用 const got = (await import("got")).default 动态导入,或者把项目改为 ES 模块。请以您所用 Node.js 版本的 require(esm) 支持情况为准。

为什么连续请求 IP 都一样,动态代理不换 IP?

agent 在复用隧道:访问 HTTPS 网站时每个 CONNECT 隧道一个出口 IP,隧道复用期间出口不变。要重新选择出口,就新建连接(不复用 agent 或关闭 keepAlive),并接受每次都重复进行握手的开销;重新选择后仍可能分到之前的 IP。普通 HTTP 请求(http:// 目标)则逐次选择出口。详见动态代理 IP 不变怎么办。

本地能通,部署到容器或 CI 后不通?

先看环境变量:axios 会读 HTTP_PROXY 和 HTTPS_PROXY,残留的旧值会覆盖代码;内置 fetch 可能因为 NODE_USE_ENV_PROXY 的差异行为不同。再看容器网络:DNS、出站规则和 MTU,见代理连接失败或超时怎么排查。

Puppeteer 或 Playwright 怎么配代理?

和本文的 HTTP 客户端不同。Chromium 的代理命令行参数本身不携带账号密码。Playwright 请在 launch 或 newContext 的 proxy 选项里传入 server、username 和 password,由 Playwright 回答代理的认证挑战;不要把代理账号密码填进 httpCredentials,那是给目标网站自己的 HTTP 认证用的。Puppeteer 在启动参数里只传代理地址,账号密码在每个页面导航前用 page.authenticate 提供。两者的完整示例在英文帮助中心的 Puppeteer and Playwright 文章里。