# NO_PROXY matching lab

These files accompany https://ipvolt.com/blog/no-proxy-matching-tested. They record how 15 HTTP clients decided whether to use a proxy for the same `NO_PROXY` / `no_proxy` values. The run is dated 2026-09-23 and every result is synthetic: a local program ran on one Mac against a loopback proxy. No real proxy, provider, account or third-party website was involved.

## What was measured

A small HTTP proxy listens on `127.0.0.1`. It records every absolute-form request (`GET http://host/...`) and every `CONNECT`, then answers the request itself. It never forwards anything. Every client gets the same `http_proxy`, `HTTP_PROXY`, `https_proxy` and `HTTPS_PROXY` values, which point at that proxy. Each test changes only the `NO_PROXY` values. The one exception is a case that sets `http_proxy` to an empty string.

The test destinations are names under the reserved `.test` top-level domain and documentation addresses (`192.0.2.0/24`, `198.51.100.0/24`, `2001:db8::/32`). On macOS each client runs under `sandbox-exec` with the profile in `nonet.sb`. That profile denies outbound network access to anything except loopback. When a client skips the proxy, the sandbox refuses its DNS lookup or TCP connect at once, so nothing leaves the machine.

**Classification uses only the proxy log.** A cell is `proxy` when the lab proxy logged at least one request from that client run. Otherwise it is `not_proxied`. Whether the client got a response never decides the classification. Two more sources separate a real direct attempt from a client that failed before sending anything:

- `sandbox-denial`: the kernel logged a denied connect or DNS lookup for that process ID. macOS sometimes drops these lines, so this source is corroboration only.
- `client-error` / `origin-log`: the client's own error names a DNS or connect failure for the destination, or, for loopback rows, the local origin server logged the request.

The `observed` field combines these sources:

- `proxy`: the lab proxy logged the request.
- `direct`: the request was not proxied, and at least one of the sources above recorded a direct attempt.
- `no_request`: the request was not proxied, and no direct attempt was recorded. In this run, all 14 such cells are httpx or httpx2 raising `InvalidURL` while the client was being constructed.

"Direct" therefore means the client tried to resolve or connect to the destination itself. The sandbox stopped it on the machine, so no real host was reached. Loopback destinations (`127.0.0.1`, `localhost`, `[::1]`) appear only in the `loopback` section, where a local origin server records direct requests, because some clients treat loopback specially.

## The published run

- Date: 23 September 2026, 19:13 to 19:15 UTC, on macOS 15.7.4 (arm64).
- Size: 41 cases, 82 value-and-URL rows and 1,266 cells: 1,230 from the 15 clients and 36 from the four Node control clients described below.
- Controls: with no `NO_PROXY` set, all 15 clients proxied every URL shape. An unrelated entry, `other.test` for a request to `example.test`, did not change the result in any client.
- Reproduction: a clean rerun that followed the steps under "Reproduce" from this archive on 24 September 2026 classified all 1,266 cells identically.

## Clients (pinned)

| Client id | What runs |
|---|---|
| `curl-8.7.1` | `/usr/bin/curl` shipped with macOS 15.7.4 |
| `curl-8.22.0` | Homebrew curl |
| `wget-1.25.0` | GNU Wget (Homebrew) |
| `go-1.27.1-net-http` | Go 1.27.1 `http.Client` with the default transport (`http.ProxyFromEnvironment`) |
| `python-urllib-3.14.7` | `urllib.request.urlopen` on Python 3.14.7 |
| `requests-2.34.2`, `httpx-0.28.1`, `httpx2-2.13.1` | default environment handling |
| `aiohttp-3.14.3` | `ClientSession(trust_env=True)` |
| `node-26.10.0-fetch`, `node-26.10.0-http` | built-in `fetch()` and `http.get()` with `NODE_USE_ENV_PROXY=1` (bundled undici 8.10.2) |
| `node-24.21.0-fetch`, `node-24.21.0-http` | the same on Node 24.21.0 (bundled undici 7.29.1) |
| `undici-7.29.1-EnvHttpProxyAgent`, `undici-8.11.0-EnvHttpProxyAgent` | npm undici's own `fetch()` with `dispatcher: new EnvHttpProxyAgent()`, on Node 26.10.0 |

Four control clients run only the no-`NO_PROXY` baseline. They are Node 26 and Node 24 `fetch`/`http` without `NODE_USE_ENV_PROXY`, and they show that Node ignores the proxy variables unless the opt-in is set.

## Files

`no-proxy-matching-tested.zip` contains every file below in a `no-proxy-matching-tested/` folder, with the client files in `clients/`.

| File | Purpose |
|---|---|
| `no_proxy_lab.py` | setup, proxy/origin servers, test cases, runner, analysis, `compare` and `table` |
| `clients/py_client.py`, `clients/node_client.cjs`, `clients/go_client.go` | one request per process, one JSON line of output |
| `nonet.sb` | macOS sandbox profile (loopback only) |
| `requirements.txt`, `package.json`, `package-lock.json` | exact Python and npm pins (npm lockfile includes registry integrity hashes) |
| `results.json` | the published run: environment, cases, every cell, per-row comparison and summaries |
| `results.csv` | one row per cell |

The harness loads the three client files from a `clients/` folder next to `no_proxy_lab.py`. If you download the files one at a time instead of using the archive, recreate that folder. Otherwise `setup` fails when it builds the Go client, after it has already downloaded Node.js, the Python packages and the npm packages.

## Read the published results

`table` needs no setup and no network, and it does not need Python 3.14: it gave identical output with Python 3.9.6 and 3.14.7. This prints every row of the published run as a Markdown table:

```sh
python3 no_proxy_lab.py table results.json
```

Add `--section main` (or `baseline`, `variables`, `loopback`, `https`) to print one section.

## Reproduce

Requirements:

- macOS, because every client runs under `/usr/bin/sandbox-exec`. Elsewhere, `run` stops with `sandbox-exec not found`. See the note on `--isolation none` below.
- Python 3.14, run as `python3.14`. The virtual environment is created with the Python that runs `setup`.
- Go, found on `PATH` or at `/opt/homebrew/bin/go`, or passed with `setup --go PATH`.
- The curl and wget binaries you want to compare. The defaults are `/usr/bin/curl`, `/opt/homebrew/opt/curl/bin/curl` and `/opt/homebrew/bin/wget`. A missing default curl is skipped, and wget falls back to the one on `PATH`. Use `run --curl PATH` (repeatable) and `run --wget PATH` to choose others.
- About 700 MB of disk space, and HTTPS access to nodejs.org, pypi.org and registry.npmjs.org during `setup`.

Download and extract the archive, then run the harness from inside its folder:

```sh
curl -fLO https://ipvolt.com/downloads/no-proxy-matching-tested/no-proxy-matching-tested.zip
unzip no-proxy-matching-tested.zip
cd no-proxy-matching-tested
python3.14 no_proxy_lab.py setup --work ./work
python3.14 no_proxy_lab.py run --work ./work --out ./out
python3.14 no_proxy_lab.py compare results.json out/results.json
python3.14 no_proxy_lab.py table out/results.json --section main
```

`setup` only downloads from official registries, and it writes only inside `./work`:

- It downloads the official Node.js v26.10.0 and v24.21.0 tarballs from nodejs.org. Each tarball is checked against the release's `SHASUMS256.txt` and against the darwin-arm64 digests pinned in the script.
- It creates a Python virtual environment and installs `requirements.txt` from PyPI (wheels only).
- It runs `npm ci` with the downloaded Node 26 npm, against the lockfile and the public npm registry.
- It builds the Go client with the local `go`.

It installs nothing system-wide. The work directory needs about 700 MB, mostly the two extracted Node.js releases and the Go build cache.

`run` takes about two minutes and refuses to write into a non-empty `--out` directory. It first runs a sandbox self-test: an outbound connect must be denied, and a loopback connect must succeed. It then writes these files to `--out`:

- `results.json` and `results.csv`;
- `raw/lab-log.jsonl`: every proxy and origin log entry;
- `raw/sandbox-denials.jsonl`: the kernel denial lines, collected with `log show`.

`compare` checks the `route` (proxy log) and `observed` classification of every cell and ignores timing fields. It exits 0 only when both runs contain the same cells with the same classifications. Client ids include version numbers. If your curl, wget, Go or Python versions differ from the pins above, the client ids differ too, and `compare` reports those cells as present in only one run. That is a different experiment, not a failed reproduction.

To test other versions, change the pins before `setup`:

- Node.js: `NODE_VERSIONS` near the top of `no_proxy_lab.py`. The first entry also runs npm and the two npm undici clients. Versions without an entry in `NODE_PINNED_SHA256` are checked against the release's `SHASUMS256.txt` only.
- Python libraries: `requirements.txt`.
- npm undici: the `undici7` and `undici8` aliases in `package.json`. Keep those two alias names, because the harness looks them up. Then regenerate `package-lock.json`, for example with `npm install --package-lock-only`, because `setup` runs `npm ci`, which refuses a lockfile that does not match `package.json`.

On other operating systems, `--isolation none` runs without the sandbox, but direct attempts then really leave the machine. They are DNS lookups for `.test` names and TCP SYNs to documentation addresses. Use it only inside isolation you control, such as a network namespace or a container without network access. That mode was not used for the published results.

## Test your own NO_PROXY value

`run --case` only selects cases that already exist in `CASES` in `no_proxy_lab.py`. To test another value, add a case at runtime and run only that case with the unchanged harness. Save this as `my_cases.py` next to `no_proxy_lab.py`, after `setup`:

```python
import sys
import no_proxy_lab as L

# The article's export line, with example.test in place of example.com.
L.CASES.append({"id": "x-composed", "section": "main", "label": "article export line (example.test names)",
                "vars": L.both("localhost,127.0.0.1,example.test,.example.test,192.0.2.10"),
                "urls": [L.A, L.S, L.D, L.L, L.V4, L.V4B, L.O,
                         "http://127.0.0.1:{origin}/", "http://localhost:{origin}/"]})
# An exact IPv4 address next to an IPv4 range.
L.CASES.append({"id": "x-ipv4-exact-plus-cidr", "section": "main", "label": "exact IPv4 plus IPv4 CIDR",
                "vars": L.both("192.0.2.10,192.0.2.0/24"), "urls": [L.V4, L.V4B, L.V4OUT]})
sys.argv = ["no_proxy_lab.py", "run", "--work", "./work", "--out", "./out-mine",
            "--case", "x-composed", "--case", "x-ipv4-exact-plus-cidr"]
sys.exit(L.main())
```

Then run `python3.14 my_cases.py` and `python3 no_proxy_lab.py table out-mine/results.json`. Only your cases ran, so their rows come last in that table; the published rows above them show `-`. These two cases are the one-off checks the article reports; they ran on 24 September 2026 and are not part of the published `results.json`. To test your own value, replace the `vars` string and the `urls` list. `L.both(value)` sets `no_proxy` and `NO_PROXY` to the same value. `A`, `S`, `D`, `L` and `O` are `example.test`, `sub.example.test`, `a.b.example.test`, `notexample.test` and `other.test`; `V4`, `V4B` and `V4OUT` are `192.0.2.10`, `192.0.2.11` and `198.51.100.10`. Keep to reserved `.test` names and documentation addresses, and write loopback URLs with the `{origin}` placeholder so they reach the local origin server.

## Linux and other operating systems

Linux was not tested. Each client matches `NO_PROXY` in its own code or its language's standard library, not through an operating-system service. With the proxy variables set, CPython's `urllib` takes the same environment path on macOS as on Linux (`getproxies_environment` and `proxy_bypass_environment` in `Lib/urllib/request.py`). On Linux the rows should therefore follow the client version rather than the operating system. For example, a distribution's curl older than 8.9.0 treats spaces as separators, and one older than 8.18.0 ignores IPv6 CIDR entries. That is an inference from the source code, not a Linux result. Windows, where environment variable names are case-insensitive, was not tested either.

## Reading results.json

- `rows`: one entry per NO_PROXY value and request URL. `routes` maps each client to `proxy`, `direct` or `no_request`. `agreement` is true only when all 15 clients match.
- `summary.portable_rows` / `summary.divergent_rows`: the main-matrix rows that matched in every client, and the rows that did not.
- `summary.case_level`: whether every URL for a NO_PROXY value agreed.
- `summary.same_runtime_pairs`: differences between clients that share a runtime or library family, such as Node `fetch` and `http.get`, or curl 8.7.1 and 8.22.0.
- `summary.https_vs_http`: each HTTPS spot-check next to the HTTP cell with the same value and host. For HTTPS, the proxy logs the `CONNECT`, accepts it, and then resets the connection when the client starts a TLS handshake, so those clients report a TLS or connection error. That error is expected; the proxy log is what counts.
- `summary.http_target_proxy_mode`: whether a client sent an `http://` URL in absolute form (`forward`) or tunneled it with `CONNECT`.
- `summary.anomalies`: stray log entries, timeouts, proxied HTTP requests that did not receive the proxy's reply, and cells whose log entry pointed at a different cell. In the published run, every list is empty except the 14 `no_request` cells described above.
- `cells`: per-run detail. It includes the exact environment values, the proxy log entries, the evidence sources, the client's error text (without local paths), exit status and duration.

## Limits

- This is one macOS machine on one date, with the pinned versions above. Newer releases can change any row. undici 8.11.0, for example, changed wildcard handling and is not yet the undici bundled in Node 26.10.0. Go is changing which variable wins when `NO_PROXY` and `no_proxy` disagree: Go 1.27.1's `net/http` uses `NO_PROXY`, but `golang.org/x/net/http/httpproxy` prefers the lowercase names from x/net v0.58.0 (commit a02ddfa7ea, golang/go#79656), and the Go 1.28 release-note draft lists the same change for `ProxyFromEnvironment`. Rows with an empty lowercase variable are not affected, because Go still skips empty values. The harness tests only Go's `net/http`.
- Only environment-variable handling was tested, with the settings in the client table: aiohttp with `trust_env=True`, Node's built-in clients with `NODE_USE_ENV_PROXY=1`, and npm undici with `dispatcher: new EnvHttpProxyAgent()`. The run did not use explicit proxy arguments, curl `--noproxy`, any other agent or dispatcher, `trust_env=False`, Windows registry settings or macOS system proxy settings. The Python clients read environment proxies before macOS system settings, and every test set environment proxies.
- Only HTTP forward proxies were tested (absolute-form and CONNECT). SOCKS, PAC files, authentication and redirects were not.
- Matching was tested on the name or address in the URL. None of the 662 proxied cells in the published run triggered a sandbox DNS or connect denial. That strongly suggests no client resolved the destination before choosing the proxy. It is not complete proof, because the kernel log drops a small share of denial lines: 15 of the 513 non-loopback direct attempts in the published run had none. Entries that depend on resolving a name to an address were not tested.
- Not tested: `*example.com` without a dot, dash ranges such as `192.168.1.1-192.168.1.100`, partial addresses such as `192.168.*`, semicolons as separators, internationalized names, changing `no_proxy` while a process runs, and entries that depend on DNS resolution. Ruby, Java, .NET, Deno and Bun were not included. Only the darwin-arm64 Node.js digests are pinned in the script.
- The loopback proxy stands in for the proxy role only. These results say nothing about any real proxy service, its performance or its availability.
