fix(push): retry FCM token requests instead of failing on the first timeout

Enabling notifications started failing with

  java.util.concurrent.ExecutionException: java.io.IOException: SERVICE_NOT_AVAILABLE

SERVICE_NOT_AVAILABLE is Firebase saying its endpoint is momentarily
unreachable. It is transient, and Firebase's own guidance is to retry with
backoff. requestToken() made a single attempt with no catch, so a few seconds of
that ended the flow and put a raw stack trace in front of the user.

Nothing about the app or its Firebase project changed — the project is healthy
and the API key still authenticates. The app simply had no tolerance for a
service that is documented as intermittently unavailable, so it worked until the
first time Google was slow to answer.

Now four attempts with 1s/2s/4s backoff.

The wrapping matters: the IOException arrives inside an ExecutionException, so
catching IOException directly would never have matched and the retry would have
been dead code. The check walks the cause chain instead. Failures that are not
network-related — a wrong google-services.json, a missing Play Services install —
are rethrown on the first attempt, since retrying those only delays the same
error.
This commit is contained in:
2026-07-31 20:12:29 -07:00
parent df745e9147
commit 8c4550780e
@@ -8,11 +8,13 @@ import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsS
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent
import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationHandler
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.CoroutineContext
@@ -56,12 +58,71 @@ class NovaFirebaseMessagingService : FirebaseMessagingService(), CoroutineScope
companion object {
// Three attempts, 1s + 2s of backoff. The caller wraps this in
// withTimeout(SAVING_TIMEOUT = 15s), so the retries plus the attempts
// themselves have to fit inside that budget: a fourth attempt would add
// 4s of backoff and, if FCM answers slowly rather than failing fast,
// could be cut off mid-retry and report a timeout instead of the real
// error. Observed failures return in ~130ms, so this leaves ample room.
private const val TOKEN_RETRY_ATTEMPTS = 3
private const val TOKEN_RETRY_BASE_DELAY_MS = 1_000L
suspend fun getToken(): String? {
return runCatching { FirebaseMessaging.getInstance().token.await() }.getOrNull()
}
/**
* FCM token, retrying on the transient failures Firebase expects callers to
* handle themselves.
*
* getToken() surfaces SERVICE_NOT_AVAILABLE when Google's endpoint is
* momentarily unreachable — an IOException, not a configuration problem, and
* Firebase's own guidance is to retry with backoff. A single attempt meant a
* few seconds of that landed in front of the user as
* "java.util.concurrent.ExecutionException: java.io.IOException:
* SERVICE_NOT_AVAILABLE" and enabling notifications simply failed.
*
* Retries three times with 1s/2s backoff. Only network failures qualify;
* a wrong google-services.json or a missing Play Services install fails
* differently and will not start working on the second try, so those are
* rethrown on the first attempt.
*/
suspend fun requestToken(): String {
return FirebaseMessaging.getInstance().token.await()
var lastError: Exception? = null
repeat(TOKEN_RETRY_ATTEMPTS) { attempt ->
try {
return FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
// The IOException arrives wrapped — the message users saw was
// "java.util.concurrent.ExecutionException: java.io.IOException:
// SERVICE_NOT_AVAILABLE" — so catching IOException alone would
// never match and the retry would be dead code.
if (!e.isTransientNetworkFailure()) throw e
lastError = e
if (attempt < TOKEN_RETRY_ATTEMPTS - 1) {
delay(TOKEN_RETRY_BASE_DELAY_MS shl attempt)
}
}
}
throw lastError ?: IOException("Failed to obtain FCM token")
}
/**
* True when the failure is Firebase being momentarily unreachable, which is
* worth another attempt. A misconfigured google-services.json or a missing
* Play Services install fails differently and will not start working on a
* retry, so those are rethrown immediately.
*/
private fun Throwable.isTransientNetworkFailure(): Boolean {
var cause: Throwable? = this
while (cause != null) {
if (cause is IOException) return true
cause = cause.cause
}
return false
}
suspend fun deleteToken() {