Repository: denoland/deno
Pull request: fix(ext/node): retry stale keepAlive connections in http client

Closes https://github.com/denoland/deno/issues/31891

When a server closes an idle keepAlive connection, the client's HTTP agent may still have the socket pooled. 
Attempting to reuse this stale socket results in ECONNRESET ("socket hang up"). 
This matches Node.js behavior by transparently retrying the request on a fresh connection when a reused socket encounters a stale connection error.

Changed files:
filename: ext/node/polyfills/http.ts
patch: @@ -432,6 +432,8 @@ class ClientRequest extends OutgoingMessage {
     }
 
     if (this.agent) {
+      // Store options for potential retry on stale keepAlive connections.
+      this._agentOptions = optsWithoutSignal;
       this.agent.addRequest(this, optsWithoutSignal);
     } else {
       // No agent, default to Connection:close.
@@ -727,7 +729,21 @@ class ClientRequest extends OutgoingMessage {
           err.message.includes("Bad resource ID") ||
           err.message.includes("operation was canceled")
         ) {
-          // Stale connection - emit ECONNRESET so clients retry.
+          // Stale keepAlive connection. If this was a reused socket,
+          // retry the request on a fresh connection.
+          if (this.reusedSocket && this.agent && this._agentOptions) {
+            const socket = this.socket;
+            if (socket) {
+              socket.destroy();
+            }
+            this.socket = null;
+            this._header = null;
+            this._headerSent = false;
+            this.reusedSocket = false;
+            this._req = null;
+            this.agent.addRequest(this, this._agentOptions);
+            return;
+          }
           this.emit("error", connResetException("socket hang up"));
         } else {
           this.emit("error", err);

filename: tests/specs/node/http_agent_keepalive_stale_retry/__test__.jsonc
patch: @@ -0,0 +1,4 @@
+{
+  "args": "run -A main.ts",
+  "output": "main.out"
+}

filename: tests/specs/node/http_agent_keepalive_stale_retry/main.out
patch: @@ -0,0 +1,4 @@
+First request: request 1
+Second request: request 2
+Third request: request 3
+All stale retry tests passed!

filename: tests/specs/node/http_agent_keepalive_stale_retry/main.ts
patch: @@ -0,0 +1,86 @@
+// Test that stale keepAlive connections are transparently retried
+// instead of throwing ECONNRESET. This matches Node.js behavior.
+import http from "node:http";
+import net from "node:net";
+
+// Use a raw TCP server to have full control over connection lifecycle.
+// It responds with HTTP/1.1 keep-alive, then closes the socket after idle.
+let requestCount = 0;
+
+const server = net.createServer((socket) => {
+  let idleTimer: ReturnType<typeof setTimeout> | null = null;
+
+  const resetIdle = () => {
+    if (idleTimer) clearTimeout(idleTimer);
+    idleTimer = setTimeout(() => socket.destroy(), 50);
+  };
+
+  socket.on("data", (_data) => {
+    requestCount++;
+    const body = `request ${requestCount}`;
+    const response = [
+      "HTTP/1.1 200 OK",
+      `Content-Length: ${body.length}`,
+      "Connection: keep-alive",
+      "",
+      body,
+    ].join("\r\n");
+    socket.write(response);
+    resetIdle();
+  });
+
+  socket.on("close", () => {
+    if (idleTimer) clearTimeout(idleTimer);
+  });
+});
+
+const agent = new http.Agent({
+  keepAlive: true,
+  maxSockets: 1,
+});
+
+function makeRequest(path: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const req = http.get(
+      {
+        host: "localhost",
+        port: (server.address() as net.AddressInfo).port,
+        agent,
+        path,
+      },
+      (res) => {
+        let data = "";
+        res.on("data", (chunk: Buffer) => (data += chunk));
+        res.on("end", () => resolve(data));
+      },
+    );
+    req.on("error", reject);
+  });
+}
+
+server.listen(0, async () => {
+  try {
+    // First request - establishes connection
+    const first = await makeRequest("/first");
+    console.log("First request:", first);
+
+    // Wait for server to close the idle connection (50ms timeout + buffer)
+    await new Promise((resolve) => setTimeout(resolve, 150));
+
+    // Second request - pooled socket is stale, should retry transparently
+    const second = await makeRequest("/second");
+    console.log("Second request:", second);
+
+    // Third request to confirm continued operation
+    const third = await makeRequest("/third");
+    console.log("Third request:", third);
+
+    console.log("All stale retry tests passed!");
+  } catch (err) {
+    console.error("FAILED:", (err as Error).message);
+    Deno.exit(1);
+  } finally {
+    server.close();
+    agent.destroy();
+  }
+});