Repository: denoland/deno
Pull request: fix(ext/fetch): retry on stale pooled HTTP/1.1 connections

## Summary

Fixes #31955

When a server shuts down and restarts on the same port, the fetch client's
connection pool may still hold a stale keep-alive connection. Reusing it causes
`connection closed before message completed` errors on every other request.

The existing `is_error_retryable()` only handled HTTP/2 GOAWAY and
REFUSED_STREAM. This PR extends it to also retry on:

- `hyper::Error::is_incomplete_message()` — connection closed before the
  message completed (the main error from the issue)
- `io::ErrorKind::ConnectionReset` — TCP RST from a stale pooled connection

Both are safe to retry because the server never received/processed the request.
The retry is limited to one attempt (enforced by the existing `Retried` marker
extension) and only applies to requests with clonable bodies (not streaming).

Note: despite being reported as a regression in 2.6.6, this issue reproduces all
the way back to Deno 1.46.0 — it was a long-standing bug, not a regression.

Changed files:
filename: ext/fetch/lib.rs
patch: @@ -1514,6 +1514,30 @@ fn is_error_retryable(err: &(dyn std::error::Error + 'static)) -> bool {
     }
   }
 
+  // HTTP/1.1: The connection was closed before the message completed.
+  // This happens when a pooled keep-alive connection is stale (e.g. the
+  // server shut down between requests). Safe to retry because the server
+  // never received/processed the request on this connection.
+  if let Some(err) = find_source::<hyper::Error>(err)
+    && err.is_incomplete_message()
+  {
+    return true;
+  }
+
+  // Connection reset/aborted by the server before we could send the request.
+  // This is another manifestation of stale pooled connections.
+  // ConnectionReset (ECONNRESET) on Unix, ConnectionAborted (WSAECONNABORTED /
+  // os error 10053) on Windows.
+  if let Some(err) = find_source::<std::io::Error>(err)
+    && matches!(
+      err.kind(),
+      std::io::ErrorKind::ConnectionReset
+        | std::io::ErrorKind::ConnectionAborted
+    )
+  {
+    return true;
+  }
+
   false
 }
 

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

filename: tests/specs/run/fetch_retry_stale_connection/main.out
patch: @@ -0,0 +1,6 @@
+Round 1: ok
+Round 2: ok
+Round 3: ok
+Round 4: ok
+Round 5: ok
+Round 6: ok

filename: tests/specs/run/fetch_retry_stale_connection/main.ts
patch: @@ -0,0 +1,17 @@
+// Regression test for https://github.com/denoland/deno/issues/31955
+// Fetch should retry on stale pooled keep-alive connections when the
+// server is shut down and restarted between requests.
+
+for (let round = 1; round <= 6; round++) {
+  const server = Deno.serve(
+    { hostname: "127.0.0.1", port: 4567, onListen() {} },
+    () => new Response("ok"),
+  );
+  const resp = await fetch("http://127.0.0.1:4567");
+  const text = await resp.text();
+  if (text !== "ok") {
+    throw new Error(`Round ${round}: expected "ok", got "${text}"`);
+  }
+  await server.shutdown();
+  console.log(`Round ${round}: ok`);
+}

filename: tests/unit/fetch_test.ts
patch: @@ -2170,15 +2170,23 @@ Deno.test(
   { permissions: { net: true } },
   async function errorMessageIncludesUrlAndDetailsWithTcpInfo() {
     const listener = Deno.listen({ port: listenPort });
+    // Accept connections in a loop so retries also hit the same error.
+    // This is needed because connection reset is retryable.
     const server = (async () => {
-      const conn = await listener.accept();
-      listener.close();
-      // Immediately close the connection to simulate a connection error
-      conn.close();
+      while (true) {
+        let conn;
+        try {
+          conn = await listener.accept();
+        } catch {
+          break;
+        }
+        conn.close();
+      }
     })();
 
     const url = `http://localhost:${listenPort}`;
     const err = await assertRejects(() => fetch(url));
+    listener.close();
 
     assert(err instanceof TypeError, `${err}`);
     assertStringIncludes(