From faa0c7024ba276b0b66c9a2c67069b62c370ce89 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 13:40:49 -0400 Subject: [PATCH] =?UTF-8?q?fix(android):=20hysteresis=20on=20/healthz=20re?= =?UTF-8?q?achable=20signal=20=E2=80=94=203=20consecutive=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../update/data/VersionCheckController.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt index 1aa51435..c8749d95 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt @@ -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 = 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 {