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:
parent
eb5bdd4b7b
commit
75cfc41924
3 changed files with 25 additions and 2 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue