Notes · 2026-10-05 · tested on VS Code 1.140.0

In a VS Code extension, http.request isn't Node's http.request

If your VS Code extension opens its own sockets and hands them to Node's HTTP client, check this. Inside the extension host, http.request is a different function, and it ignores the socket you give it.

I found out the expensive way. The first release of my REST client extension sent every HTTPS request as plain HTTP to port 443. Servers answered with 400 The plain HTTP request was sent to HTTPS port, or simply reset the connection (ECONNRESET). All my unit tests passed, because they run in plain Node, where none of this happens.

The pattern that breaks

A request tool wants control over the connection: which IP it dials, TLS options, timing for each phase. One documented way to get that is to open the socket yourself and pass it in with createConnection:

const socket = tls.connect({ host: 'example.com', port: 443, servername: 'example.com' });
socket.once('secureConnect', () => {
  const req = http.request({
    host: 'example.com', port: 443, path: '/',
    createConnection: () => socket,   // "use this socket"
  });
  req.end();
});

http (not https) is deliberate here: the socket is already TLS, so the client only has to speak HTTP over it. In plain Node this returns 200, and req.socket === socket.

What happens inside VS Code

Run the same code in the extension host (VS Code 1.140.0, Node 24.21.0, default settings):

ApproachPlain Node 22VS Code extension host
http.request({ …, createConnection })200, your socket used400 "plain HTTP request was sent to HTTPS port"
…plus agent: false400400
…with a custom http.Agent subclass that overrides createConnection200400
new http.ClientRequest({ …, createConnection })200200, your socket used

In the extension host, http.request.name is "patched". Raw output: VS Code · plain Node.

Why

VS Code ships proxy support for extensions in @vscode/proxy-agent. It replaces the module-level get and request functions on http and https with a wrapper. The core of it (v0.45.0, createHttpPatch), lightly trimmed:

const config = params.getProxySupport();          // the "http.proxySupport" setting, default "override"
const useProxySettings = !optionsPatched &&
  (config === 'override' || config === 'fallback' || (config === 'on' && originalAgent === undefined));
if (useProxySettings || addCertificatesV1) {
  const agent = createPacProxyAgent(resolveP, {
    originalAgent: (!useProxySettings || isLocalhost || config === 'fallback') ? originalAgent : undefined,
    …
  });
  agent.protocol = isHttps ? 'https:' : 'http:';
  options.agent = agent;
  return original(options, callback);
}

Under the default override setting, every call gets options.agent set to VS Code's proxy agent. For non-localhost hosts, any agent you passed is dropped.

That alone wouldn't matter, except for one line in Node's docs for http.request: createConnection is "a function that produces a socket/stream to use for the request when the agent option is not used."

So the patch adds an agent, Node ignores your createConnection, and VS Code's agent dials a fresh TCP connection to example.com:443. You called it from the http module, so the agent's protocol is http: and the request goes out unencrypted, on the HTTPS port.

The patch is doing its job: it makes the user's proxy settings and system certificates work for extensions that never think about proxies. It just doesn't expect an extension that brings its own socket.

What works

Construct http.ClientRequest directly. The class is what http.request calls internally, and the patch only replaces the module-level functions, so the constructor still honours createConnection:

const req = new http.ClientRequest({
  host: 'example.com', port: 443, path: '/',
  setHost: false,
  createConnection: () => socket,
});
req.setHeader('Host', 'example.com');

Things to know before copying this:

  • You opt out of VS Code's proxy handling for that request. For a tool that deliberately manages its own connections, that's the point. But if your users sit behind a corporate proxy, supporting them is now your job (CallFlow does its own CONNECT when a proxy is configured).
  • Constructing ClientRequest yourself is legal but uncommon. The class is documented, and http.request is a thin wrapper that calls new ClientRequest(...). It's still the less-travelled path, so pin it with a test.
  • The user can turn the patch off with "http.proxySupport": "off". That's not something an extension should ask of its users.

Not tested, so not claimed here: https.request with createConnection, and fetch, which the same package also patches through the undici dispatcher.

The test that would have caught it

Unit tests run in plain Node and can't see the patch. My integration tests did run inside VS Code, but every request they sent went to a plain-HTTP server on 127.0.0.1, where a freshly dialled TCP connection gives the same answer as my socket would have.

The fix that stuck was a rule: every network path gets an integration test inside a real VS Code, for both http and https targets. @vscode/test-electron makes that cheap. The repro for this post is a 30-line extension plus a runner, and it runs in under a minute once VS Code is cached.

Repro

Five small files, no dependencies besides @vscode/test-electron:

  • suite.cjs: the four experiments above, written to out.txt
  • run.mjs: downloads stable VS Code and runs the suite in its extension host
  • plain.cjs: the same experiments in plain Node, for comparison
  • package.json and ext.js: an empty extension so the host will load
npm i -D @vscode/test-electron
node run.mjs && cat out.txt
node plain.cjs

I build CallFlow, a REST client and HTTP debugger for VS Code, which is where this bit me. If you write extensions that talk to the network, I hope this saves you a release. Corrections welcome at hello@smallhours.works.