# Fix Node.js TypeError: fetch failed behind a proxy

Source: https://ipvolt.com/guides/fix-node-fetch-failed-proxy
Markdown: https://ipvolt.com/guides/fix-node-fetch-failed-proxy.md

[Home](https://ipvolt.com/index.md) / [Guides](https://ipvolt.com/guides.md) / [Fix Node.js TypeError: fetch failed behind a proxy](https://ipvolt.com/guides/fix-node-fetch-failed-proxy.md)

Category: Troubleshooting
Reviewed: 2026-09-24
Published: 2026-09-24
Reading time: 22 minutes
Author: ipvolt

Decode Node.js TypeError: fetch failed behind a proxy: print the err.cause chain, fix an ignored HTTPS_PROXY or undici skew, and read a proxy 403, 407 or 502.

`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 `fetch` ignores `HTTPS_PROXY` until you opt in with `NODE_USE_ENV_PROXY=1`, `--use-env-proxy` or `http.setGlobalProxyFromEnv()`, and Node 26 ignores a global dispatcher set by undici 5, 6, or 7 before 7.27.0. You see `getaddrinfo 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 the `fetch` of Node 22 or 24, fails with `invalid onError method` or `invalid onRequestStart method` before any byte reaches the proxy.
- **The proxy refused the request.** `Proxy response (NNN) !== 200 when HTTP Tunneling` sits two levels down, with the proxy's status, such as 403, 407, 429 or 502. Plain `http://` URLs on undici 8.7 and later differ.
- **An unreachable or failing proxy.** `ECONNREFUSED`, `ETIMEDOUT`, `UND_ERR_CONNECT_TIMEOUT` or `ENOTFOUND` naming 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](https://github.com/nodejs/undici/blob/v8.10.2/lib/web/fetch/index.js#L272) 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](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/print-cause.mjs), the printer that recorded every cell in the lab:

```js
// 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`:

```text
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (407) !== 200 when HTTP Tunneling
```

Read 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](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Errors.md) 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](https://github.com/nodejs/undici/issues/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](/guides/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](https://github.com/nodejs/node/blob/v26.10.0/lib/internal/process/pre_execution.js#L314-L333) 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`](https://nodejs.org/api/cli.html#node_use_env_proxy1) | v24.0.0, v22.21.0 | proxied on all three |
| [`node --use-env-proxy`](https://nodejs.org/api/cli.html#--use-env-proxy) | v24.5.0, v22.21.0 | proxied on all three |
| [`http.setGlobalProxyFromEnv()`](https://nodejs.org/api/http.html#httpsetglobalproxyfromenvproxyenv) | 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 `fetch` there.
- Include the scheme. With the opt-in on, a value without `http://`, such as `127.0.0.1:<port>` or `localhost:<port>`, made all three runtimes exit at startup with `TypeError: Invalid URL` or `Invalid URL protocol`, before any application code ran. Only `http://` and `https://` proxy URLs are documented; SOCKS5 is still an open item in Node's [proxy tracking issue](https://github.com/nodejs/node/issues/57872).
- Node parses the variables "during startup", per the CLI docs, so setting `process.env.HTTPS_PROXY` from 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_PROXY` set, even `https://127.0.0.1` went through the proxy. What `NO_PROXY` exempts differs between clients; see the [NO_PROXY matching matrix](/blog/no-proxy-matching-tested).
- A per-request `dispatcher` takes over from the opt-in for its request: with `HTTPS_PROXY` pointing 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](/guides/proxy-environment-variables); for an explicit `ProxyAgent`, [use a proxy with Node.js fetch](/guides/nodejs-fetch-proxy).

## invalid onError method: an older undici dispatcher on Node 26

[undici 8.0.0](https://github.com/nodejs/undici/releases/tag/v8.0.0) removed the legacy handler wrappers and renamed handler callbacks, for example `onError` to `onResponseError` ([migration guide](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/migrating-from-v7-to-v8.md)). 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](https://github.com/nodejs/undici/blob/v6.28.1/lib/dispatcher/dispatcher-base.js#L168-L196), 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 `ProxyAgent`s 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:

```text
[0] TypeError: fetch failed
  [1] InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onError method
```

[xen-orchestra#10411](https://github.com/vatesfr/xen-orchestra/issues/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](https://github.com/nodejs/undici/pull/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](https://github.com/nodejs/undici/blob/v8.10.2/lib/core/util.js#L567-L605) without `onRequestStart`. [shadcn-vue#1959](https://github.com/unovue/shadcn-vue/issues/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:

```js
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](https://github.com/nodejs/undici/pull/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](https://nodejs.org/api/globals.html#custom-dispatcher)). According to the [Node source tree](https://github.com/nodejs/node/blob/v26.10.0/src/undici_version.h) 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:

1. **Use undici's own `fetch` with 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 as `FormData` must come from the same package ([undici docs](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/undici-vs-builtin-fetch.md)).
2. **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_PROXY` follows the bundled undici. [Corepack 0.35.0](https://github.com/nodejs/corepack/releases/tag/v0.35.0) took this route.
3. **Match the major.** An npm undici with the same major as `process.versions.undici` reached 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.
4. **`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 every `fetch` in 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 audit` flagged 7.27.0 as well.
5. **`Dispatcher1Wrapper` (undici 8).** It proxied per request on all three runtimes.
6. **[`install()`](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/GlobalInstallation.md) (undici 7.11.0 and later).** It replaces `globalThis.fetch` and 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:

```text
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (403) !== 200 when HTTP Tunneling
```

In 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](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.6), any answer other than a 2xx means the tunnel was never formed. undici [discards](https://github.com/nodejs/undici/blob/v8.10.2/lib/dispatcher/proxy-agent.js#L229-L233) 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](/guides/curl-proxy-setup) covers that setup.

| Status | What the proxy is saying | Where to look |
| --- | --- | --- |
| 407 | It wants proxy credentials | [Fix proxy error 407](/guides/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](https://www.rfc-editor.org/rfc/rfc6585#section-4)) | 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](/blog/proxy-status-codes-407-429-502) 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](https://github.com/nodejs/undici/pull/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):

```text
[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](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Client.md)), 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):

```text
[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 `ProxyAgent`s 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](https://github.com/nodejs/undici/blob/v8.10.2/lib/core/connect.js#L167-L190)); 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](https://github.com/nodejs/undici/issues/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](/guides/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](https://nodejs.org/api/deprecations.html#dep0123-setting-the-tls-servername-to-an-ip-address)). 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 failed` over depth 2 `SocketError (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](https://github.com/nodejs/undici/pull/5441) added `ProxyConnectionError` to fail these requests instead of looping ([issue #3897](https://github.com/nodejs/undici/issues/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`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile) 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`](https://nodejs.org/api/cli.html#--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](https://nodejs.org/learn/http/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.

1. Run `node -p process.versions.undici` with the same `node` binary the tool uses.
2. Find the tool's copies with `npm explain undici`. In the lab, `npm ls undici` printed `(empty)` for copies installed under an npm alias (`npm:undici@…`) that `npm explain` listed. A copy compiled into the tool's bundle shows up in neither; check the tool's changelog or issue tracker.
3. 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](https://github.com/vercel/vercel/issues/17629), open on 2026-09-23: the Vercel CLI's bundled undici 5.29.0 fails with `invalid onError method` on Node 26.8.2 when a proxy variable is set, and works on Node 24.21.0.
- [nodejs/corepack#834](https://github.com/nodejs/corepack/issues/834): a bundled undici 6 `ProxyAgent` failed on Node 26; Corepack 0.35.0 fixed it by requiring `NODE_USE_ENV_PROXY=1`.
- [openclaw#155840](https://github.com/openclaw/openclaw/issues/155840): `invalid onRequestStart method` from an undici 8 dispatcher passed to a dependency's undici 7 WebSocket; the skew is not limited to `fetch`.

## 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](https://www.rfc-editor.org/rfc/rfc6761.html#section-6.2)), 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](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/README.md) 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](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/fix-node-fetch-failed-proxy-lab.zip) holds all of the files below.
- [README.md](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/README.md) explains how to run the lab and read a cell; [print-cause.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/print-cause.mjs) is the printer.
- [run-matrix.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/run-matrix.mjs), [run-checks.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/run-checks.mjs), [cell.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/cell.mjs), [cause-forms.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/cause-forms.mjs) and [compare.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/compare.mjs) are the harness.
- [get-runtimes.mjs](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/get-runtimes.mjs), [runtimes.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/runtimes.json), [package.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/package.json) and [package-lock.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/package-lock.json) pin the runtimes and packages.
- [results.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results.json), [results.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results.csv), [results-extra.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-extra.json), [results-extra.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-extra.csv), [results-long-hang.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-long-hang.json), [results-long-hang.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-long-hang.csv), [results-checks.json](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/results-checks.json) and [results-checks.csv](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/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](https://ipvolt.com/#waitlist-closing). One email when access opens. Nothing else.

## Sources & further reading

- [Node.js HTTP: Built-in Proxy Support and http.setGlobalProxyFromEnv()](https://nodejs.org/api/http.html#built-in-proxy-support)
- [Node.js CLI: NODE_USE_ENV_PROXY=1 and --use-env-proxy](https://nodejs.org/api/cli.html#node_use_env_proxy1)
- [Node.js globals: fetch with a custom dispatcher and process.versions.undici](https://nodejs.org/api/globals.html#custom-dispatcher)
- [Node.js Learn: Enterprise network configuration](https://nodejs.org/learn/http/enterprise-network-configuration)
- [Node.js deprecations: DEP0123, setting the TLS ServerName to an IP address](https://nodejs.org/api/deprecations.html#dep0123-setting-the-tls-servername-to-an-ip-address)
- [undici 8.11.0: Errors reference](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/Errors.md)
- [undici 8.11.0: ProxyAgent (CONNECT for https://, forwarding for http://)](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/api/ProxyAgent.md)
- [undici: Migrating from undici 7 to 8](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/migrating-from-v7-to-v8.md)
- [undici: Undici module vs. Node.js built-in fetch](https://github.com/nodejs/undici/blob/v8.11.0/docs/docs/best-practices/undici-vs-builtin-fetch.md)
- [undici PR #4962: mirror the legacy global dispatcher for built-in fetch (v8.0.1)](https://github.com/nodejs/undici/pull/4962)
- [undici PR #5319: setGlobalDispatcher() writes both global-dispatcher slots, for Node 26 (v7.27.0)](https://github.com/nodejs/undici/pull/5319)
- [undici PR #5116: auto-detect HTTP proxy tunneling (v8.7.0)](https://github.com/nodejs/undici/pull/5116)
- [undici PR #5441: fail instead of looping when the proxy closes during CONNECT setup (UND_ERR_PRX_CONN, v8.6.0)](https://github.com/nodejs/undici/pull/5441)
- [RFC 9110: CONNECT and status codes 403, 407, 502, 503 and 504](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.6)
- [RFC 6585: section 4, 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585#section-4)

## Related guides

- [Use a proxy with Node.js fetch](https://ipvolt.com/guides/nodejs-fetch-proxy.md)
- [Proxy environment variables: HTTP_PROXY and NO_PROXY](https://ipvolt.com/guides/proxy-environment-variables.md)
- [Troubleshoot proxy timeouts one stage at a time](https://ipvolt.com/guides/proxy-timeout-troubleshooting.md)
- [Fix proxy error 407 without guessing](https://ipvolt.com/guides/fix-proxy-error-407.md)

## About ipvolt

Examples use generic proxy settings, with links to the original technical documentation. Product-specific behavior must be checked with your provider. ipvolt is still in development.

## Know when access opens.

ipvolt · In development

We’re building proxy infrastructure for developers and data teams. Join the interest list for a heads-up when ipvolt is ready.

Consent: One email when access opens. Nothing else.

[Get early access](https://ipvolt.com/guides/fix-node-fetch-failed-proxy#waitlist-closing). Use the email form on this page to join the interest list.

[Privacy](https://ipvolt.com/privacy)

