TypeError: fetch failed is not the error itself. It is the wrapper that Node.js's built-in fetch (undici) puts around every network-level failure. The reason is in err.cause, and sometimes one level deeper. Behind a proxy, the causes fall into four groups:
- The proxy was never used. Built-in
fetchignoresHTTPS_PROXYuntil you opt in withNODE_USE_ENV_PROXY=1,--use-env-proxyorhttp.setGlobalProxyFromEnv(), and Node 26 ignores a global dispatcher set by undici 5, 6, or 7 before 7.27.0. You seegetaddrinfo ENOTFOUND, a connect error that names the destination's address, or a normal response that the proxy never logged. - Version skew around undici 8. An undici 5 or 6 dispatcher passed per request to Node 26's
fetch, or an undici 8 dispatcher passed to thefetchof Node 22 or 24, fails withinvalid onError methodorinvalid onRequestStart methodbefore any byte reaches the proxy. - The proxy refused the request.
Proxy response (NNN) !== 200 when HTTP Tunnelingsits two levels down, with the proxy's status, such as 403, 407, 429 or 502. Plainhttp://URLs on undici 8.7 and later differ. - An unreachable or failing proxy.
ECONNREFUSED,ETIMEDOUT,UND_ERR_CONNECT_TIMEOUTorENOTFOUNDnaming the proxy,UND_ERR_PRX_CONN,ECONNRESET, a TLS error, or no error at all.
Print the whole chain, find the deepest string in the tables below and apply the fix for that layer. Except for the one row marked as reported, every error string in the tables was observed in 1,965 loopback lab cells that ipvolt ran on 23 and 24 September 2026 with Node.js v22.23.3, v24.21.0 and v26.10.0 and npm undici 5.29.0 to 8.11.0.
A 4xx or 5xx from the destination never produces fetch failed; it resolves as a Response. The browser's TypeError: Failed to fetch is a different error, raised for network and CORS failures whose details JavaScript cannot see.
What "TypeError: fetch failed" means in Node.js
undici rejects network errors with new TypeError('fetch failed', { cause: response.error }). The same line is in the first and the latest undici bundled by each of Node 22, 24 and 26 (six releases, from 6.11.1 to 8.10.2). The cause can be an undici error with a stable code such as UND_ERR_INVALID_ARG, a Node.js system error such as ECONNREFUSED, or a DOMException with the message Request was cancelled. that wraps the real error one level further down, as every rejected CONNECT did in the lab. Your own AbortSignal.timeout() is the exception: it rejects with a bare TimeoutError that has no fetch failed wrapper and no cause.
Print the whole cause chain
Several common ways of logging the error stop before the deepest string. With the lab's 407 proxy on all three runtimes, err.message showed only fetch failed, err.cause.message stopped at Request was cancelled., and JSON.stringify(err) printed {}. console.log(err) did reach the 407, but with stack traces and without masking anything. Walk the chain instead with print-cause.mjs, the printer that recorded every cell in the lab:
// 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('; ')}]` : ''}`);
}
}Call it in the catch block, for example try { await fetch(url) } catch (err) { printCauseChain(err); process.exitCode = 1; }. Don't rethrow err and leave it uncaught: Node then prints the whole error itself, including an unmasked [cause], as the lab confirmed with a throwaway password. This is the output for the lab's 407 proxy on Node v26.10.0 with NODE_USE_ENV_PROXY=1:
[0] TypeError: fetch failed
[1] Error: Request was cancelled.
[2] AbortError (UND_ERR_ABORTED): Proxy response (407) !== 200 when HTTP TunnelingRead the output before you share it: anything the masks don't match, such as user:password@host without a scheme in front, passes through.
If your code branches on the error, match err.code, not instanceof. undici's Errors reference recommends this because "the bundled (global) dispatcher may come from a different undici version than the one you import directly". For UND_ERR_INVALID_ARG, check the message too: in the lab it meant version skew or a proxy 407 on a plain http:// URL.
Find your error: string, depth, layer and fix
Depth 0 is TypeError: fetch failed, and each string sits at depth 1, unless a row says otherwise. "Proxy logged nothing" means the lab proxy saw no connection for that fetch.
It failed before reaching the proxy
| What you see | Layer | Fix |
|---|---|---|
invalid onError method (UND_ERR_INVALID_ARG), proxy logged nothing | An undici 5 or 6 dispatcher handed to Node 26's fetch | An undici 7 or 8 dispatcher, undici's own fetch, or no dispatcher plus NODE_USE_ENV_PROXY=1 |
invalid onRequestStart method (UND_ERR_INVALID_ARG), proxy logged nothing | An undici 8 dispatcher handed to the fetch of Node 22 or 24 | Dispatcher1Wrapper, undici 8's own fetch or setGlobalDispatcher(), or an undici 7 agent |
getaddrinfo ENOTFOUND naming the destination, proxy logged nothing | The proxy was never used; the direct DNS lookup failed | Turn on the env-proxy opt-in. For a global dispatcher, upgrade undici to 7.27.0+ or 8.0.1+ |
getaddrinfo ENOTFOUND naming the proxy's host | The proxy's host name does not resolve on this machine | Check the host in the proxy URL, and the DNS or VPN that should resolve it |
connect ECONNREFUSED or connect ETIMEDOUT with the destination's address, proxy logged nothing | The proxy was never used; the direct connection failed | As above |
Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT) listing the destination's addresses, reported in undici#4960 | The same, reported by undici's connect timeout | As above |
| No error and a normal response, proxy logged nothing | The proxy was silently bypassed | As above |
Setting the TLS ServerName to an IP address is not permitted (ERR_INVALID_ARG_VALUE), on Node 26 | An https:// proxy URL with an IP address, which Node 25 and later refuse | http:// for a plain-HTTP proxy, or a host name the HTTPS proxy's certificate covers |
It failed at the proxy or beyond it
| What you see | Layer | Fix |
|---|---|---|
Depth 2 Proxy response (NNN) !== 200 when HTTP Tunneling under depth 1 Request was cancelled. | The proxy refused the CONNECT with status NNN | Read NNN: 407 is credentials (fix proxy error 407), 403 its policy, 429 its rate limit, 502 to 504 trouble at or beyond the proxy |
Proxy Authentication Required (407) (UND_ERR_INVALID_ARG) | A 407 for a plain http:// URL that undici 8.7 or later forwarded without CONNECT | The same credentials fix; this UND_ERR_INVALID_ARG is not version skew |
No error, but response.status is 403, 429, 502 or 503, and the origin never logged the request | The proxy's own error for a forwarded http:// URL (undici 8.7 and later) | Check the body and headers for the proxy's signature |
connect ECONNREFUSED plus the proxy's address | Nothing listens at the proxy's host and port | Check the host, the port and that the proxy is running |
AggregateError (ECONNREFUSED) with an empty message and one refused address per IP family | The same, for a proxy host name such as localhost | As above |
connect ETIMEDOUT plus the proxy's address | The TCP handshake with the proxy never completed | Check the route and firewalls to the proxy |
Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT) with the proxy's address | The same fault, reported by undici's connect timeout | As above |
ERR_SSL_WRONG_VERSION_NUMBER, with wrong version number in the message | The proxy URL says https://, but the proxy speaks plain HTTP | Use http:// in the proxy URL |
Proxy Connection failed (UND_ERR_PRX_CONN) over depth 2 other side closed | The proxy closed the connection without answering CONNECT (undici 8.6 and later) | Check the port, the protocol and whether the proxy supports CONNECT |
other side closed (UND_ERR_SOCKET) for an http:// URL | The proxy closed a forwarded request without answering (undici 8.7 and later) | Check the port and the protocol, then the proxy's log |
| No error, and the fetch never settles, while the proxy logs CONNECT after CONNECT | The same fault on undici 8.5 and older: a reconnect loop | A caller deadline turns it into a TimeoutError; undici 8.6 or later turns it into UND_ERR_PRX_CONN |
Client network socket disconnected before secure TLS connection was established (ECONNRESET) | The proxy answered 200, then dropped the tunnel | Look past the proxy: its upstream or the destination |
SELF_SIGNED_CERT_IN_CHAIN or UNABLE_TO_VERIFY_LEAF_SIGNATURE | A TLS-inspecting proxy presented a certificate from a CA that Node doesn't trust | Trust that CA with NODE_EXTRA_CA_CERTS, or with --use-system-ca if it is in the OS trust store |
No error for about five minutes, then depth 1 Headers Timeout Error (UND_ERR_HEADERS_TIMEOUT) | The proxy accepted CONNECT and never answered | Set your own deadline |
Depth 0 TimeoutError: The operation was aborted due to timeout, no cause | Your AbortSignal.timeout() fired | Check the proxy log for the pending stage |
In every cell that got as far as the injected proxy behavior, 449 in the main matrix and 803 in the checks suite, the layer that the lab derived from the strings and proxy counts alone matched that behavior.
HTTPS_PROXY is set but fetch ignores it (NODE_USE_ENV_PROXY)
With HTTPS_PROXY set and no opt-in, all three runtimes in the lab connected directly, and HTTP_PROXY with http:// URLs behaved the same way; a destination reachable without the proxy returned 200 while the proxy logged nothing. Node's startup code installs a proxy dispatcher only when the opt-in is on and one of HTTP_PROXY, HTTPS_PROXY, http_proxy or https_proxy is set. ALL_PROXY doesn't count.
| Opt-in | Documented since | Lab: v22.23.3 / v24.21.0 / v26.10.0 |
|---|---|---|
NODE_USE_ENV_PROXY=1 | v24.0.0, v22.21.0 | proxied on all three |
node --use-env-proxy | v24.5.0, v22.21.0 | proxied on all three |
http.setGlobalProxyFromEnv() | v24.14.0, v25.4.0 | not a function on v22.23.3; proxied on the other two |
npm undici setGlobalDispatcher(new EnvHttpProxyAgent()) | exported by 6.28.1 and every undici 7 and 8 in the lab | proxied, except on v26.10.0 with 6.28.1, 7.16.0 or 7.26.0, which went direct |
- Older releases don't have the opt-in; use a dispatcher or undici's own
fetchthere. - Include the scheme. With the opt-in on, a value without
http://, such as127.0.0.1:<port>orlocalhost:<port>, made all three runtimes exit at startup withTypeError: Invalid URLorInvalid URL protocol, before any application code ran. Onlyhttp://andhttps://proxy URLs are documented; SOCKS5 is still an open item in Node's proxy tracking issue. - Node parses the variables "during startup", per the CLI docs, so setting
process.env.HTTPS_PROXYfrom code later does not help.http.setGlobalProxyFromEnv()is the runtime alternative. - On v22.23.3, the opt-in printed
[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, a stability notice, not an error. - With no
NO_PROXYset, evenhttps://127.0.0.1went through the proxy. WhatNO_PROXYexempts differs between clients; see the NO_PROXY matching matrix. - A per-request
dispatchertakes over from the opt-in for its request: withHTTPS_PROXYpointing at a dead port and the agent at the working proxy, every request that ran used the agent.
In the proxy log, a plain http:// URL on undici 8.7 and later, including the env proxy of Node 26.5 and later, shows no CONNECT, only the request itself in absolute form, such as GET http://origin.test/…. For the same variables in curl and Python, see proxy environment variables; for an explicit ProxyAgent, use a proxy with Node.js fetch.
invalid onError method: an older undici dispatcher on Node 26
undici 8.0.0 removed the legacy handler wrappers and renamed handler callbacks, for example onError to onResponseError (migration guide). Every Node 26 release bundles undici 8, so its fetch gives dispatchers a handler with only the new callbacks. In undici 6, the first failed check, such as invalid onConnect method, goes to the dispatch error path, which calls handler.onError; that method no longer exists, so undici throws invalid onError method and the original message is lost.
npm undici 5.29.0 and 6.28.1 ProxyAgents passed as dispatcher to v26.10.0's fetch failed in all 18 cells, before any connection, and adding NODE_USE_ENV_PROXY=1 did not help:
[0] TypeError: fetch failed
[1] InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onError methodxen-orchestra#10411 reports UND_ERR_INVALID_ARG for an undici 6.28.1 EnvHttpProxyAgent passed to Node 26's fetch.
The global route fails more quietly. On v26.10.0, setGlobalDispatcher() from npm undici 5.29.0 (with a ProxyAgent; that release has no EnvHttpProxyAgent), 6.28.1, 7.16.0 or 7.26.0 (with a ProxyAgent or an EnvHttpProxyAgent) had no effect on the built-in fetch. Requests went direct and ended in ENOTFOUND, or in a 200 with zero CONNECTs, while the same code proxied on v22.23.3 and v24.21.0. These versions, like the other undici 7 tags before 7.27.0 whose lib/global.js was checked (7.0.0, 7.10.0 and 7.25.0), store the global dispatcher only under Symbol.for('undici.globalDispatcher.1'), which v26.10.0's fetch ignored. undici 7.27.0, released on 2026-06-01 with PR #5319, writes both .1 and .2, as do 7.29.1 and 8.11.0; with those three the global dispatcher proxied on all three runtimes. undici 6.28.1's EnvHttpProxyAgent still printed its experimental warning on v26.10.0, so the warning does not prove the agent is in use.
invalid onRequestStart method: an undici 8 dispatcher on Node 22 or 24
An npm undici 8.11.0 ProxyAgent passed per request to the built-in fetch of v22.23.3 or v24.21.0 failed in all 18 cells with InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onRequestStart method at depth 1, again before any connection. The fetch in Node 22 and 24 builds a legacy handler, and undici 8 rejects any handler without onRequestStart. shadcn-vue#1959 reports the same string from a CLI that bundles undici 8.10.2 on Node 24.
On v22.23.3 and v24.21.0, wrapping the agent in Dispatcher1Wrapper, undici 8's documented bridge for legacy consumers, reached the proxy:
import { Dispatcher1Wrapper, ProxyAgent } from 'undici'; // undici 8
const dispatcher = new Dispatcher1Wrapper(new ProxyAgent(proxyUrl));
const response = await fetch(url, { dispatcher });So did setGlobalDispatcher(new ProxyAgent(proxyUrl)) from undici 8.11.0, which also stores a wrapped copy in the legacy slot that built-in fetch reads. undici 8.0.0 did not, so on Node 24 and 25 built-in fetch ignored it; PR #4962 fixed that in 8.0.1 on 2026-04-03.
Pick a fix: match undici to process.versions.undici
Check the runtime first with node -p process.versions.undici (Node.js docs). According to the Node source tree at every release tag, every 22.x release bundles undici 6, every 24.x release 7 and every 26.x release 8. In the lab, a per-request dispatcher from npm undici 5.29.0 or 6.28.1 reached the proxy on v22.23.3 and v24.21.0 but failed on v26.10.0; one from undici 7.16.0, 7.26.0, 7.27.0 or 7.29.1 reached it on all three; and one from 8.11.0 reached it only on v26.10.0. The fixes, and what each one costs:
- Use undici's own
fetchwith its own dispatcher. It worked with the four main npm versions on every runtime in the lab. The costs: one more dependency, and body classes such asFormDatamust come from the same package (undici docs). - Drop the custom dispatcher and use the runtime opt-in. It proxied on every runtime that has it, but it is process-wide, covers HTTP(S) proxies only, and
NO_PROXYfollows the bundled undici. Corepack 0.35.0 took this route. - Match the major. An npm undici with the same major as
process.versions.undicireached the proxy on all three runtimes. A per-request undici 7 agent was accepted by all three too, but that is a snapshot, not a promise about future Node lines. setGlobalDispatcher()from undici 7.27.0 or later 7.x, or from 8.0.1 and later. With 7.27.0, 7.29.1 and 8.11.0, it proxied on all three runtimes, for everyfetchin the process. With undici 5, 6, or 7 before 7.27.0 on Node 26, it is ignored, so upgrade undici to 7.27.0+ or 8.0.1+. Prefer the latest 7.x: on 24 September 2026,npm auditflagged 7.27.0 as well.Dispatcher1Wrapper(undici 8). It proxied per request on all three runtimes.install()(undici 7.11.0 and later). It replacesglobalThis.fetchand related globals with the npm copy. Documented only; the lab did not run it.
Proxy response (NNN) !== 200 when HTTP Tunneling
When the proxy answers CONNECT with anything but 200, the proxy's status appears only at depth 2:
[0] TypeError: fetch failed
[1] Error: Request was cancelled.
[2] AbortError (UND_ERR_ABORTED): Proxy response (403) !== 200 when HTTP TunnelingIn the lab, every wiring that reached a proxy answering CONNECT with 403, 407, 429, 502 or 503 produced this chain with that status, 49 https:// cells per status. Under RFC 9110, any answer other than a 2xx means the tunnel was never formed. undici discards the rest of the answer, including Retry-After and the body. To see them, run curl through the same proxy, for example curl -v -x http://PROXY_HOST:PORT https://DESTINATION/ -o /dev/null. Against the lab's 429 proxy, curl 8.7.1 and 8.22.0 printed its Retry-After: 30. The curl baseline covers that setup.
| Status | What the proxy is saying | Where to look |
|---|---|---|
| 407 | It wants proxy credentials | Fix proxy error 407 |
| 403 | Its policy refuses the request; RFC 9110 asks proxies to limit CONNECT to known ports or allowed targets | The proxy's allow rules for this destination and port |
| 429 | A rate limit; the status alone doesn't say whose (RFC 6585) | Slow down; read Retry-After with curl -v |
| 502 or 504 | Its upstream sent a bad answer, or none in time | Retry within a budget; check the destination from the proxy's side |
| 503 | It is temporarily unable to handle the request | Retry later, honoring any Retry-After |
undici builds the same message from any non-200 status. Proxy errors explained covers each status in more depth.
A plain http:// URL depends on which undici owns the agent. undici 8.6 and older, including the env proxy of Node 22, 24 and 26.0 to 26.4, send CONNECT origin.test:80 and fail with the same chain (in the lab, every undici 7.29.1 and older path, 33 cells per status). undici 8.7 and later, including the env proxy of Node 26.5 and later, forward the request itself without CONNECT (PR #5116). Of those forwarded requests (16 cells per status), a 407 came back one level down as InvalidArgumentError (UND_ERR_INVALID_ARG): Proxy Authentication Required (407), the same code that version skew uses. A 403, 429, 502 or 503 did not throw at all: fetch resolved with the proxy's status and body, and the origin never saw the request.
ECONNREFUSED, ETIMEDOUT or ENOTFOUND: whose address is it?
connect ECONNREFUSED followed by the proxy's IP and port (49 cells) means nothing is listening there. For a proxy host name such as localhost, Node tries each address and reports an AggregateError with an empty message instead (49 cells):
[0] TypeError: fetch failed
[1] AggregateError (ECONNREFUSED): [ECONNREFUSED: connect ECONNREFUSED ::1:<proxy-port>; ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:<proxy-port>]If the address is the destination's, the request went direct: with HTTPS_PROXY set and no opt-in, a refused destination produced connect ECONNREFUSED 127.0.0.1:<dest-port> on all three runtimes.
getaddrinfo ENOTFOUND works the same way: read the host name. With the proxy URL http://proxy.invalid:3128, a reserved name that never resolves, every wiring that used the proxy failed with depth 1 getaddrinfo ENOTFOUND proxy.invalid (49 cells), the same shape as a bypass's getaddrinfo ENOTFOUND origin.test.
connect ETIMEDOUT followed by the proxy's address (77 cells) means the TCP handshake with the proxy never completed; the lab used a listener whose accept queue was full. macOS usually gave up after about 7.8 seconds, before undici's default connect timeout. A never-accepting destination behind a bypassed proxy produced the same string with the destination's address.
UND_ERR_CONNECT_TIMEOUT: proxy or destination?
undici's connectTimeout defaults to 10 seconds (Client docs), and on macOS loopback the OS timeout usually came first. The published run therefore shows UND_ERR_CONNECT_TIMEOUT only with connectTimeout: 3000 on undici 8.11.0 agents, after about 3.5 seconds (13 cells):
[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 and 7.29.1 ProxyAgents given the same option still ended at the OS timeout (28 cells), because they build the proxy connection from proxyTls options only. On Linux or a real network, undici's 10-second timer may win more often; the lab did not test that. undici 8.10.2 and 8.11.0 also report Connect Timeout Error when every address of a host name fails and one timed out, whichever timer fired, with the configured timeout in the message and the AggregateError at depth 2 (connect.js); the lab used single addresses and never produced that form.
Read the address in the message. The proxy's address means the proxy is unreachable; the destination's means the request never used the proxy, as in undici#4960, where the message listed six destination addresses. undici 6 and later print attempted address: or attempted addresses:; undici 5 prints a bare Connect Timeout Error with no address, so compare the proxy's log instead. For deadlines and retry budgets, see proxy timeout troubleshooting.
ERR_SSL_WRONG_VERSION_NUMBER: an https:// proxy URL for a plain proxy
A plain-HTTP forward proxy still carries https:// destinations inside the CONNECT tunnel, but a proxy URL that starts with https:// makes the client open TLS to the proxy itself. In the lab, a plain-HTTP proxy answered that handshake with an HTTP 400, and every wiring that reached it failed with depth 1 Error (ERR_SSL_WRONG_VERSION_NUMBER) and an OpenSSL message containing SSL routines:tls_validate_record_header:wrong version number (85 cells).
With an IP address in the https:// proxy URL, 13 wirings on v26.10.0 failed before connecting with depth 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 made an IP-address TLS server name an error in v25.0.0 (DEP0123). The fix for both is http:// in the proxy URL, unless the proxy really serves TLS on that port.
UND_ERR_PRX_CONN: the proxy closed before answering CONNECT
One fixture accepted TCP, read the CONNECT and closed without replying. The outcome depended on which undici owned the proxy agent:
- undici 8.6 and later (in the lab, every npm 8.11.0 agent that reached the proxy and the built-in env proxy of v26.10.0): depth 1
ProxyConnectionError (UND_ERR_PRX_CONN): Proxy Connection failedover depth 2SocketError (UND_ERR_SOCKET): other side closed, after one CONNECT (16 cells). - undici 8.5 and older (in the lab, every npm 5, 6 and 7 agent that reached the proxy and the built-in env proxy of Node 22 and 24): no error. The client reconnected at once until the harness stopped it at 25 CONNECTs (33 cells).
From the application, that loop looks like a hang: the fetch never settles. With AbortSignal.timeout(2000) and no stop, the looping clients rejected after 2 seconds with a bare TimeoutError, and the proxy logged 18,600 to 20,548 CONNECTs for that single request in the published run (9 cells, on loopback).
For a plain http:// URL, undici 8.7 and later send no CONNECT, so the same fault surfaced as depth 1 SocketError (UND_ERR_SOCKET): other side closed (16 cells), while undici 7 and older looped as above (33 cells).
PR #5441 added ProxyConnectionError to fail these requests instead of looping (issue #3897). The class first shipped in undici 8.6.0, although the 8.11.0 Errors reference labels it v8.10.1, and v26.5.0 is the first Node 26 release whose bundled undici includes it.
ECONNRESET after CONNECT 200: the tunnel dropped
When the proxy answered 200 and then closed before sending any tunnel bytes, every wiring that reached it (49 cells) reported Client network socket disconnected before secure TLS connection was established (ECONNRESET) at depth 1. The CONNECT succeeded, so look past the proxy's front door: its upstream, the exit, the destination, or the proxy dropping the tunnel. PR #5441 covers only tunnel setup, so this case does not become UND_ERR_PRX_CONN.
SELF_SIGNED_CERT_IN_CHAIN behind a TLS-inspecting proxy
Behind a TLS-inspecting proxy whose CA Node doesn't trust, the chain ends in a certificate error. The lab's stand-in answered CONNECT with 200, then presented its own certificate for the destination from a throwaway CA. Every wiring that reached it failed at depth 1 on all three runtimes, with self-signed certificate in certificate chain (SELF_SIGNED_CERT_IN_CHAIN) when the proxy sent its CA certificate along (49 cells) and unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE) when it sent only its own certificate (49 cells).
Pointing NODE_EXTRA_CA_CERTS at a file that held that CA fixed all 98 cells; Node reads it only at process start. If the CA is already in the operating system's trust store, Node's --use-system-ca flag (documented since v22.15.0 and v23.8.0) or NODE_USE_SYSTEM_CA=1 (since v22.19.0 and v24.6.0) makes Node trust that store too (enterprise network configuration); the lab did not test these. Don't disable verification with NODE_TLS_REJECT_UNAUTHORIZED=0.
No error for five minutes: set your own deadline
A proxy that accepted CONNECT and never answered produced no error within the harness's 15-second limit (49 cells). In the published long run, all 49 of those cells failed after 301 to 302 seconds with HeadersTimeoutError (UND_ERR_HEADERS_TIMEOUT): Headers Timeout Error, which matches undici's documented 300-second headersTimeout default. With signal: AbortSignal.timeout(2000), the same wirings rejected after about 2 seconds with a bare TimeoutError. Pass a signal on every proxied request.
When the failing code is a tool you did not write
CLIs, SDKs and MCP servers often ship their own undici and hand its agent to the built-in fetch or install it with setGlobalDispatcher(). The skew appears when either side moves: an older tool on Node 26, or a tool that adopted undici 8 on Node 22 or 24.
- Run
node -p process.versions.undiciwith the samenodebinary the tool uses. - Find the tool's copies with
npm explain undici. In the lab,npm ls undiciprinted(empty)for copies installed under an npm alias (npm:undici@…) thatnpm explainlisted. A copy compiled into the tool's bundle shows up in neither; check the tool's changelog or issue tracker. - Upgrade the tool first. If its docs support Node's opt-in, use
NODE_USE_ENV_PROXY=1. Otherwise, run it temporarily on a Node line that accepts its copy (see the fixes above), and report the printed chain upstream.
These are reported examples that ipvolt did not reproduce:
- vercel/vercel#17629, open on 2026-09-23: the Vercel CLI's bundled undici 5.29.0 fails with
invalid onError methodon Node 26.8.2 when a proxy variable is set, and works on Node 24.21.0. - nodejs/corepack#834: a bundled undici 6
ProxyAgentfailed on Node 26; Corepack 0.35.0 fixed it by requiringNODE_USE_ENV_PROXY=1. - openclaw#155840:
invalid onRequestStart methodfrom an undici 8 dispatcher passed to a dependency's undici 7 WebSocket; the skew is not limited tofetch.
Method, limitations and downloads
ipvolt ran these checks locally on 23 and 24 September 2026 on macOS 15.7.4 (arm64), with the official Node.js v22.23.3, v24.21.0 and v26.10.0 tarballs and npm undici 5.29.0, 6.28.1, 7.29.1 and 8.11.0, plus 7.16.0, 7.26.0 and 7.27.0 for the global-dispatcher checks. Each cell ran one fetch in its own child process through a single-behavior loopback proxy that counted connections and requests. Most cells requested the reserved name origin.test (RFC 6761), which only the lab proxy resolved. The main matrix had 591 cells, the extra suite 282 (http:// URLs, a localhost proxy URL, unreachable destinations) and the checks suite 1,029; a separate 63-cell run let the never-answering proxy run for 330 seconds. The README has the full method, per-suite results and run history.
A clean rerun from only the final archive, following the README, classified all 591 main, 282 extra, 1,029 checks and 63 long-run cells the same way as the published results. Expect a rerun to differ on a few never-completing-handshake cells, where the OS and undici connect timeouts race, and on the loop-deadline checks when they run back to back: a looping cell leaves thousands of sockets in TIME_WAIT, so the next one can record zero CONNECTs until the ports drain about 40 seconds later.
The lab did not cover Linux or real networks, real HTTPS or SOCKS proxies, proxies that accept credentials, 504, NO_PROXY matching, http.request, Deno, Bun or real third-party tools. The results show how these Node.js and undici versions report each injected behavior, not how any proxy provider behaves. The lockfile pins old undici versions on purpose, and npm ci reports them as a high-severity vulnerability; don't copy these pins into an application.
All files are under https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/:
- fix-node-fetch-failed-proxy-lab.zip holds all of the files below.
- README.md explains how to run the lab and read a cell; print-cause.mjs is the printer.
- run-matrix.mjs, run-checks.mjs, cell.mjs, cause-forms.mjs and compare.mjs are the harness.
- get-runtimes.mjs, runtimes.json, package.json and package-lock.json pin the runtimes and packages.
- results.json, results.csv, results-extra.json, results-extra.csv, results-long-hang.json, results-long-hang.csv, results-checks.json and results-checks.csv hold every cell's cause chain and proxy counts.
To reproduce the results, unzip the archive and follow the README: npm ci, node get-runtimes.mjs, then each suite and node compare.mjs against a copy of the published file. No proxy account is needed.
If you want to hear when ipvolt access opens, join the early-access list. One email when access opens. Nothing else.
Sources & further reading
Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.
- Node.js HTTP: Built-in Proxy Support and http.setGlobalProxyFromEnv()
- Node.js CLI: NODE_USE_ENV_PROXY=1 and --use-env-proxy
- Node.js globals: fetch with a custom dispatcher and process.versions.undici
- Node.js Learn: Enterprise network configuration
- Node.js deprecations: DEP0123, setting the TLS ServerName to an IP address
- undici 8.11.0: Errors reference
- undici 8.11.0: ProxyAgent (CONNECT for https://, forwarding for http://)
- undici: Migrating from undici 7 to 8
- undici: Undici module vs. Node.js built-in fetch
- undici PR #4962: mirror the legacy global dispatcher for built-in fetch (v8.0.1)
- undici PR #5319: setGlobalDispatcher() writes both global-dispatcher slots, for Node 26 (v7.27.0)
- undici PR #5116: auto-detect HTTP proxy tunneling (v8.7.0)
- undici PR #5441: fail instead of looping when the proxy closes during CONNECT setup (UND_ERR_PRX_CONN, v8.6.0)
- RFC 9110: CONNECT and status codes 403, 407, 502, 503 and 504
- RFC 6585: section 4, 429 Too Many Requests