fix(android): hysteresis on /healthz reachable signal — 3 consecutive failures
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:
2026-06-04 13:40:49 -04:00
parent 1e17eeda72
commit faa0c7024b
@@ -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 {