@@ -78,7 +78,10 @@ open class EventStreamClient(
7878
7979 /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
8080 * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
81− * reads "check the server URL and version". Returns null for transient/retryable errors. */
81+ * reads "check the server URL and version". Returns null for transient/retryable errors.
82+ * SSL/TLS and certificate-pinning failures are permanent (a changed/rotated cert won't
83+ * heal on retry) and are classified as Other so the user sees "check the server URL and
84+ * version / cert pin" instead of an infinite "Reconnecting…" spinner. */
8285 private enum class SseFailureKind { Auth, Other }
8386
8487 private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
@@ -88,11 +91,21 @@ open class EventStreamClient(
8891 when (t) {
8992 is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
9093 is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
94+ // A TLS handshake / cert-pinning / SSL peer unverified failure happens
95+ // before any HTTP response, so it carries no status code. It is never
96+ // transient (the server's cert must change), so classify it directly
97+ // instead of falling through to the null (retryable) default — otherwise
98+ // the client retries forever with backoff against an unreachable pinned
99+ // server, showing "Reconnecting…" with no hint that the failure is fixed.
100+ is javax.net.ssl.SSLException -> -1
101+ is java.security.cert.CertificateException -> -1
91102 else -> null
92103 }
93104 }
94105 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
106+ // The -1 sentinel (SSL/cert) is also permanent → Other.
95107 .map { code -> when {
108+ code == -1 -> SseFailureKind.Other
96109 code in 400..499 && code != 408 && code != 429 ->
97110 if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
98111 else -> null
@@ -326,9 +339,14 @@ open class EventStreamClient(
326339 if (reconnectConsumedDuringRead) return true
327340 var signaled = false
328341 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
342+ // Floor the wait at half the initial backoff: symmetric jitter can otherwise pull a
343+ // small backoff down to 0ms, producing a sub-second reconnect storm right after a
344+ // triggerReconnect reset on a connect-RST loop. A non-zero floor preserves the
345+ // purpose of backoff (spacing retries) while still allowing jitter to shorten it.
346+ val minWait = (initialBackoffMs / 2).coerceAtLeast(1L)
329347 select {
330348 reconnectSignal.onReceive { signaled = true }
331− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
349+ onTimeout((backoffMs + jitter).coerceAtLeast(minWait)) { /* normal backoff elapsed */ }
332350 }
333351 return signaled
334352 }