From 67b0cb4cf8dfa0c46476eec2bb78759a83a20afe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 16:53:43 -0400 Subject: [PATCH] fix(logging): silence AUTHENTICATION_FAILED and INTERNAL_SERVER_ERROR from Bugsnag FirebaseMessaging.getToken() fails inside Play Services with IOException("AUTHENTICATION_FAILED") or IOException("INTERNAL_SERVER_ERROR"), wrapped as IOException("FCM Registration failed!") -> ExecutionException. Neither code was in gmsTransientMessages, so FirebasePushTokenProvider reported every failure. Bugsnag 6a9df0a95ab98605a9dfcb88 has 57 events from 4 devices on 2026.8.5, 17 of them from one phone relaunching mid-onboarding. Both strings come from GMS on the device, not app code. INTERNAL_SERVER_ERROR is one Firebase's GmsRpc already treats as retryable. Add both, Firebase's alternate spelling InternalServerError, and PHONE_REGISTRATION_ERROR, which appeared once in the same group on 2026.8.4. Behaviour is unchanged: the provider already resumes null and updateFcmToken() returns early. isGmsTransientError is now internal so the nested exception shape is covered by ErrorUtilsTest. --- .../kotlin/com/getcode/utils/ErrorUtils.kt | 19 ++++++++++++++-- .../com/getcode/utils/ErrorUtilsTest.kt | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt b/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt index 25b93af61..5fa512d29 100644 --- a/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt +++ b/libs/logging/src/main/kotlin/com/getcode/utils/ErrorUtils.kt @@ -93,9 +93,24 @@ object ErrorUtils { throwable is SocketException || throwable.cause is SocketException - private val gmsTransientMessages = setOf("SERVICE_NOT_AVAILABLE", "FIS_AUTH_ERROR", "MISSING_INSTANCEID_SERVICE", "TOO_MANY_REGISTRATIONS") + /** + * Error strings Google Play Services returns for a failed FCM registration. They describe the + * device's GMS state or the FCM backend, not app code, so they stay out of Bugsnag. Firebase + * wraps them as `IOException("FCM Registration failed!")` -> `ExecutionException` -> + * `IOException()`, which is why the cause chain is walked. + */ + private val gmsTransientMessages = setOf( + "SERVICE_NOT_AVAILABLE", + "FIS_AUTH_ERROR", + "MISSING_INSTANCEID_SERVICE", + "TOO_MANY_REGISTRATIONS", + "AUTHENTICATION_FAILED", + "INTERNAL_SERVER_ERROR", + "InternalServerError", + "PHONE_REGISTRATION_ERROR", + ) - private fun isGmsTransientError(throwable: Throwable): Boolean = + internal fun isGmsTransientError(throwable: Throwable): Boolean = generateSequence(throwable) { it.cause } .any { it is java.io.IOException && it.message in gmsTransientMessages } diff --git a/libs/logging/src/test/kotlin/com/getcode/utils/ErrorUtilsTest.kt b/libs/logging/src/test/kotlin/com/getcode/utils/ErrorUtilsTest.kt index 84cc366dc..09efc856c 100644 --- a/libs/logging/src/test/kotlin/com/getcode/utils/ErrorUtilsTest.kt +++ b/libs/logging/src/test/kotlin/com/getcode/utils/ErrorUtilsTest.kt @@ -1,6 +1,8 @@ package com.getcode.utils +import java.io.IOException import java.net.UnknownHostException +import java.util.concurrent.ExecutionException import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -34,4 +36,24 @@ class ErrorUtilsTest { assertFalse(ErrorUtils.shouldReport(error, error)) assertFalse(ErrorUtils.shouldReport(Fault(), error)) } + + @Test + fun `a wrapped FCM registration failure is a transient GMS error`() { + for (code in listOf("AUTHENTICATION_FAILED", "INTERNAL_SERVER_ERROR", "SERVICE_NOT_AVAILABLE")) { + val error = IOException("FCM Registration failed!", ExecutionException(IOException(code))) + assertTrue(ErrorUtils.isGmsTransientError(error), code) + } + } + + @Test + fun `a bare GMS error code is a transient GMS error`() { + assertTrue(ErrorUtils.isGmsTransientError(IOException("InternalServerError"))) + } + + @Test + fun `an unrelated IOException is not a transient GMS error`() { + val error = IOException("FCM Registration failed!", ExecutionException(IOException("disk full"))) + assertFalse(ErrorUtils.isGmsTransientError(error)) + assertFalse(ErrorUtils.isGmsTransientError(Fault())) + } }