From 8c4550780ef0a3bb3f0c33303f6b9e744ee4b18c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 31 Jul 2026 20:12:29 -0700 Subject: [PATCH] fix(push): retry FCM token requests instead of failing on the first timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../NovaFirebaseMessagingService.kt | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt index 8804d5fc..f96f38b2 100644 --- a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt @@ -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() {