Quick Summary: Troubleshoot a frustrating Node.js http.Agent deadlock on Alpine Linux (musl) caused by rapid server restarts, leading to ECONNRESET and ETIMEDOUT.
Alright, listen up. You're probably tearing your hair out, staring at logs choked with ECONNRESET and baffling ETIMEDOUT errors from your Node.js proxy service. It only happens on certain environments, under specific load patterns. And yes, you've tried everything. Your code seems fine. Your network engineers swear it's not them. Guess what? They're probably right. The ghost is in your Node.js http.Agent, and it's clinging to dead sockets like a zombie.
This isn't your average 'forgetting to close a file descriptor' problem. This is a subtle, insidious resource exhaustion deadlock that rears its ugly head under specific conditions, particularly when you're doing non-HTTP proxying with Node.js and making internal HTTP calls with the default agent.
The Problem: The Invisible Socket Leaks
Your Node.js application acts as a TCP proxy. Maybe it's a load balancer, maybe a custom protocol forwarder. Whatever its purpose, it's frequently handling new connections, and sometimes, it needs to rapidly tear down and recreate internal server instances (e.g., during configuration reloads, blue/green deployments, or simply due to client churn). Concurrently, this same Node.js process makes internal HTTP requests to other services, perhaps for health checks or to fetch metadata, using the default http.Agent. Under sustained load, especially after several rapid server restarts or client connection cycles, your internal HTTP requests start failing. First with intermittent ECONNRESET, then increasingly with prolonged ETIMEDOUT. It's infuriating because netstat might not show an overwhelming number of CLOSE_WAIT states, and your CPU/memory usage looks fine. But nothing gets through.
Triggering Environments
This particular beast thrives in a very specific ecosystem. Here's where we've seen it hit hardest:
| Operating System | Node.js Version | Library/Context |
|---|---|---|
| Alpine Linux 3.12 (musl libc) | 14.x LTS (e.g., 14.17.x) | Node.js net.createServer proxying with internal http.request calls using default http.Agent |
| Alpine Linux 3.13 (musl libc) | 16.x LTS (e.g., 16.14.x) | Node.js net.createServer proxying with internal http.request calls using default http.Agent |
| Alpine Linux 3.14 (musl libc) | 18.x LTS (e.g., 18.0.x) | Node.js net.createServer proxying with internal http.request calls using default http.Agent |
Notice the common thread: Alpine Linux with its musl C library. While this isn't exclusively a musl issue, the observed behavior can be more pronounced or manifest differently compared to glibc environments due to subtle differences in socket management and error propagation.
Initial Debugging (The Wild Goose Chase)
You probably checked firewall rules, DNS, CPU saturation, memory leaks, and even opened a ticket with your cloud provider. You ran lsof -iTCP -sTCP:ESTABLISHED, looked for an insane number of open files, but nothing immediately screamed 'problem'. Maybe you saw some FIN_WAIT1 or CLOSE_WAIT states, but not enough to cause a complete deadlock. You even considered if you were scaling the monolith's ghost, thinking you had some deep system resource exhaustion.
The key symptom: the internal HTTP calls (often health checks or simple API calls) start failing, while the primary proxying function might continue working, albeit unreliably. This asymmetry is the first clue.
The Root Cause
Here's the brutal truth: The default global http.Agent in Node.js is designed for performance. It aggressively maintains a pool of persistent TCP connections to reuse for subsequent HTTP requests. This is usually fantastic, saving on connection setup overhead. However, when your Node.js application is simultaneously acting as a low-level TCP proxy (e.g., using net.createServer) and rapidly cycling its own internal server instances, it can invalidate the underlying TCP sockets that the http.Agent thinks are perfectly healthy.
When your net.createServer instance is rapidly torn down (e.g., server.close()) or internal client connections are abruptly terminated (e.g., during a proxy target restart), the sockets associated with those connections might be destroyed at the OS level. However, the global http.Agent is blissfully unaware of this low-level carnage. It holds onto references to these now-dead or dying sockets in its internal pool. Subsequent HTTP requests attempt to reuse these stale sockets. If the socket is truly dead, you get an immediate ECONNRESET. If it's in a transitional state or being actively cleaned up by the OS (especially in musl environments which can have different default socket timeouts or how errors propagate), Node.js will attempt to write to it, hit a black hole, and eventually ETIMEDOUT after exhausting its retry logic.
Over time, this pool fills up with an increasing number of unusable sockets, effectively starving new, legitimate connections. The http.Agent reaches its maxSockets limit, and since all its pooled sockets are dead, it can't establish new ones. It’s a resource deadlock, specifically on the connection pool maintained by the default agent. You need to be mindful of such subtle interactions, especially when building zero-latency algorithmic trading APIs where every millisecond counts and connection state is paramount.
The Solution: Stop Trusting the Default Agent (For Critical Internal Calls)
The fix is surprisingly simple but often overlooked. For any internal HTTP or HTTPS calls made from your proxy service that are critical, or that you suspect might be affected by this, explicitly tell Node.js NOT to use the default shared connection pool. Force it to create a fresh connection for each request, or at least manage its own isolated pool.
The quickest, most direct fix is to pass agent: false to your http.request or https.request options:
const http = require('http');
const https = require('https');
// --- BAD (default agent implicitly reused, prone to deadlock under specific conditions) ---
function makeInternalRequestBad() {
return new Promise((resolve, reject) => {
http.get('http://localhost:8080/health', (res) => {
// ... handle response
resolve(res.statusCode);
}).on('error', (e) => {
reject(e);
});
});
}
// --- GOOD (disables connection pooling for this request, forces new connection) ---
function makeInternalRequestGood() {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 8080,
path: '/health',
method: 'GET',
agent: false // CRITICAL: Do not use the default global agent for this request
};
const req = http.request(options, (res) => {
// ... handle response
resolve(res.statusCode);
// Ensure response is fully consumed to prevent connection from lingering unnecessarily
res.resume();
});
req.on('error', (e) => {
reject(e);
});
req.end();
});
}
// --- ALTERNATIVE: Use a dedicated, disposable agent for a set of requests ---
const customAgent = new http.Agent({ keepAlive: false, maxSockets: 5 }); // Or with keepAlive: true and a very short keepAliveMsecs
function makeInternalRequestWithCustomAgent() {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 8080,
path: '/metadata',
method: 'GET',
agent: customAgent // Use a specific agent instance
};
const req = http.request(options, (res) => {
resolve(res.statusCode);
res.resume();
});
req.on('error', (e) => {
reject(e);
});
req.end();
});
}
// Remember to destroy custom agents when no longer needed, especially during server shutdowns:
// customAgent.destroy();
Why This Works
By setting agent: false, you're telling Node.js to create a one-off connection for that specific request. It bypasses the global http.Agent's pooling mechanism entirely. While this incurs the overhead of a new TCP handshake for each request, it guarantees that you're not trying to use a stale, dead socket from a compromised pool. For infrequent internal health checks or configuration fetches, the overhead is negligible compared to the alternative: a completely dead application.
The alternative, using a customAgent, gives you more control. You can configure it with very short keepAliveMsecs or explicitly disable keepAlive if your workload truly benefits from persistent connections but needs stricter management. Crucially, you can then call customAgent.destroy() when your proxy service is shutting down or restarting its internal components, explicitly cleaning up any pooled sockets.
Prevention is Key
This problem highlights a critical aspect of Node.js operations: the default global behaviors, while convenient, can hide complex interactions. When operating in environments with rapid churn, like ephemeral containers on Alpine, or designing services that perform complex internal orchestration, always be explicit about resource management. Don't assume the default behavior is always safe under extreme or unusual load patterns. Look at your applications' internal connection patterns. If they are proxies or orchestrators, evaluate their internal API calls and consider dedicating agents or disabling pooling where reliability trumps a micro-optimization in connection reuse.
Comments
Post a Comment