Skip to content

npm cannot fetch from ANY local/loopback HTTP registry stub: no TCP connection is opened, request times out (FETCH_ERROR) — make-fetch-happen reproduces it standalone; node's own http clients work #10077

Open

Description

@ebowwa

Current Behavior

npm view / npm ping against a local HTTP registry stub (a plain http.createServer answering every request with 200) never opens a TCP connection to the stub and dies with FETCH_ERROR / request-timeout after the fetch timeout (~5s with --fetch-timeout=5000). With default flags the request just stalls (observed >30s). node's own clients against the same stub work instantly. The failure is identical on every box we tested — Linux and macOS.

Expected Behavior

npm should connect to and fetch from the local registry stub, as node's own http.request/fetch do.

Steps to Reproduce

Self-contained: start a stub in one node process, run npm view against it.

// stub.mjs — answers any request with a minimal packument
import http from "node:http";
let hits = 0, conns = 0;
http.createServer((req, res) => {
  hits++;
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ _id: "left-pad", name: "left-pad", "dist-tags": { latest: "1.0.0" }, versions: { "1.0.0": { name: "left-pad", version: "1.0.0" } } }));
}).on("connection", () => conns++)
  .listen(0, "[redacted:ip]", () => console.log(`stub http://[redacted:ip]:${stub.address().port} — watch hits/conns here`));
npm view left-pad@^1.0.0 version --json --registry http://[redacted:ip]:<port> --fetch-retries=0 --fetch-timeout=5000
# -> ~5.2s, exit 1: npm error code FETCH_ERROR / "network timeout at: ..."
# stub-side: hits and TCP conns UNCHANGED — npm never connected
node -e 'fetch("http://[redacted:ip]:<port>/left-pad").then(r => console.log(r.status))'
# -> 200 in single-digit ms — node's own client is fine
npm ping --registry http://[redacted:ip]:<port> --fetch-retries=0 --fetch-timeout=5000
# -> same FETCH_ERROR, stub still never sees a socket

Layer isolation (why we think it is make-fetch-happen, not npm wiring)

All of this in one session, npm 10.9.8 bundled tree:

  1. node fetch and http.get against the stub: HTTP 200 in 3–25ms (stub sees the connection).
  2. npm CLI (view, ping): FETCH_ERROR at ~5.2s with --fetch-timeout=5000; stub-side TCP connection count and request bytes: 0. --loglevel=silly shows npm http fetch GET ... attempt 1 failed with FETCH_ERROR, type request-timeout, stack inside minipass-fetch/lib/index.js timeout timer.
  3. make-fetch-happen@14.0.3 (npm's bundled copy) driven directly from a plain node script — no npm, no npmrc, no config:
    const mfh = makeRequire("<npm-install>/node_modules/make-fetch-happen/package.json")("make-fetch-happen");
    await mfh("http://[redacted:ip]:<port>/", { timeout: 5000 }); // -> FETCH_ERROR at 5006ms, stub saw 0 connections
    Identical hang. Instrumenting http.request inside that child: the options are well-formed enough (hostname: "[redacted:ip]", port, path: "/"; note minipass-fetch's getNodeRequestOptions also sets host: parsedURL.host which contains ip:port — request.js:239-242 — but plain node handles that shape fine when hostname is present), the request fires 'socket' with a socket that reports remoteAddress/remotePort as the stub and connecting: false — yet the stub never receives the connection. 'error' never fires; the minipass-fetch request-timeout timer is what ends it.
  4. Plain node http.request with the exact same options shape (including the host: "ip:port" quirk and an equivalent keepAlive agent + extras): HTTP 200 in 2–33ms every time. So the fault is not the options shape, not node's http stack, and not npm's CLI wiring — it is inside make-fetch-happen's agent/fetch layer.
  5. Controls: real registry fine (npm view left-pad@^999.0.0 → fast E404 round-trip, 270–570ms); closed loopback port → fast ECONNREFUSED (216–355ms) — so npm's client does attempt connects and can see refusals; it is specifically the listening local server case that silently never connects.
  6. Not a proxy/npmrc/env artifact: npm config get proxy/https-proxy → null; no HTTP(S)_PROXY/npm_config_* in env on the failing legs; .npmrc empty; http://localhost:<port> fails identically to http://[redacted:ip]:<port>.

Environment

  • npm 10.9.8 / node 22.23.1 / macOS arm64 (Darwin 27)
  • npm 10.9.9 / node 22.23.3 / Linux x64 (GitHub-hosted ubuntu-latest, Linux 6.17)
  • npm 10.9.8 / node 22.23.2 / macOS arm64 (GitHub-hosted macos-14, Darwin 23.6)
  • Bundled layer: make-fetch-happen@14.0.3, minipass-fetch@4.0.1

All three boxes fail byte-for-byte identically, including the standalone make-fetch-happen repro on box 1.

Why this matters

The "spin a stub registry and point npm at it" recipe is the standard way to make npm-behavior pins hermetic (E404 vs E5xx vs ECONNREFUSED classification, packument shapes). On current npm it silently cannot work on any of our Linux or macOS environments — the stub never sees a socket and the caller times out, which reads as a flake/hang rather than a clear error.

Happy to provide the full instrumented-child receipts or test candidate fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions