Skip to content

agent-base: requests queued past maxSockets are stranded forever when connections fail #427

Description

@manzoorwanijk

Describe the bug

When more requests are made than maxSockets, the excess requests are queued inside Node's HTTP agent (agent.requests[name]). agent-base drains that queue only when a connection succeeds. If the in-flight connections all fail (connect() rejects — e.g. ETIMEDOUT, ECONNREFUSED), the queued requests are never dispatched: their promises never settle, neither resolving nor rejecting. The process hangs until the event loop empties.

To Reproduce

const http = require('node:http')
const { Agent } = require('agent-base')

// An agent whose connect() always rejects, simulating a host that
// drops every TCP connection (ETIMEDOUT) or refuses it (ECONNREFUSED).
class FailingAgent extends Agent {
  async connect () {
    throw Object.assign(new Error('simulated connect failure'), { code: 'ECONNREFUSED' })
  }
}

const agent = new FailingAgent({ maxSockets: 2 })
const TOTAL = 6
let settled = 0

for (let i = 0; i < TOTAL; i++) {
  const req = http.request(`http://example.invalid/${i}`, { agent }, res => {
    res.resume()
    res.on('end', () => settled++)
  })
  req.on('error', () => settled++)
  req.end()
}

setTimeout(() => {
  console.log(`settled ${settled}/${TOTAL}`)
}, 2000)

Output: settled 2/6 — only the first maxSockets requests ever settle. The other 4 are stranded; in a real program with nothing else keeping the loop alive, the process would exit silently here.

Expected behavior

All 6 requests settle (each with a connection error). A queued request must still be dispatched — and fail — once a slot frees up, regardless of whether the connection that freed the slot succeeded or failed.

Actual behavior

Only maxSockets requests settle. Every request queued because of the maxSockets limit hangs forever.

Root cause

In createSocket() (packages/agent-base/src/index.ts), agent-base pushes a fake placeholder socket via incrementSockets() so maxSockets accounting stays correct while the async connect() runs:

  • On success: decrementSockets() removes the fake socket, then super.createSocket() installs the real socket. Node's socket lifecycle (close / free) later fires removeSocket(), which drains agent.requests[name].
  • On connect() rejection: the failure handler only calls decrementSockets() (a plain Array#splice) and cb(err). It never goes through removeSocket(), so agent.requests[name] is never drained.
}, (err) => {
    this.decrementSockets(name, fakeSocket); // splices the fake socket, but...
    cb(err);                                 // ...nothing ever drains this.requests[name]
});

Plain Node http.Agent does not have this bug: net.connect() always yields a socket object even for a doomed connection, so a connection failure flows through the normal socket 'close'removeSocket() → queue-drain path. agent-base's async connect() bypasses that path entirely on failure.

Note for the fix: naively calling this.removeSocket(fakeSocket, connectOpts) in the failure handler is not a correct fix — it causes a re-dispatch storm. removeSocket() picks requests[name][0] without shifting it off the queue, relying on the success/'free' path to do the shift; when connect() keeps failing that never happens, so the same request is re-picked endlessly. A correct fix routes the failure through Node's socket lifecycle (or otherwise shifts the queued request as it is re-dispatched).

Related

#299 — that issue (maxSockets not respected) is what introduced the incrementSockets/decrementSockets fake-socket scheme; this bug is its inverse on the connection-failure path.

Environment

  • agent-base 7.1.4 (current). The same createSocket failure path exists unchanged in 9.0.0, so upgrading does not help.
  • Node.js 20 and 24 (reproduced on both).

Downstream impact

npm CLI — npm/cli#9386. During a network outage, npm install / npm ci of any project with more than maxsockets (default 15) dependencies hangs and prints Exit handler never called!, leaving node_modules incomplete. Reproduced on the current npm latest branch (npm 12.0.0-pre): of a 68-package tree, exactly 15 packages attempt a fetch and 53 are stranded.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions