Repository: streamlit/streamlit
Pull request #10831: Send `postMessage` on websocket connection errors
Author: login: mayagbarnes
Labels: name: impact:internal
description: PR changes only affect internal code
name: change:refactor
description: PR contains code refactoring without behavior change
Created: 2025-03-18T22:47:52Z | Merged: 2025-03-19T21:54:13Z

## Describe your changes
**Part 3 of sending client errors to host: PR going into feature branch**

The `CLIENT_ERROR_DIALOG` message is already fired to the host from App's `onConnectionError`, but this does not generally provide sufficient context on the source/error. This PR adds `CLIENT_ERROR` messages at the points of failure for our websocket connection. Added as follows:
* `ConnectionManager` when the establishment of a websocket connection fails
* `WebsocketConnection` when `FATAL_ERROR` event is received and the connection state is not already `DISCONNECTED_FOREVER` (currently triggers the Connection Error dialog), when `onerror` event is fired, and when the connection times out
* `DoInitPings` when pings to `health` or `host-config` endpoints fails - server responded & not successful (currently triggers the Connection Error dialog after 6 failed attempts).

**Context - when the Connection Error dialog is shown:**
`App` passes down `onConnectionError` (triggers Connection Error dialog) to the `ConnectionManager`, which is called by:
* `setConnectionState` when a second arg `errMsg` is passed to it
    * This happens in `WebsocketConnection`'s function `stepFsm` when a `FATAL_ERROR` event is received and the connection state is not already `DISCONNECTED_FOREVER`
* `showRetryError` when `totalRetries` hits 6
    *  This happens in the `DoInitPings` function `retry` which is fired directly/indirectly in the `catch` of the axios requests to the `health` and `host-config` endpoints

## Testing Plan
- JS Unit Tests: ✅ 
- Manual Testing: ✅ 


Diff:
diff --git a/frontend/app/src/App.tsx b/frontend/app/src/App.tsx
index 956bcf32a20..9f421a69f3e 100644
--- a/frontend/app/src/App.tsx
+++ b/frontend/app/src/App.tsx
@@ -433,6 +433,19 @@ export class App extends PureComponent<Props, State> {
       connectionStateChanged: this.handleConnectionStateChanged,
       claimHostAuthToken: this.hostCommunicationMgr.claimAuthToken,
       resetHostAuthToken: this.hostCommunicationMgr.resetAuthToken,
+      sendClientError: (
+        error: string | number,
+        message: string,
+        source: string
+      ) => {
+        this.hostCommunicationMgr.sendMessageToHost({
+          type: "CLIENT_ERROR",
+          component: "Websocket Connection",
+          error,
+          message,
+          source,
+        })
+      },
       onHostConfigResp: (response: IHostConfigResponse) => {
         const {
           allowedOrigins,
diff --git a/frontend/connection/src/ConnectionManager.ts b/frontend/connection/src/ConnectionManager.ts
index d1f5334adba..6f9bfc8531a 100644
--- a/frontend/connection/src/ConnectionManager.ts
+++ b/frontend/connection/src/ConnectionManager.ts
@@ -69,6 +69,16 @@ interface Props {
    */
   resetHostAuthToken: () => void
 
+  /**
+   * Sends message to host when websocket connection errors encountered to
+   * inform where/why the error occurred.
+   */
+  sendClientError: (
+    error: string | number,
+    message: string,
+    source: string
+  ) => void
+
   /**
    * Function to set the host config for this app (if in a relevant deployment
    * scenario).
@@ -168,7 +178,12 @@ export class ConnectionManager {
         this.websocketConnection = await this.connectToRunningServer()
       } catch (e) {
         const err = e instanceof Error ? e : new Error(`${e}`)
-        LOG.error(err.message)
+        LOG.error(`Client Error: Websocket connection - ${err.message}`)
+        this.props.sendClientError(
+          "Failed to establish websocket connection",
+          err.message,
+          "Connection Manager"
+        )
         this.setConnectionState(
           ConnectionState.DISCONNECTED_FOREVER,
           err.message
@@ -220,6 +235,7 @@ export class ConnectionManager {
       onRetry: this.showRetryError,
       claimHostAuthToken: this.props.claimHostAuthToken,
       resetHostAuthToken: this.props.resetHostAuthToken,
+      sendClientError: this.props.sendClientError,
       onHostConfigResp: this.props.onHostConfigResp,
     })
   }
diff --git a/frontend/connection/src/DoInitPings.tsx b/frontend/connection/src/DoInitPings.tsx
index 80e355f485a..9834e7acef5 100644
--- a/frontend/connection/src/DoInitPings.tsx
+++ b/frontend/connection/src/DoInitPings.tsx
@@ -35,12 +35,18 @@ import {
 import { IHostConfigResponse, OnRetry } from "./types"
 
 const LOG = getLogger("DoInitPings")
+export const THRESHOLD_FOR_CONNECTION_ERROR_DIALOG = 6
 
 export function doInitPings(
   uriPartsList: URL[],
   minimumTimeoutMs: number,
   maximumTimeoutMs: number,
   retryCallback: OnRetry,
+  sendClientError: (
+    error: string | number,
+    message: string,
+    source: string
+  ) => void,
   onHostConfigResp: (resp: IHostConfigResponse) => void
 ): Promise<number> {
   const { promise, resolve } = Promise.withResolvers<number>()
@@ -101,6 +107,21 @@ If you are trying to access a Streamlit app running on another server, this coul
     retry(forbiddenMessage)
   }
 
+  // Handle retrieving the source URL, otherwise fallback to "DoInitPings"
+  const determineUrlSource = (url: string | undefined): string => {
+    let source = "DoInitPings"
+
+    if (url) {
+      try {
+        source = new URL(url).pathname
+      } catch (e) {
+        LOG.error(`unrecognized url: ${url}`)
+      }
+    }
+
+    return source
+  }
+
   connect = () => {
     const uriParts = uriPartsList[uriNumber]
     const healthzUri = buildHttpUri(uriParts, SERVER_PING_PATH)
@@ -128,7 +149,22 @@ If you are trying to access a Streamlit app running on another server, this coul
         resolve(uriNumber)
       })
       .catch(error => {
+        // If its our 6th try (retry count at which we show connection error dialog), send a client error
+        // to inform the host of connection error
+        const shouldSendClientError =
+          totalTries === THRESHOLD_FOR_CONNECTION_ERROR_DIALOG
+
         if (error.code === "ECONNABORTED") {
+          if (shouldSendClientError) {
+            // Handle retrieving the source URL from the error (health or host-config endpoint)
+            const source = determineUrlSource(error.config?.url)
+            LOG.error("Client error: DoInitPings timed out")
+            sendClientError(
+              "DoInitPings timed out",
+              "Connection timed out - ECONNABORTED",
+              source
+            )
+          }
           return retry("Connection timed out.")
         }
 
@@ -136,14 +172,40 @@ If you are trying to access a Streamlit app running on another server, this coul
           // The request was made and the server responded with a status code
           // that falls out of the range of 2xx
 
-          const { data, status } = error.response
+          const { data, status, statusText } = error.response
+          // Handle retrieving the source URL from the error (health or host-config endpoint)
+          const source = determineUrlSource(error.response.config?.url)
 
           if (status === /* NO RESPONSE */ 0) {
+            if (shouldSendClientError) {
+              LOG.error(
+                `Client Error: response received with status ${status} when attempting to reach ${source}`
+              )
+              sendClientError(
+                `Response received with status ${status}`,
+                statusText,
+                source
+              )
+            }
             return retryWhenTheresNoResponse()
           }
+
           if (status === 403) {
+            if (shouldSendClientError) {
+              LOG.error(
+                `Client Error: response received with status ${status} when attempting to reach ${source}`
+              )
+              sendClientError(status, statusText, source)
+            }
             return retryWhenIsForbidden()
           }
+
+          if (shouldSendClientError) {
+            LOG.error(
+              `Client Error: response received with status ${status} when attempting to reach ${source}`
+            )
+            sendClientError(status, statusText, source)
+          }
           return retry(
             `Connection failed with status ${status}, ` +
               `and response "${data}".`
@@ -153,9 +215,34 @@ If you are trying to access a Streamlit app running on another server, this coul
           // The request was made but no response was received
           // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
           // http.ClientRequest in node.js
+
+          if (shouldSendClientError) {
+            // Handle retrieving the source URL from the error (health or host-config endpoint)
+            const source = determineUrlSource(error.request.path)
+            LOG.error(
+              `Client Error in reaching server endpoint - No response received when attempting to reach ${source}`
+            )
+            sendClientError(
+              "No response received from server",
+              error.request.status,
+              source
+            )
+          }
           return retryWhenTheresNoResponse()
         }
         // Something happened in setting up the request that triggered an Error
+        if (shouldSendClientError) {
+          // Handle retrieving the source URL from the error (health or host-config endpoint)
+          const source = determineUrlSource(error.config?.url)
+          LOG.error(
+            `Client Error in reaching server endpoint - error in setting up request when attempting to reach ${source}`
+          )
+          sendClientError(
+            "Error setting up request to server",
+            error.message ?? "",
+            source
+          )
+        }
         return retry(error.message)
       })
   }
diff --git a/frontend/connection/src/WebsocketConnection.test.tsx b/frontend/connection/src/WebsocketConnection.test.tsx
index 4e783532d5d..6c87595220c 100644
--- a/frontend/connection/src/WebsocketConnection.test.tsx
+++ b/frontend/connection/src/WebsocketConnection.test.tsx
@@ -23,7 +23,10 @@ import { BackMsg } from "@streamlit/protobuf"
 import { ConnectionState } from "./ConnectionState"
 import { Args, WebsocketConnection } from "./WebsocketConnection"
 import { CORS_ERROR_MESSAGE_DOCUMENTATION_LINK } from "./constants"
-import { doInitPings } from "./DoInitPings"
+import {
+  doInitPings,
+  THRESHOLD_FOR_CONNECTION_ERROR_DIALOG,
+} from "./DoInitPings"
 import { mockEndpoints } from "./testUtils"
 
 const MOCK_ALLOWED_ORIGINS_CONFIG = {
@@ -37,6 +40,31 @@ const MOCK_HOST_CONFIG_RESPONSE = {
 
 const MOCK_HEALTH_RESPONSE = { status: "ok" }
 
+// Sets up axios get mock to fail a specific number of times before succeeding
+function setupAxiosMockWithFailures(
+  retryCount: number,
+  errorObj: any
+): ReturnType<typeof vi.fn> {
+  const mockImplementation = vi.fn()
+  axios.get = mockImplementation
+
+  // Each "totalTries" increment involves cycling through all URIs
+  // Each URI requires 2 axios calls (health + config)
+  // So total failed calls needed = retryCount * numUris * 2
+  const totalFailedCalls = retryCount * 2 * 2
+
+  // Setup all the rejected calls
+  for (let i = 0; i < totalFailedCalls; i++) {
+    mockImplementation.mockRejectedValueOnce(errorObj)
+  }
+
+  // Add final successful calls to break the loop
+  mockImplementation.mockResolvedValueOnce("") // healthzUri success
+  mockImplementation.mockResolvedValueOnce(MOCK_HOST_CONFIG_RESPONSE) // hostConfigUri success
+
+  return mockImplementation
+}
+
 /** Create mock WebsocketConnection arguments */
 function createMockArgs(overrides?: Partial<Args>): Args {
   return {
@@ -54,6 +82,7 @@ function createMockArgs(overrides?: Partial<Args>): Args {
     onRetry: vi.fn(),
     claimHostAuthToken: () => Promise.resolve(undefined),
     resetHostAuthToken: vi.fn(),
+    sendClientError: vi.fn(),
     onHostConfigResp: vi.fn(),
     ...overrides,
   }
@@ -68,6 +97,7 @@ describe("doInitPings", () => {
     timeoutMs: 10,
     maxTimeoutMs: 100,
     retryCallback: vi.fn(),
+    sendClientError: vi.fn(),
     setAllowedOrigins: vi.fn(),
   }
 
@@ -94,6 +124,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
     expect(uriIndex).toEqual(0)
@@ -113,6 +144,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
     expect(uriIndex).toEqual(0)
@@ -136,6 +168,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
     expect(uriIndex).toEqual(1)
@@ -161,6 +194,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -188,6 +222,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -219,6 +254,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -248,6 +284,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -287,6 +324,7 @@ describe("doInitPings", () => {
       MOCK_PING_DATA_LOCALHOST.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA_LOCALHOST.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -322,6 +360,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -354,6 +393,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -393,6 +433,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       MOCK_PING_DATA.retryCallback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -443,6 +484,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       callback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -496,6 +538,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       callback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -550,6 +593,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       callback,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -573,6 +617,7 @@ If you are trying to access a Streamlit app running on another server, this coul
       MOCK_PING_DATA.timeoutMs,
       MOCK_PING_DATA.maxTimeoutMs,
       callback2,
+      MOCK_PING_DATA.sendClientError,
       MOCK_PING_DATA.setAllowedOrigins
     )
 
@@ -580,6 +625,137 @@ If you are trying to access a Streamlit app running on another server, this coul
     expect(timeouts[1]).toBeGreaterThan(timeouts[0])
     expect(timeouts2[0]).toEqual(10)
   })
+
+  describe("calls sendClientError when we've reached connection error threshold", () => {
+    it("with status = 0 response", async () => {
+      const sendClientErrorSpy = vi.fn()
+
+      // We need to mock axios.get to simulate connection error threshold
+      axios.get = setupAxiosMockWithFailures(
+        THRESHOLD_FOR_CONNECTION_ERROR_DIALOG,
+        {
+          response: {
+            status: 0,
+            statusText: "No response",
+            config: {
+              url: "https://example.com/health",
+            },
+          },
+        }
+      )
+
+      await doInitPings(
+        MOCK_PING_DATA.uri,
+        MOCK_PING_DATA.timeoutMs,
+        MOCK_PING_DATA.maxTimeoutMs,
+        MOCK_PING_DATA.retryCallback,
+        sendClientErrorSpy,
+        MOCK_PING_DATA.setAllowedOrigins
+      )
+
+      // Verify that sendClientError was called with the expected arguments
+      expect(sendClientErrorSpy).toHaveBeenCalledWith(
+        "Response received with status 0",
+        "No response",
+        "/health"
+      )
+    })
+
+    it("with status = 403 response", async () => {
+      const sendClientErrorSpy = vi.fn()
+
+      // We need to mock axios.get to simulate connection error threshold
+      axios.get = setupAxiosMockWithFailures(
+        THRESHOLD_FOR_CONNECTION_ERROR_DIALOG,
+        {
+          response: {
+            status: 403,
+            statusText: "Forbidden",
+            config: {
+              url: "https://example.com/health",
+            },
+          },
+        }
+      )
+
+      await doInitPings(
+        MOCK_PING_DATA.uri,
+        MOCK_PING_DATA.timeoutMs,
+        MOCK_PING_DATA.maxTimeoutMs,
+        MOCK_PING_DATA.retryCallback,
+        sendClientErrorSpy,
+        MOCK_PING_DATA.setAllowedOrigins
+      )
+
+      expect(sendClientErrorSpy).toHaveBeenCalledWith(
+        403,
+        "Forbidden",
+        "/health"
+      )
+    })
+
+    it("with status = 500 response", async () => {
+      const sendClientErrorSpy = vi.fn()
+
+      // We need to mock axios.get to simulate connection error threshold
+      axios.get = setupAxiosMockWithFailures(
+        THRESHOLD_FOR_CONNECTION_ERROR_DIALOG,
+        {
+          response: {
+            status: 500,
+            statusText: "Internal Server Error",
+            config: {
+              url: "https://example.com/health",
+            },
+          },
+        }
+      )
+
+      await doInitPings(
+        MOCK_PING_DATA.uri,
+        MOCK_PING_DATA.timeoutMs,
+        MOCK_PING_DATA.maxTimeoutMs,
+        MOCK_PING_DATA.retryCallback,
+        sendClientErrorSpy,
+        MOCK_PING_DATA.setAllowedOrigins
+      )
+
+      expect(sendClientErrorSpy).toHaveBeenCalledWith(
+        500,
+        "Internal Server Error",
+        "/health"
+      )
+    })
+
+    it("with request error", async () => {
+      const sendClientErrorSpy = vi.fn()
+
+      // We need to mock axios.get to simulate connection error threshold
+      axios.get = setupAxiosMockWithFailures(
+        THRESHOLD_FOR_CONNECTION_ERROR_DIALOG,
+        {
+          request: {
+            path: "https://example.com/health",
+          },
+        }
+      )
+
+      await doInitPings(
+        MOCK_PING_DATA.uri,
+        MOCK_PING_DATA.timeoutMs,
+        MOCK_PING_DATA.maxTimeoutMs,
+        MOCK_PING_DATA.retryCallback,
+        sendClientErrorSpy,
+        MOCK_PING_DATA.setAllowedOrigins
+      )
+
+      expect(sendClientErrorSpy).toHaveBeenCalledWith(
+        "No response received from server",
+        undefined,
+        "/health"
+      )
+    })
+  })
 })
 
 describe("WebsocketConnection", () => {
diff --git a/frontend/connection/src/WebsocketConnection.tsx b/frontend/connection/src/WebsocketConnection.tsx
index 1c5221148ad..86e35d77864 100644
--- a/frontend/connection/src/WebsocketConnection.tsx
+++ b/frontend/connection/src/WebsocketConnection.tsx
@@ -84,6 +84,16 @@ export interface Args {
    */
   resetHostAuthToken: () => void
 
+  /**
+   * Sends message to host when websocket connection errors encountered to
+   * inform where/why the error occurred.
+   */
+  sendClientError: (
+    error: string | number,
+    message: string,
+    source: string
+  ) => void
+
   /**
    * Function to set the host config and allowed-message-origins for this app (if in a relevant deployment
    * scenario).
@@ -240,6 +250,15 @@ export class WebsocketConnection {
       event === "FATAL_ERROR" &&
       this.state !== ConnectionState.DISCONNECTED_FOREVER
     ) {
+      LOG.error(
+        `Client Error: Websocket connection encountered fatal error - ${errMsg}`
+      )
+      this.args.sendClientError(
+        "Websocket connection fatal error encountered",
+        // @ts-expect-error - errMsg always passed with FATAL_ERROR event
+        errMsg,
+        "Websocket Connection"
+      )
       // If we get a fatal error, we transition to DISCONNECTED_FOREVER
       // regardless of our current state.
       this.setFsmState(ConnectionState.DISCONNECTED_FOREVER, errMsg)
@@ -313,6 +332,7 @@ export class WebsocketConnection {
       PING_MINIMUM_RETRY_PERIOD_MS,
       PING_MAXIMUM_RETRY_PERIOD_MS,
       this.args.onRetry,
+      this.args.sendClientError,
       this.args.onHostConfigResp
     )
 
@@ -404,9 +424,14 @@ export class WebsocketConnection {
       }
     })
 
-    this.websocket.addEventListener("error", () => {
+    this.websocket.addEventListener("error", (event: unknown) => {
       if (checkWebsocket()) {
-        LOG.error("WebSocket onerror")
+        LOG.error("Client Error: WebSocket onerror")
+        this.args.sendClientError(
+          "Websocket connection onerror triggered",
+          `Error: ${event}`,
+          "Websocket Connection"
+        )
         this.closeConnection()
         this.stepFsm("CONNECTION_ERROR")
       }
@@ -443,7 +468,12 @@ export class WebsocketConnection {
       }
 
       if (this.websocket.readyState === 0 /* CONNECTING */) {
-        LOG.info(`${uri} timed out`)
+        LOG.info(`Client error: ${uri} timed out`)
+        this.args.sendClientError(
+          "Websocket connection timed out",
+          `${uri} timed out`,
+          "Websocket Connection"
+        )
         this.closeConnection()
         this.stepFsm("CONNECTION_TIMED_OUT")
       }


Reviews:
user: login: kmcgrady
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T00:51:07Z
user: login: mayagbarnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T21:05:55Z
user: login: kmcgrady
body: Just one small improvement I think would be a little bit safer.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-19T21:28:32Z

Review comments:
diff_hunk: @@ -128,22 +133,69 @@ If you are trying to access a Streamlit app running on another server, this coul
         resolve(uriNumber)
       })
       .catch(error => {
+        // If its our 6th try (retry count at which we show connection error dialog), send a client error
+        // to inform the host of connection error
+        const shouldSendClientError = totalTries === 6
path: frontend/connection/src/DoInitPings.tsx
user: login: kmcgrady
body: Should probably make 6 a constant like `THRESHOLD_FOR_CONNECTION_ERROR_DIALOG`
created_at: 2025-03-18T23:11:01Z
author_association: COLLABORATOR
diff_hunk: @@ -128,22 +133,69 @@ If you are trying to access a Streamlit app running on another server, this coul
         resolve(uriNumber)
       })
       .catch(error => {
+        // If its our 6th try (retry count at which we show connection error dialog), send a client error
+        // to inform the host of connection error
+        const shouldSendClientError = totalTries === 6
+
+        // Handle retrieving the source URL from the error (health or host-config endpoint)
+        // Fallback to "DoInitPings" if we can't retrieve the source url from the error
+        let source = "DoInitPings"
+        if (error.config?.url) {
+          source = new URL(error.config.url).pathname
+        } else if (error.response?.config?.url) {
+          source = new URL(error.response.config.url).pathname
+        } else if (error.request?.path) {
+          source = new URL(error.request.path).pathname
+        }
path: frontend/connection/src/DoInitPings.tsx
user: login: kmcgrady
body: Might need some explanation on each of these cases, why would one branch succeed over another?
created_at: 2025-03-18T23:13:30Z
author_association: COLLABORATOR
diff_hunk: @@ -128,22 +133,69 @@ If you are trying to access a Streamlit app running on another server, this coul
         resolve(uriNumber)
       })
       .catch(error => {
+        // If its our 6th try (retry count at which we show connection error dialog), send a client error
+        // to inform the host of connection error
+        const shouldSendClientError = totalTries === 6
+
+        // Handle retrieving the source URL from the error (health or host-config endpoint)
+        // Fallback to "DoInitPings" if we can't retrieve the source url from the error
+        let source = "DoInitPings"
+        if (error.config?.url) {
+          source = new URL(error.config.url).pathname
+        } else if (error.response?.config?.url) {
+          source = new URL(error.response.config.url).pathname
+        } else if (error.request?.path) {
+          source = new URL(error.request.path).pathname
+        }
path: frontend/connection/src/DoInitPings.tsx
user: login: mayagbarnes
body: Moved to their respective sections for clarity
created_at: 2025-03-19T21:05:54Z
author_association: COLLABORATOR
diff_hunk: @@ -153,9 +204,38 @@ If you are trying to access a Streamlit app running on another server, this coul
           // The request was made but no response was received
           // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
           // http.ClientRequest in node.js
+
+          if (shouldSendClientError) {
+            // Handle retrieving the source URL from the error (health or host-config endpoint)
+            const source = error.request.path
+              ? new URL(error.request.path).pathname
path: frontend/connection/src/DoInitPings.tsx
user: login: kmcgrady
body: Just checking, is there any way `error.request.path` could fail to be parsed as a URL? Would wrapping in a `try-catch` be a little safer?
created_at: 2025-03-19T21:23:47Z
author_association: COLLABORATOR
diff_hunk: @@ -153,9 +204,38 @@ If you are trying to access a Streamlit app running on another server, this coul
           // The request was made but no response was received
           // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
           // http.ClientRequest in node.js
+
+          if (shouldSendClientError) {
+            // Handle retrieving the source URL from the error (health or host-config endpoint)
+            const source = error.request.path
+              ? new URL(error.request.path).pathname
+              : "DoInitPings"
+            LOG.error(
+              `Client Error in reaching server endpoint - No response received when attempting to reach ${source}`
+            )
+            sendClientError(
+              "No response received from server",
+              error.request.status,
+              source
+            )
+          }
           return retryWhenTheresNoResponse()
         }
         // Something happened in setting up the request that triggered an Error
+        if (shouldSendClientError) {
+          // Handle retrieving the source URL from the error (health or host-config endpoint)
+          const source = error.config?.url
+            ? new URL(error.config.url).pathname
path: frontend/connection/src/DoInitPings.tsx
user: login: kmcgrady
body: I assume the url is in fact a URL, but same question about parsing. Should we have a convenience function or something.

```js
function determineUrlSource(url) {
  let source = "DoInitPings"
  
  if (url) {
    try {
      source = new URL(url).pathname
    } catch(e) {
      LOG.error(`unrecognized url: ${url}`)
    }
  }

  return source;
}
created_at: 2025-03-19T21:27:32Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T22:48:08Z
body: ### ✅ PR preview is ready!

| Name | Link |
| :----: | ---- |
| 📦 Wheel file | https://core-previews.s3-us-west-2.amazonaws.com/pr-10831/streamlit-1.43.2-py3-none-any.whl |
| 🕹️ Preview app | [pr-10831.streamlit.app](https://pr-10831.streamlit.app) (☁️ Deploy [here](https://share.streamlit.io/deploy?repository=streamlit/core-previews&branch=pr-10831&mainModule=E2E_Tester_🧪.py&subdomain=pr-10831) if not accessible)| 
author_association: CONTRIBUTOR
user: login: snyk-io[bot]
created_at: 2025-03-18T22:49:05Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/streamlit-open-source/pr-checks/aaedd866-b24c-44fe-995d-5fff889b0129?refs=2251a1779a0508a8ca0cfc26ba6dda2c2fa8f202&source=prChecksComment)

:white_check_mark: **license/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/streamlit-open-source/pr-checks/aaedd866-b24c-44fe-995d-5fff889b0129/license?refs=2251a1779a0508a8ca0cfc26ba6dda2c2fa8f202&source=prChecksComment)



author_association: CONTRIBUTOR