Node.js fetch with a proxy: undici, environment variables and the errors

Node’s fetch ignores your proxy variables, the obvious fix breaks on the current undici, and a refused proxy just says “fetch failed”. All three, measured.

6 min readTested on Node 22 and 24 · undici 6, 7 and 8
Node's fetch reports a refused proxy as fetch failed; the proxy's status is only in err.cause.cause.message.The answer you need is two causes deeperr.messagefetch failederr.cause.messageRequest was cancelled.err.cause.cause.messageProxy response (407) !== 200 when HTTP Tunnelinginvalid onRequestStart methodnpm undici 8 agent + Node’s built-in fetch: the versions don’t mixNode 22 and 24 · undici 6, 7, 8

In short

Import fetch and ProxyAgent from the same package, undici, and pass the agent as dispatcher. NODE_USE_ENV_PROXY=1 makes the built-in fetch follow HTTPS_PROXY. When it fails, the proxy’s status is in err.cause.cause.message.

undici fetch + ProxyAgenterr.cause.cause

Node’s fetch is built on undici, which ships inside Node. It doesn’t read proxy variables on its own: with HTTPS_PROXY set, a plain fetch() came from our own IP on Node 22 and on Node 24. What did go through the proxy:

SetupNode 22Node 24
fetch(url), HTTPS_PROXY setown IPown IP
NODE_USE_ENV_PROXY=1, HTTPS_PROXY setproxy (with an “experimental” warning)proxy
setGlobalDispatcher(new EnvHttpProxyAgent())proxyproxy
undici fetch(url, { dispatcher: new ProxyAgent(…) })proxyproxy
built-in fetch + ProxyAgent from npm undici 8invalid onRequestStart methodinvalid onRequestStart method
Fig. 1 — Node 22.23 and 24.21 against via.proxies.vision:9000, October 14, 2026. 'Own IP' means the proxy was not used.

For a whole process: the environment

Start Node with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY=http://USER:PASS@via.proxies.vision:9000. Every fetch then uses the proxy, no code change. Node 24 does this quietly; Node 22.23 did it too, with a warning that the feature is experimental. From inside your program, setGlobalDispatcher(new EnvHttpProxyAgent()) from undici does the same; it worked with undici 6, 7 and 8.

Per request: a dispatcher

For different proxies or different IPs per request, pass a ProxyAgent as dispatcher. Use undici’s fetch together with it, which brings us to the error most people hit first.

npm i undici installs version 8 today. Its ProxyAgent, handed to Node’s built-in fetch, fails before anything is sent:

txt
TypeError: fetch failed
  [cause]: InvalidArgumentError: invalid onRequestStart method { code: 'UND_ERR_INVALID_ARG' }

The built-in fetch is the undici bundled with Node: 6.28 in Node 22, 7.29 in Node 24. Version 8 talks to it in a way those don’t understand. We tried every combination: npm undici 6 and 7 worked with the built-in fetch on both Node versions, 8 failed on both. The clean fix is to take fetch from the same package as the agent:

invalid onRequestStartfetch (built-in) + ProxyAgent from undici 8two undici versions
200import { fetch, ProxyAgent } from "undici"one version

When the proxy refuses the tunnel, fetch throws TypeError: fetch failed. Its cause says only Request was cancelled., the same for a wrong password, an unreadable username and a filter no exit matches. The status is one level further down:

txt
err.message              fetch failed
err.cause.message        Request was cancelled.
err.cause.cause.message  Proxy response (407) !== 200 when HTTP Tunneling

What the number means

407: the login. 403 on our gateway: a value in the username it can’t read, such as a three-letter country code or a session label on the rotating port. 465: no exit matches your filters. The same codes in more detail, with Python examples, are in the requests article.

Our rotating port picks a new exit for every connection, and a ProxyAgent keeps its connection open:

1 IPone ProxyAgent, 3 requeststhe connection is reused
3 IPsa new ProxyAgent per requesta new connection each

Want one IP on purpose, for a login? Add a session label and use the sticky port: USER-session-checkout1-lifetime-10 on via.proxies.vision:10000.

A setup that works

undici’s fetch with a ProxyAgent per request, the proxy’s status read out, retries only where they help. Ran as shown on Node 24.

proxy_fetch.mjs
// npm i undici: use undici's own fetch with its ProxyAgent, so the versions match.
import { fetch, ProxyAgent } from "undici";

const USER = process.env.PROXY_USER;
const PASS = process.env.PROXY_PASS;

// One ProxyAgent keeps its connection, so it keeps one IP. A new agent, a new IP.
function agent(targeting = "") {
  const login = `${encodeURIComponent(USER + targeting)}:${encodeURIComponent(PASS)}`;
  return new ProxyAgent(`http://${login}@via.proxies.vision:9000`);
}

// The proxy's status is two causes deep: "Proxy response (407) !== 200 when HTTP Tunneling".
function proxyStatus(err) {
  const match = /Proxy response \((\d+)\)/.exec(err?.cause?.cause?.message ?? "");
  return match ? Number(match[1]) : null;
}

async function get(url, targeting = "", attempts = 3) {
  for (let i = 1; ; i++) {
    try {
      return await fetch(url, { dispatcher: agent(targeting), signal: AbortSignal.timeout(30000) });
    } catch (err) {
      const status = proxyStatus(err);
      // 407 and 403 won't change on a retry; dropped exits and 465 might.
      if (status === 407 || status === 403 || i === attempts) {
        throw new Error(status ? `proxy refused with ${status}` : "request failed", { cause: err });
      }
    }
  }
}

for (let i = 0; i < 3; i++) {
  const data = await (await get("https://ipinfo.io/json", "-country-de")).json();
  console.log(data.ip, data.country, data.org);
}

try {
  await get("https://ipinfo.io/json", "-country-usa");
} catch (err) {
  console.log(err.message);
}
txt
84.156.98.243 DE AS3320 Deutsche Telekom AG
92.208.60.82 DE AS3209 Vodafone GmbH
79.201.226.79 DE AS3320 Deutsche Telekom AG
proxy refused with 403

Why some errors are retried

An exit is someone’s home connection and can drop a request; a new agent gets a new exit. A 465 can go away when another exit comes online. A 407 or 403 is about your settings and fails the same way every time, so the helper stops at once.

Does Node's fetch use HTTP_PROXY or HTTPS_PROXY?
Not by default. On Node 22 and 24 a plain fetch() with HTTPS_PROXY set went out from our own IP. With NODE_USE_ENV_PROXY=1 it uses them (Node 22.23 printed an "experimental" warning, Node 24 none); on any version, setGlobalDispatcher(new EnvHttpProxyAgent()) from undici does the same.
What does 'invalid onRequestStart method' mean?
You passed a ProxyAgent from the npm undici package (version 8) as dispatcher to Node's built-in fetch, which runs an older undici (6 on Node 22, 7 on Node 24). The two don't fit together. Import fetch from undici as well, or install undici 7.
How do I see the proxy's status code when fetch fails?
fetch only says 'fetch failed', and its cause says 'Request was cancelled.'. The proxy's answer is one level further down: err.cause.cause.message reads 'Proxy response (407) !== 200 when HTTP Tunneling'.
How do I rotate IPs with fetch and a proxy?
A ProxyAgent keeps its connection to the proxy open, and our rotating port picks an exit per connection, so one agent gave one IP for three requests in our test. A new ProxyAgent per request gave three IPs. For a fixed IP, use a session label on the sticky port instead.

Residential proxies, pay per GB

HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.