fix: keep transient ticket failures reconnecting (#740)
* fix: keep transient ticket failures reconnecting * fix: recover rejected websocket sends * fix: serialize websocket reconnect scheduling
This commit is contained in:
1 parent
13895585b0
commit
e39544ebd5
4 files changed
+748
-113
No files matched your search
@@ -14,6 +14,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -33,6 +34,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.utf8Size
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -65,17 +67,26 @@ object HermesWsClient {
|
||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||
|
||||
// ── Internal state (all access through synchronized / atomic) ────────
|
||||
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val connectionGeneration = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||
private val outboundLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var closingSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var currentBackoff = INITIAL_BACKOFF_MS
|
||||
|
||||
@@ -84,6 +95,9 @@ object HermesWsClient {
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var outboundDrainJob: Job? = null
|
||||
|
||||
// ── Health and Ping/Pong tracking ────────────────────────────────────
|
||||
|
||||
@Volatile
|
||||
@@ -161,10 +175,19 @@ object HermesWsClient {
|
||||
// Monitor network state to trigger immediate reconnect when network is restored
|
||||
wsScope.launch {
|
||||
NetworkMonitor.isConnected.collect { connected ->
|
||||
if (connected && !isConnected && !intentionalClose.get() && AuthManager.isAutoReconnect()) {
|
||||
Log.d(TAG, "Network restored — triggering immediate reconnect")
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
reconnectJob?.cancel()
|
||||
val shouldOpen =
|
||||
synchronized(outboundLock) {
|
||||
if (connected && !isConnected && !intentionalClose.get() && AuthManager.isAutoReconnect()) {
|
||||
Log.d(TAG, "Network restored — triggering immediate reconnect")
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (shouldOpen) {
|
||||
openSocket()
|
||||
}
|
||||
}
|
||||
@@ -193,6 +216,7 @@ object HermesWsClient {
|
||||
|
||||
/** Open a WebSocket connection using settings from [AuthManager]. */
|
||||
fun connect() {
|
||||
acceptQueuedMessages.set(true)
|
||||
if (connected.get()) {
|
||||
Log.d(TAG, "Already connected — skipping")
|
||||
return
|
||||
@@ -217,7 +241,9 @@ object HermesWsClient {
|
||||
return
|
||||
}
|
||||
intentionalClose.set(false)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
synchronized(outboundLock) {
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
}
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||
openSocket()
|
||||
}
|
||||
@@ -268,41 +294,64 @@ object HermesWsClient {
|
||||
client.newCall(request).execute()
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body.string()
|
||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
AuthManager.setToken(ticket)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||
return true
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body.string()
|
||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
AuthManager.setToken(ticket)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
|
||||
return handleWsTicketRefreshFailure(ConnectionStatus.DISCONNECTED)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||
val status =
|
||||
when {
|
||||
response.code == 401 || response.code == 403 -> ConnectionStatus.AUTH_EXPIRED
|
||||
response.code == 408 || response.code == 429 || response.code >= 500 ->
|
||||
ConnectionStatus.RECONNECTING
|
||||
else -> ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
return handleWsTicketRefreshFailure(status)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}")
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
return false
|
||||
return handleWsTicketRefreshFailure(ConnectionStatus.RECONNECTING)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWsTicketRefreshFailure(status: ConnectionStatus): Boolean {
|
||||
_connectionStatus.value = status
|
||||
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
|
||||
return false
|
||||
}
|
||||
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect() {
|
||||
intentionalClose.set(true)
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
stopHealthTracking()
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
connected.set(false)
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||
synchronized(outboundLock) {
|
||||
intentionalClose.set(true)
|
||||
acceptQueuedMessages.set(!clearPendingMessages)
|
||||
connectionGeneration.incrementAndGet()
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
stopHealthTracking()
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
closingSocket = null
|
||||
if (clearPendingMessages) {
|
||||
messageQueue.clear()
|
||||
queuedMessagesById.clear()
|
||||
}
|
||||
connected.set(false)
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
// ── Awaited RPC request layer (issue #526) ─────────────────────────
|
||||
@@ -368,6 +417,7 @@ object HermesWsClient {
|
||||
error: JsonRpcError?,
|
||||
) {
|
||||
val call = pendingCalls.remove(id) ?: return
|
||||
removeQueuedMessage(id)
|
||||
call.timeoutJob?.cancel()
|
||||
if (error != null) {
|
||||
call.deferred.completeExceptionally(HermesRpcException(error.message))
|
||||
@@ -390,6 +440,7 @@ object HermesWsClient {
|
||||
val snapshot = pendingCalls.toList()
|
||||
pendingCalls.clear()
|
||||
for ((id, call) in snapshot) {
|
||||
removeQueuedMessage(id)
|
||||
Log.w(TAG, "Rejecting pending request on disconnect: ${call.method} (id=$id)")
|
||||
call.timeoutJob?.cancel()
|
||||
call.deferred.completeExceptionally(error)
|
||||
@@ -412,16 +463,79 @@ object HermesWsClient {
|
||||
val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
|
||||
val json = OkHttpProvider.json.encodeToString(request)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||
val ws = webSocket
|
||||
if (ws != null && connected.get()) {
|
||||
ws.send(json)
|
||||
} else {
|
||||
Log.d(TAG, "WS disconnected — queuing message")
|
||||
messageQueue.add(json)
|
||||
synchronized(outboundLock) {
|
||||
val ws = webSocket
|
||||
if (ws != null && connected.get()) {
|
||||
if (!ws.send(json)) {
|
||||
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||
if (isRetryableMessage(json)) {
|
||||
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
|
||||
queueMessage(id, json)
|
||||
recoverRejectedSocket(ws)
|
||||
} else {
|
||||
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
|
||||
}
|
||||
}
|
||||
} else if (acceptQueuedMessages.get()) {
|
||||
if (isRetryableMessage(json)) {
|
||||
Log.d(TAG, "WS disconnected — queuing message")
|
||||
queueMessage(id, json)
|
||||
} else {
|
||||
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private fun isRetryableMessage(json: String): Boolean {
|
||||
if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false
|
||||
if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true
|
||||
return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong()
|
||||
}
|
||||
|
||||
private fun queueMessage(
|
||||
id: String,
|
||||
json: String,
|
||||
) {
|
||||
messageQueue.add(json)
|
||||
queuedMessagesById[id] = json
|
||||
}
|
||||
|
||||
private fun removeQueuedMessage(id: String) {
|
||||
synchronized(outboundLock) {
|
||||
queuedMessagesById.remove(id)?.let(messageQueue::remove)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markQueuedMessageSent(json: String) {
|
||||
queuedMessagesById.entries.removeIf { it.value == json }
|
||||
}
|
||||
|
||||
private fun recoverRejectedSocket(ws: WebSocket) {
|
||||
connected.set(false)
|
||||
if (closingSocket === ws) return
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
if (ws.queueSize() == 0L) {
|
||||
ws.cancel()
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (intentionalClose.get()) return
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob =
|
||||
wsScope.launch {
|
||||
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
|
||||
synchronized(outboundLock) {
|
||||
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
|
||||
ws.cancel()
|
||||
outboundDrainJob = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: submit a user prompt to an existing session. */
|
||||
fun sendMessage(
|
||||
sessionId: String,
|
||||
@@ -463,56 +577,96 @@ object HermesWsClient {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl())
|
||||
val generation = connectionGeneration.incrementAndGet()
|
||||
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||
synchronized(outboundLock) {
|
||||
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||
webSocket = newSocket
|
||||
} else {
|
||||
newSocket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (intentionalClose.get()) return
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (!NetworkMonitor.isConnected.value) {
|
||||
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
||||
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||
return
|
||||
}
|
||||
val delay = currentBackoff
|
||||
currentBackoff =
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${delay}ms …")
|
||||
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob =
|
||||
wsScope.launch {
|
||||
delay(delay)
|
||||
if (!intentionalClose.get() && !connected.get()) {
|
||||
openSocket()
|
||||
}
|
||||
synchronized(outboundLock) {
|
||||
if (intentionalClose.get() || reconnectJob?.isActive == true) return
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (!NetworkMonitor.isConnected.value) {
|
||||
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
||||
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||
return
|
||||
}
|
||||
val reconnectDelay = currentBackoff
|
||||
currentBackoff =
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
|
||||
|
||||
reconnectJob =
|
||||
wsScope.launch {
|
||||
delay(reconnectDelay)
|
||||
val owner = currentCoroutineContext()[Job]
|
||||
val shouldOpen =
|
||||
synchronized(outboundLock) {
|
||||
if (reconnectJob !== owner) {
|
||||
false
|
||||
} else {
|
||||
reconnectJob = null
|
||||
!intentionalClose.get() && !connected.get()
|
||||
}
|
||||
}
|
||||
if (shouldOpen) openSocket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WsListenerImpl : WebSocketListener() {
|
||||
private class WsListenerImpl(
|
||||
private val generation: Int,
|
||||
) : WebSocketListener() {
|
||||
private fun isCurrent(): Boolean = connectionGeneration.get() == generation && !intentionalClose.get()
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
startHealthTracking()
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) {
|
||||
webSocket.close(1000, "Superseded")
|
||||
return
|
||||
}
|
||||
HermesWsClient.webSocket = webSocket
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
startHealthTracking()
|
||||
|
||||
while (messageQueue.isNotEmpty()) {
|
||||
val msg = messageQueue.poll()
|
||||
if (msg != null) {
|
||||
while (true) {
|
||||
val msg = messageQueue.peek() ?: break
|
||||
if (!isRetryableMessage(msg)) {
|
||||
Log.w(TAG, "Dropping oversized queued message")
|
||||
messageQueue.poll()
|
||||
markQueuedMessageSent(msg)
|
||||
continue
|
||||
}
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg")
|
||||
webSocket.send(msg)
|
||||
if (!webSocket.send(msg)) {
|
||||
recoverRejectedSocket(webSocket)
|
||||
break
|
||||
}
|
||||
messageQueue.poll()
|
||||
markQueuedMessageSent(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,6 +675,7 @@ object HermesWsClient {
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
|
||||
lastPongTimestamp = System.currentTimeMillis()
|
||||
// Resolve any in-flight `request()` awaiting this RPC result/error
|
||||
@@ -549,9 +704,22 @@ object HermesWsClient {
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
// Do NOT log [reason] — it may carry server-side context.
|
||||
Log.d(TAG, "WebSocket closing: $code")
|
||||
webSocket.close(code, reason)
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) {
|
||||
webSocket.close(code, reason)
|
||||
return
|
||||
}
|
||||
closingSocket = webSocket
|
||||
// Do NOT log [reason] — it may carry server-side context.
|
||||
Log.d(TAG, "WebSocket closing: $code")
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
}
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
@@ -559,19 +727,27 @@ object HermesWsClient {
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
// Do NOT log [reason] — it may carry server-side context. The
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
connected.set(false)
|
||||
stopHealthTracking()
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null
|
||||
// Do NOT log [reason] — it may carry server-side context. The
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
connected.set(false)
|
||||
stopHealthTracking()
|
||||
if (code == 4001 || code == 4401 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,21 +756,29 @@ object HermesWsClient {
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
// Log the exception class only — [Throwable.message] can leak URLs
|
||||
// or headers. The message is still inspected internally for auth
|
||||
// detection.
|
||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||
connected.set(false)
|
||||
stopHealthTracking()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || t.message?.contains(
|
||||
"401",
|
||||
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null
|
||||
// Log the exception class only — [Throwable.message] can leak URLs
|
||||
// or headers. The message is still inspected internally for auth
|
||||
// detection.
|
||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||
connected.set(false)
|
||||
stopHealthTracking()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || t.message?.contains(
|
||||
"401",
|
||||
) == true || t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,7 @@ fun ChatConnectionBanner(
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionStatus.RECONNECTING,
|
||||
ConnectionStatus.AUTH_EXPIRED,
|
||||
-> {
|
||||
ConnectionStatus.AUTH_EXPIRED -> {
|
||||
TextButton(
|
||||
onClick = onReloginClick,
|
||||
colors =
|
||||
@@ -135,6 +133,8 @@ fun ChatConnectionBanner(
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionStatus.RECONNECTING -> {}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ class SettingsViewModel(
|
||||
AuthManager.setToken(null)
|
||||
AuthManager.setSessionCookie(null)
|
||||
AuthManager.setWsAuthParam("token")
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
// Don't rebuild ApiClient here — let the navigation complete first
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user