Bun's fetch had no way to skip the proxy
Contents
Builds on The model that only says RUN, which describes the fake model server and the six agent harnesses these templates set up.
Bun's documentation for fetch has a paragraph I only found after the fix was in: pass proxy: false "to connect directly regardless of the environment". The Bun my tests ran on, 1.3.10, accepts that option without complaint and then sends the request to the proxy anyway.
That gap cost my agents their models. The config templates I keep for six terminal coding agents find local model servers in two places. At install time a Python tool asks each candidate host for its model list and writes the result into the configs. At run time a TypeScript module does the same thing inside OpenCode, pi and omp. Same hosts, same question, two languages.
With HTTP_PROXY set, and a proxy that can't reach the model server on the local network, the Python tool found the models and the agents found none.
Why the Python tool kept working#
The install-time tool builds its URL opener with an empty proxy handler, on purpose. urllib reads the proxy variables by default; ProxyHandler({}) tells it there are none. The TypeScript module used fetch, which in Bun reads them too, and had nothing to tell it otherwise.
To reproduce it without my network, I put a fake model server on 127.0.0.2, a loopback address that stands in for a server elsewhere on the network and that no usual NO_PROXY entry (localhost, 127.0.0.1) covers, and pointed HTTP_PROXY at port 9, where nothing listens. A dead proxy fails fast and loudly, which is the most cooperative thing a proxy has ever done for me. The probe asks for the model list three ways: plain fetch, proxy: "", and proxy: false.
$ bun --version
1.3.10
$ HTTP_PROXY=http://127.0.0.1:9 bun probe_false.mjs
fetch() -> ConnectionRefused
fetch({proxy: ""}) -> ConnectionRefused
fetch({proxy: false}) -> ConnectionRefused
$ HTTP_PROXY=http://127.0.0.1:9 node probe_false.mjs
fetch() -> model-a,model-b
fetch({proxy: ""}) -> model-a,model-b
fetch({proxy: false}) -> model-a,model-b
$ NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://127.0.0.1:9 node --no-warnings probe_false.mjs
fetch() -> TypeError
fetch({proxy: ""}) -> TypeError
fetch({proxy: false}) -> TypeError
$ HTTP_PROXY=http://127.0.0.1:9 python3 -I urllib_probe.py
urllib default -> URLError [Errno 111] Connection refused
urllib ProxyHandler({}) -> model-a,model-b
ConnectionRefused is the dead proxy answering for the model server. Bun 1.3.10 went to the proxy in all three cases. An empty string didn't mean "no proxy", and false didn't either. An earlier try with proxy: "direct://" got a different error, UnsupportedProxyProtocol, which at least admits it read the option.
Node 22.22.0 ignores the proxy variables unless NODE_USE_ENV_PROXY=1 is set. The Node docs describe that variable as the equivalent of --use-env-proxy, and it prints an experimental warning (--no-warnings hides it above). With it set, Node's fetch takes the proxy every time too; the TypeError wraps an ECONNREFUSED. Node's fetch has no proxy option at all, so the second and third lines only show that it ignored one.
The same went for the older API. Bun 1.3.10 implements node:http on top of its fetch, and it went to the proxy. Node's node:http did too, once NODE_USE_ENV_PROXY was set:
$ HTTP_PROXY=http://127.0.0.1:9 bun nodehttp_probe.mjs
node:http -> ECONNREFUSED
$ NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://127.0.0.1:9 node --no-warnings nodehttp_probe.mjs
node:http -> ECONNREFUSED
A user's proxy settings are the user's business. A coding agent asking a model server on the local network for its model list is none of the proxy's business, and neither Bun 1.3.10 nor Node 22.22.0 let the module say so for one request.
A raw socket speaks HTTP/1.0#
What works in both runtimes is the layer under HTTP: node:net for plain connections and node:tls for HTTPS. Neither looks at proxy variables. The module now opens a socket to the host, sends an HTTP/1.0 request, and reads until the server closes the connection. HTTP/1.0 is the point: the server closes after one response, so "read to the end" is the whole framing logic. The module also keeps a chunked-transfer decoder in case a server answers with HTTP/1.1 framing anyway, and the whole socket client comes to about 40 lines.
Writing it turned up one more Bun surprise. The natural way to send a request and say "that's all" is socket.end(request): write the bytes, then half-close the sending side. This probe sends the same request both ways and lists the socket's events:
// HTTP/1.0 over a raw TCP socket, sent two ways: end(request) and write(request)
import net from "node:net";
const request = "GET /v1/models HTTP/1.0\r\nHost: 127.0.0.2\r\n\r\n";
function probe(mode) {
return new Promise(resolve => {
const events = [];
let bytes = 0;
const s = net.connect(18431, "127.0.0.2", () => {
events.push("connect");
if (mode === "end") s.end(request); else s.write(request);
});
s.on("data", d => { if (!events.includes("data")) events.push("data"); bytes += d.length; });
s.on("end", () => events.push("end"));
s.on("close", () => { events.push("close"); resolve(`${mode}(request): ${events.join(", ")}; ${bytes} bytes`); });
s.on("error", e => events.push("error " + e.code));
});
}
console.log(await probe("end"));
console.log(await probe("write"));
$ bun socket_probe.mjs
end(request): connect, end, close; 0 bytes
write(request): connect, data, end, close; 210 bytes
$ node socket_probe.mjs
end(request): connect, data, end, close; 210 bytes
write(request): connect, data, end, close; 210 bytes
On Bun 1.3.10, end(request) gets connect, end, close and no data: the socket closes before the reply arrives. Node gets all 210 bytes either way. write(request) works in both, and with HTTP/1.0 nothing more is needed, because the server ends the conversation itself.
How the fix is tested#
Two tests carry it. One starts discovery in a child process with HTTP_PROXY pointing at the dead port, and expects the fake server to be found. It fails on the old fetch code in Bun, and in Node with NODE_USE_ENV_PROXY=1. It runs in a child process for a reason that got a post of its own: on these versions, setting the variable inside the running test changed nothing.
The other covers the TLS branch. An HTTPS fake server uses a self-signed test certificate committed with the tests. Discovery finds it when NODE_EXTRA_CA_CERTS trusts that certificate and refuses it otherwise. With the TLS branch swapped for plain TCP, the trusted case fails, so the test does check the branch it is named after.
The trade-off is written into the docs: a model server that is only reachable through a proxy is no longer found at run time. The Python tool already behaved that way.
Bun 1.4.4 has the switch#
The proxy: false paragraph at the top of this post is from Bun's current fetch documentation, which also says Bun reads the proxy variables "each time fetch runs, so changes to process.env apply to the next request". Neither held on 1.3.10. Bun 1.4.4 was released on 2026-10-22, and I ran the same probes on it:
$ bun --version
1.4.4
$ HTTP_PROXY=http://127.0.0.1:9 bun probe_false.mjs
fetch() -> ECONNREFUSED
fetch({proxy: ""}) -> ECONNREFUSED
fetch({proxy: false}) -> model-a,model-b
$ HTTP_PROXY=http://127.0.0.1:9 bun nodehttp_probe.mjs
node:http -> 200 66 bytes
$ bun socket_probe.mjs
end(request): connect, data, end, close; 210 bytes
write(request): connect, data, end, close; 210 bytes
$ bun late_env.mjs
late HTTP_PROXY -> ECONNREFUSED
On 1.4.4, proxy: false connects directly, node:http skipped the proxy altogether, end(request) gets its reply, and a proxy variable set at run time applies to the next fetch. An empty string still means "use the environment". I didn't trace which release changed what. The raw socket stays anyway: Node 22's fetch has no proxy option to turn off, and a socket that never looks at the environment doesn't care which runtime version loaded it.
Filed under: ai-agents, nodejs, javascript, proxy, testing
Read next#
- Tests that could not fail: how the proxy test passed on the old code at first, and two more tests like it.
- The model that only says RUN: the fake model server behind the agent tests, and the six harnesses.
- The test rig was the unsafe part: how the end-to-end suite around the agents is isolated: one container per suite, no network.
Sources#
- Bun documentation: fetch, "Proxying requests": the
proxyoption,proxy: false, and reading the proxy variables on eachfetch(fetched 2026-10-26; describes the current release). - Node.js v22.22.0 CLI documentation:
--use-env-proxyandNODE_USE_ENV_PROXY=1. - npm registry: bun: release dates of 1.3.10 (2026-02-26) and 1.4.4 (2026-10-22).
- Bun 1.4.4 release: the binary used for the second set of runs.