fix(android): hysteresis on /healthz reachable signal — 3 consecutive failures
android / Build + lint + test (push) Successful in 3m55s
android / Build + lint + test (push) Successful in 3m55s
The single-failure flip-to-false produced two false-positive permanent
banner cases:
1. Startup race — 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. One
failure was enough to lock the banner on for the next 5 minutes.
2. Deployments whose reverse proxy routes only /api/* to the Go server —
/healthz never reaches the handler, so /healthz polls fail forever even
though every real /api/* call succeeds. User sees "Server unreachable"
permanently and OfflineGatedDataSource starts throwing OfflineException
on every audio cache miss, silently breaking playback of uncached
tracks.
Now we require 3 consecutive failures (~15 min at the 5-min poll cadence)
before flipping reachable=false, and any single success resets the
counter. Adds Timber.w/i at the flip transitions so operator logcat can
diagnose genuine outages.
This commit is contained in:
+30
-1
@@ -10,11 +10,25 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
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.
|
||||
* `Skipped` means the server didn't include `min_client_version`
|
||||
@@ -51,6 +65,8 @@ class VersionCheckController @Inject constructor(
|
||||
private val internalReachable = MutableStateFlow(true)
|
||||
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
@@ -69,9 +85,22 @@ class VersionCheckController @Inject constructor(
|
||||
val outcome = runCatching { api.check() }
|
||||
val response = outcome.getOrNull()
|
||||
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
|
||||
}
|
||||
if (!internalReachable.value) {
|
||||
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
internalReachable.value = true
|
||||
val min = response.minClientVersion
|
||||
internal.value = when {
|
||||
|
||||
Reference in New Issue
Block a user