Repository: denoland/deno
Pull request: fix(ext/fetch): include TCP src/dst socket info in error messages

This commit makes `fetch` error messages include source and destination TCP socket info i.e. port number and IP address for better debuggability.

Closes #24922

---

### Demo

Here's an example error message:

```
$ ./target/debug/deno eval 'await fetch("http://localhost:4444")'
error: Uncaught (in promise) TypeError: error sending request from 127.0.0.1:55985 for http://localhost:4444/ (127.0.0.1:4444): client error (SendRequest): connection closed before message completed
await fetch("http://localhost:4444")
^
    at async mainFetch (ext:deno_fetch/26_fetch.js:170:12)
    at async fetch (ext:deno_fetch/26_fetch.js:392:7)
    at async file:///Users/yusuke/Repo/github.com/magurotuna/deno/$deno$eval:1:1
```

Changed files:
filename: Cargo.lock
patch: @@ -3630,9 +3630,9 @@ dependencies = [
 
 [[package]]
 name = "hyper-util"
-version = "0.1.6"
+version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956"
+checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9"
 dependencies = [
  "bytes",
  "futures-channel",

filename: Cargo.toml
patch: @@ -124,7 +124,7 @@ http_v02 = { package = "http", version = "0.2.9" }
 httparse = "1.8.0"
 hyper = { version = "1.4.1", features = ["full"] }
 hyper-rustls = { version = "0.27.2", default-features = false, features = ["http1", "http2", "tls12", "ring"] }
-hyper-util = { version = "=0.1.6", features = ["tokio", "client", "client-legacy", "server", "server-auto"] }
+hyper-util = { version = "=0.1.7", features = ["tokio", "client", "client-legacy", "server", "server-auto"] }
 hyper_v014 = { package = "hyper", version = "0.14.26", features = ["runtime", "http1"] }
 indexmap = { version = "2", features = ["serde"] }
 ipnet = "2.3"

filename: ext/fetch/lib.rs
patch: @@ -62,11 +62,13 @@ use http::header::HOST;
 use http::header::PROXY_AUTHORIZATION;
 use http::header::RANGE;
 use http::header::USER_AGENT;
+use http::Extensions;
 use http::Method;
 use http::Uri;
 use http_body_util::BodyExt;
 use hyper::body::Frame;
 use hyper_util::client::legacy::connect::HttpConnector;
+use hyper_util::client::legacy::connect::HttpInfo;
 use hyper_util::rt::TokioExecutor;
 use hyper_util::rt::TokioIo;
 use hyper_util::rt::TokioTimer;
@@ -1104,17 +1106,39 @@ impl ClientSendError {
   pub fn is_connect_error(&self) -> bool {
     self.source.is_connect()
   }
+
+  fn http_info(&self) -> Option<HttpInfo> {
+    let mut exts = Extensions::new();
+    self.source.connect_info()?.get_extras(&mut exts);
+    exts.remove::<HttpInfo>()
+  }
 }
 
 impl std::fmt::Display for ClientSendError {
   fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-    write!(
-      f,
-      "error sending request for url ({uri}): {source}",
-      uri = self.uri,
-      // NOTE: we can use `std::error::Report` instead once it's stabilized.
-      source = error_reporter::Report::new(&self.source),
-    )
+    // NOTE: we can use `std::error::Report` instead once it's stabilized.
+    let detail = error_reporter::Report::new(&self.source);
+
+    match self.http_info() {
+      Some(http_info) => {
+        write!(
+          f,
+          "error sending request from {src} for {uri} ({dst}): {detail}",
+          src = http_info.local_addr(),
+          uri = self.uri,
+          dst = http_info.remote_addr(),
+          detail = detail,
+        )
+      }
+      None => {
+        write!(
+          f,
+          "error sending request for url ({uri}): {detail}",
+          uri = self.uri,
+          detail = detail,
+        )
+      }
+    }
   }
 }
 

filename: tests/unit/fetch_test.ts
patch: @@ -3,6 +3,7 @@ import {
   assert,
   assertEquals,
   assertRejects,
+  assertStringIncludes,
   assertThrows,
   delay,
   fail,
@@ -1977,14 +1978,24 @@ Deno.test(
     });
 
     const url = `http://localhost:${listenPort}/`;
-    const err = await assertRejects(
-      () =>
-        fetch(url, {
-          body: stream,
-          method: "POST",
-        }),
-      TypeError,
-      `error sending request for url (${url}): client error (SendRequest): error from user's Body stream`,
+    const err = await assertRejects(() =>
+      fetch(url, {
+        body: stream,
+        method: "POST",
+      })
+    );
+
+    assert(err instanceof TypeError, `err was ${err}`);
+
+    assertStringIncludes(
+      err.message,
+      "error sending request from 127.0.0.1:",
+      `err.message was ${err.message}`,
+    );
+    assertStringIncludes(
+      err.message,
+      ` for http://localhost:${listenPort}/ (127.0.0.1:${listenPort}): client error (SendRequest): error from user's Body stream`,
+      `err.message was ${err.message}`,
     );
 
     assert(err.cause, `err.cause was null ${err}`);
@@ -2066,11 +2077,41 @@ Deno.test("URL authority is used as 'Authorization' header", async () => {
 
 Deno.test(
   { permissions: { net: true } },
-  async function errorMessageIncludesUrlAndDetails() {
+  async function errorMessageIncludesUrlAndDetailsWithNoTcpInfo() {
     await assertRejects(
       () => fetch("http://example.invalid"),
       TypeError,
       "error sending request for url (http://example.invalid/): client error (Connect): dns error: ",
     );
   },
 );
+
+Deno.test(
+  { permissions: { net: true } },
+  async function errorMessageIncludesUrlAndDetailsWithTcpInfo() {
+    const listener = Deno.listen({ port: listenPort });
+    const server = (async () => {
+      const conn = await listener.accept();
+      listener.close();
+      // Immediately close the connection to simulate a connection error
+      conn.close();
+    })();
+
+    const url = `http://localhost:${listenPort}`;
+    const err = await assertRejects(() => fetch(url));
+
+    assert(err instanceof TypeError, `${err}`);
+    assertStringIncludes(
+      err.message,
+      "error sending request from 127.0.0.1:",
+      `${err.message}`,
+    );
+    assertStringIncludes(
+      err.message,
+      ` for http://localhost:${listenPort}/ (127.0.0.1:${listenPort}): client error (SendRequest): `,
+      `${err.message}`,
+    );
+
+    await server;
+  },
+);