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.
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.
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:
| Setup | Node 22 | Node 24 |
|---|---|---|
| fetch(url), HTTPS_PROXY set | own IP | own IP |
| NODE_USE_ENV_PROXY=1, HTTPS_PROXY set | proxy (with an “experimental” warning) | proxy |
| setGlobalDispatcher(new EnvHttpProxyAgent()) | proxy | proxy |
| undici fetch(url, { dispatcher: new ProxyAgent(…) }) | proxy | proxy |
| built-in fetch + ProxyAgent from npm undici 8 | invalid onRequestStart method | invalid onRequestStart method |
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:
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:
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:
err.message fetch failed
err.cause.message Request was cancelled.
err.cause.cause.message Proxy response (407) !== 200 when HTTP TunnelingWhat 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:
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.
// 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);
}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 403Why 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?
What does 'invalid onRequestStart method' mean?
How do I see the proxy's status code when fetch fails?
How do I rotate IPs with fetch and a proxy?
Keep reading
Axios HTTPS Proxy Not Working: httpsAgent, proxy: false and 407
Why an axios proxy silently doesn't apply, why HTTPS_PROXY beats your agent, and how to tell the proxy's 403 from the website's.
6 min read · #node.js#errors
Selenium Proxy With Username and Password (Chrome): What Works in 2026
Chrome under Selenium ignores a proxy login in --proxy-server and has no SOCKS5 auth. What each attempt does, and a local forwarder that works.
5 min read · #python#authentication
Go net/http Proxy Authentication: HTTP and SOCKS5
Authenticated HTTP and SOCKS5 proxies in Go without extra packages, why rotation needs DisableKeepAlives, and a code generator.
6 min read · #go#authentication
Residential proxies, pay per GB
HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.