Repository: RevenueCat/purchases-android
Pull request #2368: Use fallback API hosts when receiving server down response
Author: login: ajpallares
Labels: name: pr:other
Created: 2025-04-21T12:24:29Z | Merged: 2025-04-25T13:14:00Z

<!-- Thank you for contributing to Purchases! Before pressing the "Create Pull Request" button, please provide the following: -->

### Checklist
- [x] If applicable, unit tests
- [ ] If applicable, create follow-up issues for `purchases-ios` and hybrids

### Description
This PR implements a retry mechanism using fallback URL hosts for supported requests that fail with a server down error

### Notes
* Only some requests support a fallback API host. Currently only getCustomerInfo and getProductEntitlementMapping support it.
* `AppConfig` now has a `fallbackBaseURLs` containing the list of hosts, in order of preference, to use for supported requests.
* When a request's response is a server error (5XX), the same request is immediately retried with the next host of the array.
* The logic is implemented per-request: that is, if a request fails with a server-down error for host 1 and then falls back and succeeds for host 2, then all future requests will use again host 1 initially. No state is held between different requests.
* No fallback retries happen when using a proxy URL

iOS PR counterpart --> https://github.com/RevenueCat/purchases-ios/pull/4970


Diff:
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt
index de53a493a2..5f2995dab9 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/AppConfig.kt
@@ -50,6 +50,11 @@ internal class AppConfig(
     val baseURL: URL = proxyURL?.also {
         log(LogIntent.INFO, ConfigureStrings.CONFIGURING_PURCHASES_PROXY_URL_SET)
     } ?: URL("https://api.revenuecat.com/")
+    val fallbackBaseURLs: List<URL> = if (proxyURL != null) {
+        emptyList()
+    } else {
+        listOf(URL("https://api-production.8-lives-cat.io/"))
+    }
     val customEntitlementComputation: Boolean
         get() = dangerousSettings.customEntitlementComputation
 
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
index f10cc35e3d..3e5467e44b 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
@@ -166,6 +166,7 @@ internal class Backend(
                     body = null,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -272,6 +273,7 @@ internal class Backend(
                     body,
                     postFieldsToSign,
                     backendHelper.authenticationHeaders + extraHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -338,6 +340,7 @@ internal class Backend(
                     body = null,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -402,6 +405,7 @@ internal class Backend(
                     body,
                     postFieldsToSign,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -455,6 +459,7 @@ internal class Backend(
                     body,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -516,6 +521,7 @@ internal class Backend(
                     body,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -564,6 +570,7 @@ internal class Backend(
                     body = null,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -617,6 +624,7 @@ internal class Backend(
                     body = null,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
@@ -677,6 +685,7 @@ internal class Backend(
                     body,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
                 )
             }
 
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/BackendHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/BackendHelper.kt
index eb7b6f25e7..9b520e4229 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/BackendHelper.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/BackendHelper.kt
@@ -32,6 +32,7 @@ internal class BackendHelper(
                         body,
                         postFieldsToSign,
                         authenticationHeaders,
+                        fallbackBaseURLs = appConfig.fallbackBaseURLs,
                     )
                 }
 
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
index a750310089..703479ac78 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
@@ -99,7 +99,7 @@ internal class HTTPClient(
      * @throws JSONException Thrown for any JSON errors, not thrown for returned HTTP error codes
      * @throws IOException Thrown for any unexpected errors, not thrown for returned HTTP error codes
      */
-    @Suppress("LongParameterList")
+    @Suppress("LongParameterList", "LongMethod")
     @Throws(JSONException::class, IOException::class)
     fun performRequest(
         baseURL: URL,
@@ -108,6 +108,8 @@ internal class HTTPClient(
         postFieldsToSign: List<Pair<String, String>>?,
         requestHeaders: Map<String, String>,
         refreshETag: Boolean = false,
+        fallbackBaseURLs: List<URL> = emptyList(),
+        fallbackURLIndex: Int = 0,
     ): HTTPResult {
         if (appConfig.forceServerErrors) {
             warnLog("Forcing server error for request to ${endpoint.getPath()}")
@@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
+        ) {
+            // Handle server errors with fallback URLs
+            val fallbackBaseURL = fallbackBaseURLs[fallbackURLIndex]
+            log(
+                LogIntent.DEBUG,
+                NetworkStrings.RETRYING_CALL_WITH_FALLBACK_URL.format(endpoint.getPath(), fallbackBaseURL),
+            )
+            callResult = performRequest(
+                fallbackBaseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag,
+                fallbackBaseURLs,
+                fallbackURLIndex + 1,
+            )
         }
         return callResult
     }
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt
index 69106e9c95..fa5e0541eb 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt
@@ -84,4 +84,24 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) {
             ->
                 false
         }
+
+    val supportsFallbackBaseURLs: Boolean
+        get() = when (this) {
+            is GetOfferings,
+            GetProductEntitlementMapping,
+            ->
+                true
+
+            is LogIn,
+            PostReceipt,
+            PostRedeemWebPurchase,
+            is GetAmazonReceipt,
+            is PostAttributes,
+            PostDiagnostics,
+            PostPaywallEvents,
+            is GetCustomerInfo,
+            is GetCustomerCenterConfig,
+            ->
+                false
+        }
 }
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/NetworkStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/NetworkStrings.kt
index 0643d231f1..337188b7df 100644
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/NetworkStrings.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/NetworkStrings.kt
@@ -6,6 +6,7 @@ internal object NetworkStrings {
     const val HTTP_RESPONSE_PAYLOAD_NULL = "HTTP Response payload is null"
     const val ETAG_RETRYING_CALL = "We were expecting to be able to return a cached response, but we can't find it. " +
         "Retrying call with a new ETag"
+    const val RETRYING_CALL_WITH_FALLBACK_URL = "Retrying request %s using fallback URL %s"
     const val ETAG_CALL_ALREADY_RETRIED = "We can't find the cached response, but call has already been retried. " +
         "Returning result from backend: %s"
     const val SAME_CALL_SCHEDULED_WITHOUT_JITTER = "Request already scheduled without jitter delay, adding " +
diff --git a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonBackendTest.kt b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonBackendTest.kt
index fb9b753c18..781d30c713 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonBackendTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/amazon/AmazonBackendTest.kt
@@ -46,6 +46,7 @@ class AmazonBackendTest {
         mockClient = mockk()
         mockAppConfig = mockk<AppConfig>().apply {
             every { baseURL } returns this@AmazonBackendTest.baseURL
+            every { fallbackBaseURLs } returns emptyList()
         }
         dispatcher = SyncDispatcher()
         backendHelper = BackendHelper(API_KEY, dispatcher, mockAppConfig, mockClient)
diff --git a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt
index d5dfadc0e1..bedb267e0e 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/backend_integration_tests/BaseBackendIntegrationTest.kt
@@ -85,6 +85,7 @@ internal abstract class BaseBackendIntegrationTest {
             every { forceServerErrors } returns false
             every { forceSigningErrors } returns false
             every { isAppBackgrounded } returns false
+            every { fallbackBaseURLs } returns emptyList()
         }
         dispatcher = Dispatcher(Executors.newSingleThreadScheduledExecutor(), runningIntegrationTests = true)
         diagnosticsDispatcher = Dispatcher(Executors.newSingleThreadScheduledExecutor(), runningIntegrationTests = true)
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt
index e58b5bf81a..b972a82f5b 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt
@@ -438,4 +438,36 @@ class AppConfigTest {
                 "showInAppMessagesAutomatically=false, " +
                 "baseURL=https://api.revenuecat.com/)")
     }
+
+    // region Fallback API host
+
+    @Test
+    fun `appConfig returns expected fallback URLs list when no proxy URL is set`() {
+        val appConfig = AppConfig(
+            context = mockk(relaxed = true),
+            purchasesAreCompletedBy = REVENUECAT,
+            showInAppMessagesAutomatically = false,
+            platformInfo = PlatformInfo(flavor = "native", version = "3.2.0"),
+            proxyURL = null,
+            store = Store.PLAY_STORE,
+            isDebugBuild = false,
+        )
+        assertThat(appConfig.fallbackBaseURLs).isEqualTo(listOf(URL("https://api-production.8-lives-cat.io/")))
+    }
+
+    @Test
+    fun `appConfig returns empty fallback URLs list when a proxy URL is set`() {
+        val appConfig = AppConfig(
+            context = mockk(relaxed = true),
+            purchasesAreCompletedBy = REVENUECAT,
+            showInAppMessagesAutomatically = false,
+            platformInfo = PlatformInfo(flavor = "native", version = "3.2.0"),
+            proxyURL = URL("https://proxy.com"),
+            store = Store.PLAY_STORE,
+            isDebugBuild = false,
+        )
+        assertThat(appConfig.fallbackBaseURLs).isEmpty()
+    }
+
+    // endregion Fallback API host
 }
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt
index b3267a0bb2..5f88306e7c 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt
@@ -107,7 +107,8 @@ internal abstract class BaseHTTPClientTest {
         endpoint: Endpoint,
         expectedResult: HTTPResult,
         verificationResult: VerificationResult = VerificationResult.NOT_REQUESTED,
-        requestDateHeader: Date? = null
+        requestDateHeader: Date? = null,
+        server: MockWebServer = this.server,
     ) {
         every {
             mockETagManager.getHTTPResultFromCacheOrBackend(
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
index e882b8df63..3c93003e45 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
@@ -20,11 +20,13 @@ import io.mockk.just
 import io.mockk.mockk
 import io.mockk.verify
 import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
 import org.assertj.core.api.Assertions.assertThat
 import org.json.JSONException
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import java.net.URL
 import java.util.Date
 import kotlin.time.Duration.Companion.milliseconds
 import org.robolectric.annotation.Config as AnnotationConfig
@@ -682,4 +684,231 @@ internal class HTTPClientTest: BaseHTTPClientTest() {
     }
 
     // endregion
+
+    // region Fallback API host
+
+    @Test
+    fun `performRequest retries call with fallback API host if server returns 500`() {
+        // This test requires an endpoint that supports fallback host URLs
+        val endpoint = Endpoint.GetOfferings("test_user_id")
+        assert(endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.ERROR
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(),
+            server = fallbackServer,
+        )
+
+        client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        val request0 = server.takeRequest()
+        assertThat(request0.method).isEqualTo("GET")
+        assertThat(request0.path).isEqualTo("/v1/subscribers/test_user_id/offerings")
+
+        assertThat(server.requestCount).isEqualTo(1)
+
+        val request1 = fallbackServer.takeRequest()
+        assertThat(request1.method).isEqualTo("GET")
+        assertThat(request1.path).isEqualTo("/v1/subscribers/test_user_id/offerings")
+    }
+
+    @Test
+    fun `performRequest does not retry call with fallback API host if server returns non-500 error`() {
+        // This test requires an endpoint that supports fallback host URLs
+        val endpoint = Endpoint.GetOfferings("test_user_id")
+        assert(endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.NOT_FOUND
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(),
+            server = fallbackServer,
+        )
+
+        client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        val request = server.takeRequest()
+        assertThat(request.method).isEqualTo("GET")
+        assertThat(request.path).isEqualTo("/v1/subscribers/test_user_id/offerings")
+        assertThat(server.requestCount).isEqualTo(1)
+        assertThat(fallbackServer.requestCount).isEqualTo(0)
+    }
+
+    @Test
+    fun `performRequest does not retry call with fallback API host if endpoint does not support fallback base URLs`() {
+        // This test requires an endpoint that does not support fallback host URLs
+        val endpoint = Endpoint.GetCustomerInfo("test_user_id")
+        assert(!endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.ERROR
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(),
+            server = fallbackServer,
+        )
+
+        client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        val request = server.takeRequest()
+        assertThat(request.method).isEqualTo("GET")
+        assertThat(request.path).isEqualTo("/v1/subscribers/test_user_id")
+        assertThat(server.requestCount).isEqualTo(1)
+        assertThat(fallbackServer.requestCount).isEqualTo(0)
+    }
+
+    @Test
+    fun `performRequest returns failed response of fallback request if main server returns 500`() {
+        // This test requires an endpoint that supports fallback host URLs
+        val endpoint = Endpoint.GetOfferings("test_user_id")
+        assert(endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.ERROR
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = RCHTTPStatusCodes.NOT_FOUND),
+            server = fallbackServer,
+        )
+
+        val result = client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        assertThat(result.responseCode).`as`("response code is 404").isEqualTo(404)
+    }
+
+    @Test
+    fun `performRequest returns failed response (500) if both the main server and fallback server return 500`() {
+        // This test requires an endpoint that supports fallback host URLs
+        val endpoint = Endpoint.GetOfferings("test_user_id")
+        assert(endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.ERROR
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode),
+            server = fallbackServer,
+        )
+
+        val result = client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        assertThat(server.requestCount).isEqualTo(1)
+        assertThat(fallbackServer.requestCount).isEqualTo(1)
+        assertThat(result.responseCode).`as`("response code is 500").isEqualTo(500)
+    }
+
+    @Test
+    fun `performRequest returns successful response of fallback request if main server returns 500`() {
+        // This test requires an endpoint that supports fallback host URLs
+        val endpoint = Endpoint.GetProductEntitlementMapping
+        assert(endpoint.supportsFallbackBaseURLs)
+
+        val fallbackServer = MockWebServer()
+        val fallbackBaseURL = fallbackServer.url("/v1").toUrl()
+
+        val serverDownResponseCode = RCHTTPStatusCodes.ERROR
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(responseCode = serverDownResponseCode)
+        )
+
+        enqueue(
+            endpoint,
+            expectedResult = HTTPResult.createResult(223, "{'response': 'OK'}"),
+            server = fallbackServer
+        )
+
+        val result = client.performRequest(
+            baseURL,
+            endpoint,
+            body = null,
+            postFieldsToSign = null,
+            mapOf("" to ""),
+            fallbackBaseURLs = listOf(fallbackBaseURL),
+        )
+
+        assertThat(result.responseCode).`as`("responsecode is 223").isEqualTo(223)
+        assertThat(result.body.getString("response")).`as`("response is OK").isEqualTo("OK")
+    }
+
+    // endregion Fallback API host
 }
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt
index 518f03bf90..485b1eef36 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetCustomerCenterConfigTest.kt
@@ -227,6 +227,7 @@ class BackendGetCustomerCenterConfigTest {
         appConfig = mockk<AppConfig>().apply {
             every { baseURL } returns mockBaseURL
             every { customEntitlementComputation } returns false
+            every { fallbackBaseURLs } returns emptyList()
         }
         httpClient = mockk()
         val backendHelper = BackendHelper("TEST_API_KEY", SyncDispatcher(), appConfig, httpClient)
@@ -326,6 +327,7 @@ class BackendGetCustomerCenterConfigTest {
                 any(),
                 any(),
                 any(),
+                fallbackBaseURLs = any(),
             )
         } answers {
             if (delayMs != null) {
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendPaywallEventTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendPaywallEventTest.kt
index bf21161f45..37f6e0031c 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendPaywallEventTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendPaywallEventTest.kt
@@ -69,6 +69,7 @@ class BackendPaywallEventTest {
     @Before
     fun setUp() {
         appConfig = mockk()
+        every { appConfig.fallbackBaseURLs } returns emptyList()
         httpClient = mockk()
         unmockkObject(JsonProvider)
         val backendHelper = BackendHelper("TEST_API_KEY", SyncDispatcher(), appConfig, httpClient)
@@ -288,6 +289,7 @@ class BackendPaywallEventTest {
                 any(),
                 any(),
                 any(),
+                fallbackBaseURLs = any(),
             )
         } answers {
             if (delayMs != null) {
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt
index 2292444eea..e5c2d2b290 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt
@@ -49,6 +49,7 @@ class BackendRedeemWebPurchaseTest {
     fun setUp() {
         appConfig = mockk<AppConfig>().apply {
             every { baseURL } returns mockBaseURL
+            every { fallbackBaseURLs } returns emptyList()
         }
         httpClient = mockk()
         val backendHelper = BackendHelper("TEST_API_KEY", SyncDispatcher(), appConfig, httpClient)
diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt
index c73beb9914..7b5e551b61 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt
@@ -97,6 +97,7 @@ class BackendTest {
     private val mockAppConfig: AppConfig = mockk<AppConfig>().apply {
         every { baseURL } returns mockBaseURL
         every { customEntitlementComputation } returns false
+        every { fallbackBaseURLs } returns emptyList()
     }
     private val dispatcher = spyk(SyncDispatcher())
     private val backendHelper = BackendHelper(API_KEY, dispatcher, mockAppConfig, mockClient)
diff --git a/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesBackendTests.kt b/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesBackendTests.kt
index 08ecb2f749..f560fe2276 100644
--- a/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesBackendTests.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/subscriberattributes/SubscriberAttributesBackendTests.kt
@@ -45,6 +45,7 @@ class SubscriberAttributesPosterTests {
     private val mockAppConfig = mockk<AppConfig>().apply {
         every { baseURL } returns mockBaseURL
         every { customEntitlementComputation } returns false
+        every { fallbackBaseURLs } returns emptyList()
     }
     private val appUserID = "jerry"
     private val dispatcher = SyncDispatcher()


Reviews:
user: login: JayShortway
body: Nice job! Just 2 minor comments.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:16:32Z
user: login: ajpallares
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:22:05Z
user: login: ajpallares
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:23:38Z
user: login: JayShortway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:26:55Z
user: login: JayShortway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:28:12Z
user: login: ajpallares
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:30:36Z
user: login: JayShortway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T09:42:21Z
user: login: tonidero
body: Great job!! No comments from me!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T10:40:03Z
user: login: JayShortway
body: Awesome, thanks for adding the test!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T11:38:15Z
user: login: ajpallares
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T11:46:38Z

Review comments:
diff_hunk: @@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
user: login: JayShortway
body: This checks if `fallbackURLIndex` is valid, but we're using `fallbackURLIndex + 1` (on L169 below). 
created_at: 2025-04-25T09:09:13Z
author_association: MEMBER
diff_hunk: @@ -682,4 +684,195 @@ internal class HTTPClientTest: BaseHTTPClientTest() {
     }
 
     // endregion
+
+    // region Fallback API host
path: purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
user: login: JayShortway
body: Could we add a test where the fallback server returns 500 as well?
created_at: 2025-04-25T09:14:08Z
author_association: MEMBER
diff_hunk: @@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
user: login: ajpallares
body: Right. The check `fallbackURLIndex in fallbackBaseURLs.indices` is to make sure that we obtain a new base URL in `fallbackBaseURLs[fallbackURLIndex]` (L156).
Now, in L169 I pass `fallbackURLIndex + 1` for the `fallbackURLIndex` parameter of `performRequest`. That is, if the request returns a 5XX using the base URL `fallbackBaseURLs[fallbackURLIndex]`, then retry with the base URL at index `fallbackURLIndex + 1`. In the implementation we'll go through L153 again checking if `fallbackURLIndex + 1 in fallbackBaseURLs.indices`.

I hope this really confusing explanation made sense 😅 
created_at: 2025-04-25T09:22:05Z
author_association: MEMBER
diff_hunk: @@ -682,4 +684,195 @@ internal class HTTPClientTest: BaseHTTPClientTest() {
     }
 
     // endregion
+
+    // region Fallback API host
path: purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
user: login: ajpallares
body: That's a good point, yes. To make sure that no new requests happen and that the 500 error is returned. Thanks for the suggestion!
created_at: 2025-04-25T09:23:38Z
author_association: MEMBER
diff_hunk: @@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
user: login: JayShortway
body: Oh yes, you're getting the url at L156 already. Makes sense! Thanks for explaining! 
created_at: 2025-04-25T09:26:54Z
author_association: MEMBER
diff_hunk: @@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
user: login: JayShortway
body: Perf nit, but a comparison should be faster than a "contains": 
```suggestion
            fallbackURLIndex <= fallbackBaseURLs.lastIndex
```
created_at: 2025-04-25T09:28:12Z
author_association: MEMBER
diff_hunk: @@ -682,4 +684,195 @@ internal class HTTPClientTest: BaseHTTPClientTest() {
     }
 
     // endregion
+
+    // region Fallback API host
path: purchases/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt
user: login: ajpallares
body: Done in https://github.com/RevenueCat/purchases-android/pull/2368/commits/03737539bba899d704ea003629ee01188457c95c
created_at: 2025-04-25T09:30:36Z
author_association: MEMBER
diff_hunk: @@ -136,7 +138,36 @@ internal class HTTPClient(
         }
         if (callResult == null) {
             log(LogIntent.WARNING, NetworkStrings.ETAG_RETRYING_CALL)
-            callResult = performRequest(baseURL, endpoint, body, postFieldsToSign, requestHeaders, refreshETag = true)
+            callResult = performRequest(
+                baseURL,
+                endpoint,
+                body,
+                postFieldsToSign,
+                requestHeaders,
+                refreshETag = true,
+                fallbackBaseURLs,
+                fallbackURLIndex,
+            )
+        } else if (RCHTTPStatusCodes.isServerError(callResult.responseCode) &&
+            endpoint.supportsFallbackBaseURLs &&
+            fallbackURLIndex in fallbackBaseURLs.indices
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/HTTPClient.kt
user: login: JayShortway
body: Nevermind this! `indices` is a range already (not a collection), so "contains" is super fast. 
created_at: 2025-04-25T09:42:21Z
author_association: MEMBER
diff_hunk: @@ -516,6 +521,7 @@ internal class Backend(
                     body,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
user: login: tonidero
body: Just thinking to myself but should we even pass these fallback URLs for non-supported endpoints? I guess we are handling that separately though so it might be ok to do it like this so we don't forget on new endpoints, and handle in the `Endpoint` as you're doing :) 
created_at: 2025-04-25T10:35:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -516,6 +521,7 @@ internal class Backend(
                     body,
                     postFieldsToSign = null,
                     backendHelper.authenticationHeaders,
+                    fallbackBaseURLs = appConfig.fallbackBaseURLs,
path: purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
user: login: ajpallares
body: Yeah, I had this same dilemma about this... honestly, I don't know what the best option is. That said, I wanted to avoid duplicating the logic and, most importantly, as you say, I didn't want to forget adding this on new endpoints. 

My first approach was to have the `fallbackBaseURLs` parameter default to `appConfig.fallbackBaseURLs`, but that led to some issues (in unit tests, IIRC).
created_at: 2025-04-25T11:46:38Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-21T16:59:56Z
body: ## [Codecov](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat) Report
Attention: Patch coverage is `83.33333%` with `9 lines` in your changes missing coverage. Please review.
> Project coverage is 79.68%. Comparing base [(`dafa108`)](https://app.codecov.io/gh/RevenueCat/purchases-android/commit/dafa108e04737193b89d6cbf20f8758b78ebf741?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat) to head [(`c1cd4b3`)](https://app.codecov.io/gh/RevenueCat/purchases-android/commit/c1cd4b325d5d2d8464851aafa3fcb316632b7272?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat) | Patch % | Lines |
|---|---|---|
| [...revenuecat/purchases/common/networking/Endpoint.kt](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?src=pr&el=tree&filepath=purchases%2Fsrc%2Fmain%2Fkotlin%2Fcom%2Frevenuecat%2Fpurchases%2Fcommon%2Fnetworking%2FEndpoint.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat#diff-cHVyY2hhc2VzL3NyYy9tYWluL2tvdGxpbi9jb20vcmV2ZW51ZWNhdC9wdXJjaGFzZXMvY29tbW9uL25ldHdvcmtpbmcvRW5kcG9pbnQua3Q=) | 42.85% | [0 Missing and 8 partials :warning: ](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat) |
| [...tlin/com/revenuecat/purchases/common/HTTPClient.kt](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?src=pr&el=tree&filepath=purchases%2Fsrc%2Fmain%2Fkotlin%2Fcom%2Frevenuecat%2Fpurchases%2Fcommon%2FHTTPClient.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat#diff-cHVyY2hhc2VzL3NyYy9tYWluL2tvdGxpbi9jb20vcmV2ZW51ZWNhdC9wdXJjaGFzZXMvY29tbW9uL0hUVFBDbGllbnQua3Q=) | 96.29% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2368      +/-   ##
==========================================
+ Coverage   79.67%   79.68%   +0.01%     
==========================================
  Files         284      284              
  Lines       10182    10235      +53     
  Branches     1441     1456      +15     
==========================================
+ Hits         8112     8156      +44     
  Misses       1453     1453              
- Partials      617      626       +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/RevenueCat/purchases-android/pull/2368?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=RevenueCat).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE