Skip to content

Use fallback API hosts when receiving server down response #2368

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 25, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ internal class Backend(
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -272,6 +273,7 @@ internal class Backend(
body,
postFieldsToSign,
backendHelper.authenticationHeaders + extraHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -338,6 +340,7 @@ internal class Backend(
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -402,6 +405,7 @@ internal class Backend(
body,
postFieldsToSign,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -455,6 +459,7 @@ internal class Backend(
body,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -516,6 +521,7 @@ internal class Backend(
body,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

)
}

Expand Down Expand Up @@ -564,6 +570,7 @@ internal class Backend(
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -617,6 +624,7 @@ internal class Backend(
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down Expand Up @@ -677,6 +685,7 @@ internal class Backend(
body,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ internal class BackendHelper(
body,
postFieldsToSign,
authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()}")
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class AmazonBackendTest {
mockClient = mockk()
mockAppConfig = mockk<AppConfig>().apply {
every { baseURL } returns [email protected]
every { fallbackBaseURLs } returns emptyList()
}
dispatcher = SyncDispatcher()
backendHelper = BackendHelper(API_KEY, dispatcher, mockAppConfig, mockClient)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading