fix(android): hysteresis on /healthz reachable signal #82
+30
-1
@@ -10,11 +10,25 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
// Hysteresis on the reachable signal. Flipping internalReachable to false on
|
||||||
|
// the very first /healthz failure produced two false positives:
|
||||||
|
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
|
||||||
|
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
|
||||||
|
// ("http://localhost:8080") before the real server URL has hydrated.
|
||||||
|
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
|
||||||
|
// /healthz never reaches the handler, so the user sees a permanent
|
||||||
|
// "Server unreachable" banner even though all real /api/* calls succeed.
|
||||||
|
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
|
||||||
|
// the banner only fires on sustained, real unreachability — and a single
|
||||||
|
// success at any point resets the counter so transient hiccups self-clear.
|
||||||
|
private const val REACHABILITY_FAILURE_THRESHOLD = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of the most recent /healthz version-compatibility check.
|
* Result of the most recent /healthz version-compatibility check.
|
||||||
* `Skipped` means the server didn't include `min_client_version`
|
* `Skipped` means the server didn't include `min_client_version`
|
||||||
@@ -51,6 +65,8 @@ class VersionCheckController @Inject constructor(
|
|||||||
private val internalReachable = MutableStateFlow(true)
|
private val internalReachable = MutableStateFlow(true)
|
||||||
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||||
|
|
||||||
|
private var consecutiveFailures = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -69,9 +85,22 @@ class VersionCheckController @Inject constructor(
|
|||||||
val outcome = runCatching { api.check() }
|
val outcome = runCatching { api.check() }
|
||||||
val response = outcome.getOrNull()
|
val response = outcome.getOrNull()
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
internalReachable.value = false
|
consecutiveFailures++
|
||||||
|
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
|
||||||
|
internalReachable.value
|
||||||
|
) {
|
||||||
|
Timber.w(
|
||||||
|
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
|
||||||
|
consecutiveFailures,
|
||||||
|
)
|
||||||
|
internalReachable.value = false
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!internalReachable.value) {
|
||||||
|
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0
|
||||||
internalReachable.value = true
|
internalReachable.value = true
|
||||||
val min = response.minClientVersion
|
val min = response.minClientVersion
|
||||||
internal.value = when {
|
internal.value = when {
|
||||||
|
|||||||
Reference in New Issue
Block a user