# Node.js fetch + proxy failure lab

Companion lab for [Fix Node.js TypeError: fetch failed behind a proxy](https://ipvolt.com/guides/fix-node-fetch-failed-proxy).

This lab makes Node.js `fetch` fail behind an HTTP proxy in the common ways, then records the full `err.cause` chain for every case. It uses only local fixtures. The main matrix runs:

- **3 official Node.js runtimes:** v22.23.3 (bundles undici 6.28.1), v24.21.0 (undici 7.29.1) and v26.10.0 (undici 8.10.2)
- **4 npm undici versions:** 5.29.0, 6.28.1, 7.29.1 and 8.11.0
- **9 ways of wiring the proxy** (see below)
- **9 proxy fixtures:** local proxies that each have exactly one behavior, all against `https://` URLs

That is 591 cells. A second, `extra` suite of 282 cells covers plain `http://` URLs, a proxy URL that names `localhost`, and a bypassed proxy with an unreachable destination. A third, `checks` suite of 1,029 cells (`run-checks.mjs`) covers proxy error statuses, a proxy host name that does not resolve, an `https://` proxy URL that points at a plain-HTTP proxy, a TLS-inspecting proxy, the environment opt-in combined with a per-request agent, a reconnect loop under a caller deadline, what common ways of logging the error show, and whether undici 7.16.0, 7.26.0 and 7.27.0 can set the global dispatcher for the built-in `fetch`. Each cell runs in its own child process. The fixture proxy counts TCP connections, CONNECT requests and forwarded requests per cell and records the request lines it received. A zero count shows directly that a request never touched the proxy, so you don't have to guess from the error text.

These are **synthetic observations from local fixtures**. They show how these Node.js and undici versions report each injected fault. They say nothing about any real proxy provider's behavior, speed or reliability.

## Requirements

- macOS or Linux on arm64 or x64. The results below come from macOS 15.7.4 on arm64. The scripts support Linux, but the matrix was not run there.
- Node.js and npm on `PATH` to run the scripts (tested with Node v26.8.1). The matrix itself runs on the pinned runtimes that `get-runtimes.mjs` downloads.
- `openssl`, which creates throwaway CAs and certificates at run time, and `tar`.
- Network access to nodejs.org and registry.npmjs.org during setup. The matrix itself stays on 127.0.0.1 (and `::1` for the `localhost` cells). The only exception is DNS lookups of the reserved names `origin.test` and `proxy.invalid`, which are sent to your resolver and fail by design.

## Get the files

Download [fix-node-fetch-failed-proxy-lab.zip](https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/fix-node-fetch-failed-proxy-lab.zip), unzip it and change into the `fix-node-fetch-failed-proxy-lab` folder it creates. It holds every file listed at the end of this README. To fetch the files one by one into the current folder instead:

```sh
for f in README.md package.json package-lock.json runtimes.json get-runtimes.mjs run-matrix.mjs run-checks.mjs cell.mjs cause-forms.mjs print-cause.mjs compare.mjs results.json results.csv results-extra.json results-extra.csv results-long-hang.json results-long-hang.csv results-checks.json results-checks.csv; do
  curl -fsSO "https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/$f"
done
```

## Run it

```sh
npm ci                                   # undici 5.29.0, 6.28.1, 7.29.1, 8.11.0 as undici5..undici8, plus 7.16.0, 7.26.0, 7.27.0
node get-runtimes.mjs                    # Node v22.23.3, v24.21.0, v26.10.0 from nodejs.org/dist, SHA-256 checked, unpacked into ./runtimes
cp results.json results.published.json   # keep the published results before run-matrix.mjs overwrites them
node run-matrix.mjs                      # about 5 minutes; rewrites results.json and results.csv
node compare.mjs results.published.json results.json
cp results-extra.json results-extra.published.json
node run-matrix.mjs --suite=extra        # under a minute; rewrites results-extra.json and results-extra.csv
node compare.mjs results-extra.published.json results-extra.json
cp results-checks.json results-checks.published.json
node run-checks.mjs                      # about a minute; rewrites results-checks.json and results-checks.csv
node compare.mjs results-checks.published.json results-checks.json
```

The main and extra suites use the four `dependencies` (aliases `undici5` to `undici8`). The three undici 7 releases before and at the Node 26 global-dispatcher fix are `devDependencies` under the aliases `undici7-16`, `undici7-26` and `undici7-27`, used only by the `undici7-global` checks. Plain `npm ci` installs them; with `--omit=dev` or `NODE_ENV=production` it skips them, and `run-checks.mjs` stops with `missing undici7-16; run: npm ci`. `npm run checks:undici7` runs only that group (54 cells, a few seconds).

`compare.mjs` compares each cell's outcome, route, layer, cause-chain signature and the count buckets (0, 1, 2-4 or 5+) of CONNECT and forwarded requests, plus the recorded logging checks in the checks suite. It ignores timings and exits with status 1 if any cell differs.

A rerun can differ on a few `blackhole` cells. On macOS loopback the kernel gives up on a dropped SYN after about 7.8 seconds, and undici's own connect timer fires at 10 seconds. On a busy host the OS timeout can arrive late, so an occasional cell reports `UND_ERR_CONNECT_TIMEOUT` (after about 10 s) instead of `ETIMEDOUT`. In one clean rerun of this lab, an undici 5.29.0 cell ended that way with a bare `Connect Timeout Error` after 10.04 s. Rerun those cells with `--filter` on an idle machine before you read anything into the difference.

**Optional long run** (about 5.5 minutes of wall-clock time, 63 cells). This shows what a proxy that accepts the CONNECT and never answers eventually produces when the caller sets no deadline. Keep the machine awake for the whole run. On macOS, prefix the command with `caffeinate -i`. A host that sleeps mid-run leaves cells unsettled at the deadline.

```sh
cp results-long-hang.json results-long-hang.published.json
node run-matrix.mjs --filter='\|hang\|' --deadline-ms=330000 --concurrency=64 --out=results-long-hang.json --csv=results-long-hang.csv
node compare.mjs results-long-hang.published.json results-long-hang.json
```

`run-matrix.mjs` options:

| Option | Default | Meaning |
|---|---|---|
| `--suite` | `main` | `main` (the 591-cell matrix) or `extra` (the 282-cell suite described below) |
| `--deadline-ms` | 15000 | Stop a cell that has not settled after this long |
| `--close-deadline-ms` | 6000 | The same, for the `close` fixture |
| `--concurrency` | 6 | Cells run in parallel. `close` cells always run one at a time, after the others |
| `--filter` | none | Regular expression matched against cell ids such as `v26.10.0\|dispatcher\|undici6@6.28.1\|ok\|proxy-only-name` |
| `--out`, `--csv` | `results.json`, `results.csv` (`results-extra.*` for the extra suite) | Output files, relative to this folder |
| `--raw` | none | Folder for one raw JSON file per cell (child stdout/stderr, exit status) |

`run-checks.mjs` takes the same `--deadline-ms`, `--concurrency`, `--filter`, `--out`, `--csv` and `--raw` options (defaults `results-checks.json` and `results-checks.csv`), plus `--group`, a regular expression matched against the group names listed under "Checks suite" below. Its cell ids start with the group, for example `status\|v26.10.0\|dispatcher\|undici6@6.28.1\|status403\|proxy-only-name`.

Nothing is installed system-wide. Packages go to `./node_modules` and runtimes to `./runtimes`. The CAs and certificates are written to a temporary folder that is deleted at the end. They are trusted only by the child processes, through `NODE_EXTRA_CA_CERTS`. Child processes get a minimal environment: `PATH`, `NODE_EXTRA_CA_CERTS` and only the proxy settings a cell needs. Your own `HTTPS_PROXY`, `NO_PROXY` or `NODE_OPTIONS` therefore can't leak into the results. No credentials are used, apart from the throwaway string `s3cret` inside the synthetic error of the `log-forms` checks.

`npm ci` reports "1 high severity vulnerability" (npm 11.19.0). It is one vulnerable package, `undici`, installed under four of the aliases: `undici5` (5.29.0), `undici7-16`, `undici7-26` and `undici7-27`. `npm audit` matches it to 18 advisories for undici up to 6.27.0 and 7.0.0 to 7.28.0: 7 high, 9 moderate and 2 low. The lab pins old undici versions on purpose to reproduce version skew, and they only talk to the local fixtures. Don't copy these pins into an application.

## What the matrix covers

| Wiring | What the cell does |
|---|---|
| `dispatcher` | `fetch(url, { dispatcher: new ProxyAgent(proxy) })`, with the runtime's built-in `fetch` and `ProxyAgent` from npm undici |
| `global-dispatcher` | `setGlobalDispatcher(new ProxyAgent(proxy))` from npm undici, then the built-in `fetch(url)` |
| `undici-fetch` | `fetch` and `ProxyAgent`, both imported from the same npm undici |
| `env-agent-global` | `HTTPS_PROXY` set; `setGlobalDispatcher(new EnvHttpProxyAgent())` from npm undici, then the built-in `fetch` |
| `dispatcher1-wrapper` | undici 8.11.0 only: `fetch(url, { dispatcher: new Dispatcher1Wrapper(new ProxyAgent(proxy)) })` |
| `env-none` | `HTTPS_PROXY` set, with no opt-in |
| `env-var` | `HTTPS_PROXY` plus `NODE_USE_ENV_PROXY=1` |
| `env-flag` | `HTTPS_PROXY` plus `node --use-env-proxy` |
| `env-api` | `HTTPS_PROXY` set; `http.setGlobalProxyFromEnv()` is called before `fetch` |

For `http://` URLs in the extra suite, the environment-based wirings set `HTTP_PROXY` instead of `HTTPS_PROXY`.

| Fixture | Proxy behavior |
|---|---|
| `ok` | Answers CONNECT with 200 and splices the tunnel to a local origin. In the extra suite it also forwards an absolute-form request (`GET http://...`) to a local plain-HTTP origin. The cell checks for status 200 and the exact body |
| `auth407` | Answers CONNECT (or a forwarded request) with `407 Proxy Authentication Required` and `Proxy-Authenticate: Basic` |
| `refused` | Nothing listens on the proxy port |
| `close` | Accepts TCP, reads the CONNECT (or forwarded request) and closes without a reply |
| `drop` | Answers CONNECT with 200, then closes before any tunnel bytes |
| `hang` | Accepts TCP, reads the CONNECT and never answers. The request has no deadline |
| `hang-signal` | Same as `hang`, but the request carries `AbortSignal.timeout(2000)` |
| `blackhole` | The TCP handshake to the proxy never completes: a listener with a full accept queue makes the kernel drop the SYNs |
| `blackhole-ct3s` | Same as `blackhole`, but the npm agents are built with `connectTimeout: 3000`. Built-in wirings get `n/a` |
| `refused-localhost` | Extra suite only: the proxy URL is `http://localhost:<port>`, and nothing listens on `::1` or `127.0.0.1` |

In the main matrix, every cell requests `https://origin.test/`. That reserved name resolves only through the lab proxy, so a direct attempt fails at DNS. The environment-based wirings run the `ok` fixture a second time against `https://127.0.0.1:<origin port>/`, which is reachable without the proxy. That second run exposes silent bypass: a direct request that succeeds while the proxy log stays at zero.

The extra suite runs:

- every wiring against `http://origin.test/` with the `ok`, `auth407` and `close` fixtures, plus `http://127.0.0.1:<http origin port>/` for the environment-based wirings (213 cells)
- every wiring against `https://origin.test/` with the `refused-localhost` fixture (63 cells)
- `env-none` (proxy set, no opt-in) against `https://127.0.0.1:<port>/` with nothing listening and against the never-accepting listener, to show errors that name the destination instead of the proxy (6 cells)

## Reading a cell

| Field | Meaning |
|---|---|
| `outcome` | `ok` (200 with the expected body), `error` (fetch rejected), `no-settle` (the harness stopped the cell), `not-applicable` (API missing in that runtime or package) |
| `route` | `proxy`: the lab proxy saw a TCP connection, or the error names the proxy address. `direct`: the origin saw the request with no proxy connection, the error names the destination's address, or `origin.test` failed DNS. `none`: no network I/O observed. `unknown`: the cell never settled against the blackhole |
| `proxy` | `tcp`, `connects` and `nonConnect` (forwarded, absolute-form requests) counts seen by that cell's proxy, and `requests`, the distinct request lines. `null` for blackhole fixtures, which never accept |
| `chain` | One entry per `err.cause` depth: `depth`, `name`, `code`, `message`, `errors` (for an `AggregateError`) and `type` (the constructor name). Ports are replaced with placeholders |
| `layer` | Label derived from the observed strings, route and counts only, never from the fixture name |
| `killedFor` | `deadline`, or `retry-loop` if the proxy saw 25 requests for one fetch |
| `warnings` | Warning lines the child wrote to stderr |

## Results from 2026-09-24 (macOS 15.7.4 arm64)

All 591 main cells were classified, with no crashes, no unexpected responses and nothing unclassified. In 449 cells the request got as far as the injected proxy behavior, 65 of them the working tunnel of the `ok` fixture. In every one of them, the layer derived from the error strings and proxy counts matched that behavior. The other 142 cells never got that far, because of a version mismatch, a bypassed proxy or a missing API. The same classifications came out of the first published run on 2026-09-23, made with an earlier revision of the scripts.

### Which wiring reached the origin through the proxy (`ok` fixture, `https://origin.test/`)

| Wiring (npm undici) | Node v22.23.3 (undici 6.28.1) | Node v24.21.0 (undici 7.29.1) | Node v26.10.0 (undici 8.10.2) |
|---|---|---|---|
| `dispatcher` 5.29.0 / 6.28.1 | proxied | proxied | `invalid onError method`, 0 proxy connections |
| `dispatcher` 7.29.1 | proxied | proxied | proxied |
| `dispatcher` 8.11.0 | `invalid onRequestStart method`, 0 proxy connections | `invalid onRequestStart method`, 0 proxy connections | proxied |
| `global-dispatcher` 5.29.0 / 6.28.1 | proxied | proxied | **went direct** (0 CONNECTs, `ENOTFOUND origin.test`) |
| `global-dispatcher` 7.29.1 / 8.11.0 | proxied | proxied | proxied |
| `undici-fetch` 5.29.0 to 8.11.0 | proxied | proxied | proxied |
| `env-agent-global` 5.29.0 | n/a (not exported) | n/a | n/a |
| `env-agent-global` 6.28.1 | proxied | proxied | **went direct** |
| `env-agent-global` 7.29.1 / 8.11.0 | proxied | proxied | proxied |
| `dispatcher1-wrapper` 8.11.0 | proxied | proxied | proxied |
| `env-none` (no opt-in) | **went direct** | **went direct** | **went direct** |
| `env-var`, `env-flag` | proxied | proxied | proxied |
| `env-api` | n/a (`http.setGlobalProxyFromEnv` is not a function) | proxied | proxied |

Against the directly reachable target, `env-none` on all three runtimes returned 200 with zero proxy connections, and so did `env-agent-global` 6.28.1 on v26.10.0. The request succeeded, and nothing indicated that the proxy was skipped. With the opt-in on, the same target went through the proxy: the opt-in without `NO_PROXY` does not exempt 127.0.0.1.

The Node 26 bypass comes from where each version stores the global dispatcher. undici 5.29.0 and 6.28.1, and undici 7 before 7.27.0, store it only under `Symbol.for('undici.globalDispatcher.1')`. undici 7.27.0 and later 7.x ([PR #5319](https://github.com/nodejs/undici/pull/5319), released 2026-06-01) and 8.11.0 write both `.1` and `.2`. The `undici7-global` checks below run 7.16.0, 7.26.0 and 7.27.0 through the same wirings. After one `fetch`, Node v26.10.0 had an `Agent` in `.2` and a `Dispatcher1Wrapper` in `.1`, while Node v22.23.3 had used only `.1`. In this lab, v26.10.0's `fetch` ignored a dispatcher stored only in `.1`. undici 8.11.0's `getGlobalDispatcher()` likewise reads `.2`.

### String at depth N, and the layer it points to (main matrix, `https://` URLs)

Depth 0 is the error that `fetch` rejects with; depth N is `err.cause` followed N times. Each row is one distinct chain; the number of cells that produced it is in brackets.

| Depth 0 | Depth 1 | Depth 2 | Layer | Seen in |
|---|---|---|---|---|
| `TypeError: fetch failed` | `InvalidArgumentError` (`UND_ERR_INVALID_ARG`): `invalid onError method` | | npm undici 5/6 dispatcher handed to a fetch that bundles undici 8. Fails before any network I/O: 0 proxy connections in the 14 cells whose proxy could count them (the 4 blackhole cells cannot) | v26.10.0 `dispatcher` 5.29.0/6.28.1 [18] |
| `TypeError: fetch failed` | `InvalidArgumentError` (`UND_ERR_INVALID_ARG`): `invalid onRequestStart method` | | npm undici 8 dispatcher handed to a fetch that bundles undici 6 or 7. Fails before any network I/O, as above | v22.23.3 and v24.21.0 `dispatcher` 8.11.0 [18] |
| `TypeError: fetch failed` | `Error` (`ENOTFOUND`): `getaddrinfo ENOTFOUND origin.test` | | The proxy was never used; the direct DNS lookup of the target failed. Proxy CONNECT count: 0 | `env-none`; v26.10.0 `global-dispatcher` 5.29.0/6.28.1 and `env-agent-global` 6.28.1 [51] |
| `TypeError: fetch failed` | `Error` (constructor `DOMException`): `Request was cancelled.` | `AbortError` (`UND_ERR_ABORTED`, constructor `RequestAbortedError`): `Proxy response (407) !== 200 when HTTP Tunneling` | Proxy authentication: the proxy answered CONNECT with 407. The status is visible only at depth 2 | every wiring that reached the proxy [49] |
| `TypeError: fetch failed` | `Error` (`ECONNREFUSED`): `connect ECONNREFUSED 127.0.0.1:<proxy-port>` | | Nothing is listening at the proxy address | [49] |
| `TypeError: fetch failed` | `Error` (`ETIMEDOUT`): `connect ETIMEDOUT 127.0.0.1:<proxy-port>` | | The TCP handshake to the proxy never completed, and the OS gave up first (about 7.8 s on macOS loopback) | [77] |
| `TypeError: fetch failed` | `ConnectTimeoutError` (`UND_ERR_CONNECT_TIMEOUT`): `Connect Timeout Error (attempted address: 127.0.0.1:<proxy-port>, timeout: 3000ms)` | | Same fault, but undici's `connectTimeout` fired before the OS gave up | npm undici 8.11.0 agents with `connectTimeout: 3000` [13] |
| `TypeError: fetch failed` | `ProxyConnectionError` (`UND_ERR_PRX_CONN`): `Proxy Connection failed` | `SocketError` (`UND_ERR_SOCKET`): `other side closed` | The proxy closed the connection before answering CONNECT | every npm undici 8.11.0 agent that reached the proxy; built-in env proxy on v26.10.0 [16] |
| no error | | | Same fault on undici 7.29.1 and older: the client reconnects in a loop. The harness stopped each cell at 25 CONNECTs, 0.10 to 0.14 s after the process started in this run | npm undici 5/6/7 agents that reached the proxy; built-in env proxy on v22.23.3 and v24.21.0 [33] |
| `TypeError: fetch failed` | `Error` (`ECONNRESET`): `Client network socket disconnected before secure TLS connection was established` | | The proxy answered 200, then closed the tunnel during the TLS handshake with the target | [49] |
| no error after 15 s | | | The proxy accepted CONNECT and never answered | [49] |
| `TypeError: fetch failed` (after 301 to 302 s) | `HeadersTimeoutError` (`UND_ERR_HEADERS_TIMEOUT`): `Headers Timeout Error` | | Same fault, in the optional long run: undici's `headersTimeout` (documented default 300 s) eventually fires | every wiring that reached the proxy, `results-long-hang.json` [49] |
| `TimeoutError: The operation was aborted due to timeout` (constructor `DOMException`) | | | Your own `AbortSignal.timeout()` fired. There is no `fetch failed` wrapper and no cause | [49] |

### Extra suite: plain `http://` URLs, a `localhost` proxy URL and unreachable destinations

**Which request the proxy received for `http://origin.test/`.** Every undici 8 agent that reached the proxy (npm 8.11.0 on all three runtimes, and the built-in env proxy of v26.10.0) sent the request itself in absolute form, `GET http://origin.test/...`, with no CONNECT. undici 5.29.0, 6.28.1 and 7.29.1 agents, and the built-in env proxy of v22.23.3 and v24.21.0, sent `CONNECT origin.test:80` and tunneled. The version-skew failures and the Node 26 global-dispatcher bypass were the same as for `https://` URLs.

| Fixture, `http://` URL | CONNECT (undici 7.29.1 and older) | Forwarded, no CONNECT (undici 8) |
|---|---|---|
| `ok` | proxied [33] | proxied [16] |
| `auth407` | depth 2 `Proxy response (407) !== 200 when HTTP Tunneling` under depth 1 `Request was cancelled.` [33] | depth 1 `InvalidArgumentError` (`UND_ERR_INVALID_ARG`): `Proxy Authentication Required (407)` [16] |
| `close` | no error, reconnect loop stopped at 25 CONNECTs [33] | depth 1 `SocketError` (`UND_ERR_SOCKET`): `other side closed` [16] |

On the forwarded path, a proxy that works logs one `GET http://...` line and no CONNECT. "No CONNECT in the proxy log" is therefore not proof of a bypass for `http://` URLs; look for any request line. With the `http://127.0.0.1:<port>/` target, `env-none` on all three runtimes and `env-agent-global` 6.28.1 on v26.10.0 returned 200 while the proxy logged nothing.

**`localhost` proxy URL, nothing listening.** On all three runtimes, every wiring that reached the connect step (49 cells) failed with depth 1 `AggregateError` (`ECONNREFUSED`) with an empty message, and its `errors` list held `connect ECONNREFUSED ::1:<proxy-port>` and `connect ECONNREFUSED 127.0.0.1:<proxy-port>`. Node tried both address families of `localhost`.

**Bypassed proxy, unreachable destination.** With `HTTPS_PROXY` set and no opt-in, the proxy logged nothing. A refused destination produced depth 1 `connect ECONNREFUSED 127.0.0.1:<dest-port>` within about 20 ms, and the never-accepting destination produced depth 1 `connect ETIMEDOUT 127.0.0.1:<dest-port>` after about 7.8 s, on all three runtimes. The address in these messages is the destination's, not the proxy's.

Other observations:

- **Node v22.23.3's built-in proxy support prints a warning.** With `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, the process wrote `[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.` to stderr. npm undici 6.28.1's `EnvHttpProxyAgent` printed the same warning on every runtime. v24.21.0 and v26.10.0 printed nothing for their built-in proxy support.
- **Not every cause chain appears in every version.** The 407 chain (for `https://` URLs), the refused chain and the TLS-drop chain were identical on every wiring that reached the proxy. The close-before-reply fault splits: undici 8 paths (npm 8.11.0, and Node 26's bundled 8.10.2) fail with `UND_ERR_PRX_CONN` for a CONNECT, while undici 7.29.1 and older retry without an error.
- **`connectTimeout: 3000` only affected undici 8.11.0.** npm 5.29.0, 6.28.1 and 7.29.1 agents built with the same option still ended at the OS `ETIMEDOUT` after about 7.8 s (28 cells). Their `ProxyAgent` builds the connection to the proxy from `proxyTls` options only.

## Checks suite (`run-checks.mjs`, results from 2026-09-24)

`run-checks.mjs` reuses the same runtimes, npm packages, wirings and `cell.mjs`, and writes `results-checks.json` and `results-checks.csv` in the same cell format, with an extra `group` field. Every listening fixture behaves like a plain-HTTP forward proxy: input that is not an HTTP request line, such as a TLS ClientHello, gets `400 Bad Request` and a close (`proxy.notHttp` counts those connections).

| Group | Cells | What the proxy or the cell does |
|---|---|---|
| `status` | 504 | Answers CONNECT, or a forwarded `http://` request, with 403, 429 (with `Retry-After: 30`), 502 or 503 and a short text body. Every wiring, for `https://origin.test/` and `http://origin.test/` |
| `proxy-dns` | 63 | The proxy URL is `http://proxy.invalid:3128`, a reserved name that never resolves |
| `https-scheme` | 126 | The proxy URL is `https://127.0.0.1:<port>` or `https://localhost:<port>`, but the proxy speaks plain HTTP |
| `inspect` | 252 | A TLS-inspecting stand-in: CONNECT 200, then the proxy terminates TLS with its own `origin.test` certificate from a throwaway inspection CA (sent with or without the CA certificate) and relays to the origin. The child trusts only the lab CA, or the lab CA plus the inspection CA through `NODE_EXTRA_CA_CERTS` |
| `optin-dispatcher` | 12 | `NODE_USE_ENV_PROXY=1` with `HTTPS_PROXY` pointing at a port where nothing listens, plus a per-request npm `ProxyAgent` to the working proxy |
| `loop-deadline` | 15 | The `close` fixture, with `AbortSignal.timeout(2000)` and no loop limit, for the `dispatcher` and `env-var` wirings. These cells run last, one at a time |
| `log-forms` | 3 | The `auth407` fixture with `NODE_USE_ENV_PROXY=1`; the child is `cause-forms.mjs`, which records what common ways of logging the error show |
| `undici7-global` | 54 | The `ok` fixture with npm undici 7.16.0, 7.26.0 and 7.27.0 and the `dispatcher`, `global-dispatcher` and `env-agent-global` wirings, for `https://origin.test/` and for `https://127.0.0.1:<origin port>/`, which is reachable without the proxy |

Results (recorded 2026-09-24T03:02Z). In 803 cells the request got as far as the injected behavior, and the layer derived from the strings and counts matched it in all 803. The other 226 cells stopped earlier in the ways the main matrix shows: version mismatch (68), a missing API (60) or a proxy that was never used (98: 94 ended in `getaddrinfo ENOTFOUND origin.test`, and 4 returned 200 from the directly reachable origin with zero proxy connections). The first 975 cells, every group except `undici7-global`, classified the same way as an earlier run of the same groups at 02:14Z.

- **Proxy error statuses.** For `https://` URLs, every wiring that reached the proxy failed with depth 2 `AbortError` (`UND_ERR_ABORTED`): `Proxy response (NNN) !== 200 when HTTP Tunneling` under depth 1 `Request was cancelled.`, with the proxy's status in the parentheses (49 cells for each of 403, 429, 502 and 503). For `http://` URLs, undici 7.29.1 and older paths sent `CONNECT origin.test:80` and failed the same way (33 cells per status). undici 8 paths sent `GET http://origin.test/...` without CONNECT, and `fetch` did not reject: it resolved with the proxy's status and body, and the origin never saw the request (16 cells per status). The error carries only the status code; the proxy's headers and body are not in the chain.
- **Proxy host name that does not resolve.** Depth 1 `Error` (`ENOTFOUND`): `getaddrinfo ENOTFOUND proxy.invalid` (49 cells). The same shape as a bypassed proxy's `getaddrinfo ENOTFOUND origin.test`; only the host name differs.
- **`https://` proxy URL, plain-HTTP proxy.** Depth 1 `Error` (`ERR_SSL_WRONG_VERSION_NUMBER`) with an OpenSSL message that contains `wrong version number`, in the form `<openssl-id>:error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:../deps/openssl/openssl/ssl/record/methods/tlsany_meth.c:78:` (85 cells: every `localhost` cell, and the IP-address cells on v22.23.3 and v24.21.0, where every path except undici 5.29.0's also printed a `DEP0123` deprecation warning). On v26.10.0 with the IP address, 13 wirings 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'`. undici 5.29.0's own `fetch` on v26.10.0 reached the handshake and got `wrong version number` instead.
- **TLS-inspecting proxy.** With the CA certificate sent: depth 1 `self-signed certificate in certificate chain` (`SELF_SIGNED_CERT_IN_CHAIN`, 49 cells). Leaf only: `unable to verify the first certificate` (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, 49 cells); v24.21.0 and v26.10.0 append `; if the root CA is installed locally, try running Node.js with --use-system-ca`. With the inspection CA in `NODE_EXTRA_CA_CERTS`, all 98 cells returned 200 through the proxy.
- **Opt-in plus a per-request agent.** Every request that ran went through the per-request agent: 8 cells returned 200 with one CONNECT each to the working proxy, and none failed on the dead `HTTPS_PROXY` port. The opt-in did not help the mismatched agents: undici 5.29.0 and 6.28.1 still failed with `invalid onError method` on v26.10.0, and 8.11.0 with `invalid onRequestStart method` on v22.23.3 and v24.21.0.
- **Reconnect loop under a caller deadline.** In this group, every undici 7.29.1 and older path that reached the proxy (9 cells) rejected after 2.0 s with a bare `TimeoutError: The operation was aborted due to timeout`, and the proxy logged 18,600 to 20,548 CONNECTs for that one request on loopback. The two undici 8 paths failed at once with `UND_ERR_PRX_CONN`.
- **undici 7 before 7.27.0 as the global dispatcher.** On v26.10.0, `setGlobalDispatcher(new ProxyAgent(proxy))` and `setGlobalDispatcher(new EnvHttpProxyAgent())` from undici 7.16.0 and 7.26.0 had no effect on the built-in `fetch` (8 cells): `https://origin.test/` failed with depth 1 `getaddrinfo ENOTFOUND origin.test`, and the reachable `https://127.0.0.1` origin returned 200, both with zero proxy connections. The same calls from 7.27.0 proxied on v26.10.0. On v22.23.3 and v24.21.0 all three versions proxied through both global wirings, and as per-request dispatchers all three proxied on every runtime (46 cells in all). The `lib/global.js` of 7.16.0 and 7.26.0 defines only `Symbol.for('undici.globalDispatcher.1')`; 7.27.0 writes `.1` and `.2`. undici 7's `EnvHttpProxyAgent` printed no experimental warning.
- **Logging forms** (all three runtimes, identical):

| How the error was logged | Shows `Request was cancelled.` | Shows `Proxy response (407)` | Stack trace |
|---|---|---|---|
| `err.message`, `String(err)` | no | no | no |
| `err.cause.message`, `String(err.cause)`, `` `${err.cause}` `` | yes | no | no |
| `JSON.stringify(err)` (prints `{}`) | no | no | no |
| `console.log(err.cause)`, `console.log(err)` | yes | yes | yes |
| `printCauseChain(err)` | yes | yes | no |

  Then `cause-forms.mjs` prints a synthetic error whose cause message contains a URL with the throwaway password `s3cret`, first through `printCauseChain` and then as an uncaught rethrow. The printer output showed `//***@proxy.invalid` and not the password; Node's uncaught-exception output showed the password in its `[cause]`, with stack traces, and the process exited with status 1. `results-checks.json` records only these booleans, not the text.

## Cause-chain printer

`print-cause.mjs` walks `err.cause` and prints the name, code and message at each depth, plus the `errors` list of an `AggregateError`. It reads nothing else: no headers, bodies or stack traces. It masks:

- URL userinfo after `//` (`http://user:password@host` prints as `http://***@host`)
- `Bearer` tokens
- values after `:` or `=` whose key contains `authorization`, `cookie`, `token`, `secret`, `password`, `passwd`, `api-key` or `api_key`, including quoted JSON pairs such as `"password":"..."`

Anything else passes through, for example userinfo without a `//` in front of it. Regex masking cannot recognize every secret format, so read the output before you share it.

```js
import { printCauseChain } from './print-cause.mjs';

try {
  await fetch(url);
} catch (err) {
  printCauseChain(err);
  process.exitCode = 1; // don't rethrow err unhandled: Node would print its unmasked cause (see the log-forms checks)
}
```

Output for the 407 fixture 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
```

`causeChain(err)` returns the same data as an array. Every cell in the matrix and the checks suite records its chain through this function.

## Limitations

- One host: macOS 15.7.4 on arm64. The timeout strings depend on the operating system. On macOS loopback, the kernel gives up on a dropped SYN after about 7.8 s, which is before undici's default 10 s connect timeout. That is why `UND_ERR_CONNECT_TIMEOUT` appears here only with `connectTimeout: 3000`, apart from the occasional busy-host flip described above. Other systems and real networks can show the undici timeout instead. That was not tested here.
- Plain HTTP proxies only. The `https-scheme` checks point an `https://` proxy URL at a plain-HTTP proxy that answers the TLS handshake with an HTTP 400; a proxy that closes the connection instead can produce a different TLS error. `http://` destinations were run against the `ok`, `auth407`, `close` and status fixtures. Not tested: real HTTPS proxies, SOCKS, proxies that accept credentials, 504 and other statuses, `NO_PROXY` matching and `http.request`.
- The proxies and destinations used single IP addresses, apart from the `localhost` cells, which were refused. undici 8 (8.10.2 in v26.10.0, npm 8.11.0) reports `Connect Timeout Error (attempted addresses: ..., timeout: <configured>ms)` whenever a host name's addresses all fail and one of them timed out, whichever timer fired, with the `AggregateError` as its cause (`maybeNormalizeConnectError` in `lib/core/connect.js`). The lab never produced that form.
- The TLS-inspecting fixture is a stand-in that presents its own certificate for the destination. Real inspection products differ in which certificates they send.
- The harness stops retry loops after 25 requests (the `loop-deadline` checks have no limit), `hang` cells after 15 s and `close` cells after 6 s. The optional long run observes the headers timeout.
- This is a pinned snapshot: undici and Node.js release often. Re-run the lab before you rely on a specific cell.
- Everything here comes from synthetic loopback fixtures. None of it describes a real proxy service.

## Method and run history

- **Host and runtimes.** One macOS 15.7.4 arm64 host, on 23 and 24 September 2026. The runtimes are the official Node.js v22.23.3, v24.21.0 and v26.10.0 tarballs, each checked against its release's SHASUMS256.txt, with bundled undici 6.28.1, 7.29.1 and 8.10.2 according to `process.versions.undici`. No external provider or network was tested.
- **Main matrix arithmetic.** 3 runtimes x 21 wiring and npm-version combinations (17 npm-agent combinations across the five npm wirings, plus the 4 built-in wirings) x 9 fixtures = 567 cells, plus 24 cells of the environment-based wirings against the directly reachable target: 591.
- **Targets.** `origin.test` uses the `.test` top-level domain, which RFC 6761 section 6.2 reserves for testing. Only the lab proxy resolves it, so a direct attempt fails at DNS. `proxy.invalid` uses `.invalid`, which section 6.4 reserves as never resolving.
- **Harness limits.** Cells stop at 15 s (6 s for `close`), and reconnect loops at 25 requests. The optional long run allows 330 s.
- **Published runs.** The main, extra and long-run results were recorded on 2026-09-24 between 01:04Z and 01:15Z with the `run-matrix.mjs`, `cell.mjs` and `print-cause.mjs` in this folder, and the checks results at 03:02Z with this `run-checks.mjs`. The main matrix and the long run classified every cell the same way as the first runs on 2026-09-23, which used an earlier revision of the harness.
- **Earlier clean reruns.** Four clean reruns from earlier archives of this lab (only the archive, commands as in this README) classified every cell they reran the same way as the results published with them, except that two of them each had one main-matrix `blackhole` cell that ended with undici's connect timeout instead of `ETIMEDOUT`: once undici 5.29.0 with a bare message after 10.04 s, once undici 8.11.0 with `timeout: 10000ms`. Timings are excluded from `compare.mjs`.
- **Timings.** Timings in this README come from the published run unless stated. They vary by host and load: across the lab's runs the OS gave up on the never-completing handshake after 7.8 to 9.9 s, and the headers timeout fired after 301 to 304 s.

## Files

| File | Purpose |
|---|---|
| `README.md` | This file |
| `fix-node-fetch-failed-proxy-lab.zip` | All the files below in one archive |
| `package.json`, `package-lock.json` | Pinned npm undici versions (aliases `undici5` to `undici8`, plus `undici7-16`, `undici7-26` and `undici7-27` for the `undici7-global` checks) |
| `runtimes.json`, `get-runtimes.mjs` | Pinned Node.js versions; download and SHA-256 check against SHASUMS256.txt |
| `run-matrix.mjs` | Fixtures, the matrix, classification and output |
| `run-checks.mjs` | The checks suite: its fixtures, cells, classification and output |
| `cell.mjs` | One cell: one fetch under one wiring, one JSON line out |
| `cause-forms.mjs` | The child for the `log-forms` checks |
| `print-cause.mjs` | The cause-chain printer, also used by every cell |
| `compare.mjs` | Compares two results files by classification |
| `results.json`, `results.csv` | Published results of the main matrix |
| `results-extra.json`, `results-extra.csv` | Published results of the extra suite |
| `results-long-hang.json`, `results-long-hang.csv` | Published results of the optional long run |
| `results-checks.json`, `results-checks.csv` | Published results of the checks suite |
