fix(websocket): Reconnect-Strategie robuster machen

WebSocketClientImpl:
- Jitter (±25%) zum exponentiellen Backoff hinzugefügt, um
  Thundering-Herd-Effekt bei Server-Neustart zu vermeiden
- Retry-Zähler mit MAX_RETRIES=5: nach 5 konsekutiven Fehlschlägen
  wird ConnectionFailed-Event emittiert (nach ~62s)
- Client versucht weiterhin im MAX_BACKOFF-Intervall (60s) zu
  reconnecten, gibt nicht vollständig auf
- Zähler wird bei erfolgreicher Verbindung zurückgesetzt

WebSocketEvent: neues ConnectionFailed(message) Event hinzugefügt

SettingsViewModel: ConnectionFailed -> SyncStatus.Error,
Connected -> SyncStatus.Idle (Fehler wird beim Reconnect gelöscht)

Closes #73
This commit is contained in:
Jens Reinemann 2026-05-17 03:00:51 +02:00
parent eb5bdd4b7b
commit 75cfc41924
3 changed files with 25 additions and 2 deletions

View file

@ -14,5 +14,6 @@ internal sealed interface WebSocketEvent {
data object FullSyncRequired : WebSocketEvent data object FullSyncRequired : WebSocketEvent
data object Connected : WebSocketEvent data object Connected : WebSocketEvent
data object Disconnected : WebSocketEvent data object Disconnected : WebSocketEvent
data class ConnectionFailed(val message: String) : WebSocketEvent
data class NewMessage(val message: MessageDto) : WebSocketEvent data class NewMessage(val message: MessageDto) : WebSocketEvent
} }

View file

@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.random.Random
@Singleton @Singleton
internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
@ -40,6 +41,8 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
connectionJob?.cancel() connectionJob?.cancel()
connectionJob = scope.launch { connectionJob = scope.launch {
var backoffMs = INITIAL_BACKOFF_MS var backoffMs = INITIAL_BACKOFF_MS
var consecutiveFailures = 0
var connectionFailedEmitted = false
while (isActive) { while (isActive) {
try { try {
val wsUrl = serverUrl.trimEnd('/') val wsUrl = serverUrl.trimEnd('/')
@ -47,6 +50,8 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
.replace("http://", "ws://") .replace("http://", "ws://")
wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") { wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") {
backoffMs = INITIAL_BACKOFF_MS backoffMs = INITIAL_BACKOFF_MS
consecutiveFailures = 0
connectionFailedEmitted = false
_events.emit(WebSocketEvent.Connected) _events.emit(WebSocketEvent.Connected)
for (frame in incoming) { for (frame in incoming) {
if (frame is Frame.Text) { if (frame is Frame.Text) {
@ -57,11 +62,20 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
} catch (e: CancellationException) { } catch (e: CancellationException) {
break break
} catch (_: Exception) { } catch (_: Exception) {
// connection failed retry after backoff consecutiveFailures++
if (consecutiveFailures >= MAX_RETRIES && !connectionFailedEmitted) {
_events.emit(
WebSocketEvent.ConnectionFailed(
"Verbindung nach $MAX_RETRIES Versuchen fehlgeschlagen"
)
)
connectionFailedEmitted = true
}
} }
if (!isActive) break if (!isActive) break
_events.emit(WebSocketEvent.Disconnected) _events.emit(WebSocketEvent.Disconnected)
delay(backoffMs) val jitter = backoffMs * JITTER_FACTOR * (Random.nextDouble() * 2 - 1)
delay(backoffMs + jitter.toLong())
backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS) backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS)
} }
} }
@ -100,6 +114,8 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
private companion object { private companion object {
const val INITIAL_BACKOFF_MS = 2_000L const val INITIAL_BACKOFF_MS = 2_000L
const val MAX_BACKOFF_MS = 60_000L const val MAX_BACKOFF_MS = 60_000L
const val MAX_RETRIES = 5
const val JITTER_FACTOR = 0.25
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
} }
} }

View file

@ -53,6 +53,12 @@ internal class SettingsViewModel @Inject constructor(
when (event) { when (event) {
is WebSocketEvent.FullSyncRequired -> pullSync() is WebSocketEvent.FullSyncRequired -> pullSync()
is WebSocketEvent.InventoryUpdated -> pullSync() is WebSocketEvent.InventoryUpdated -> pullSync()
is WebSocketEvent.ConnectionFailed -> {
_uiState.update { it.copy(syncStatus = SyncStatus.Error(event.message)) }
}
is WebSocketEvent.Connected -> {
_uiState.update { it.copy(syncStatus = SyncStatus.Idle) }
}
else -> {} else -> {}
} }
} }