Skip to content

Use fallback API hosts when receiving server down response #4970

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 21 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import Foundation
extension HTTPRequest.DiagnosticsPath: HTTPRequestPath {

// swiftlint:disable:next force_unwrapping
static let serverHostURL = URL(string: "https://api-diagnostics.revenuecat.com")!
static let serverHostURLs = [URL(string: "https://api-diagnostics.revenuecat.com")!]
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: add final hosts list


var authenticated: Bool {
switch self {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Logging/Strings/NetworkStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ enum NetworkStrings {
case starting_next_request(request: String)
case starting_request(httpMethod: String, path: String)
case retrying_request(httpMethod: String, path: String)
case retrying_request_with_next_host(httpMethod: String, path: String, nextHost: Int)
case failing_url_resolved_to_host(url: URL, resolvedHost: String)
case blocked_network(url: URL, newHost: String?)
case api_request_redirect(from: URL, to: URL)
Expand Down Expand Up @@ -119,6 +120,9 @@ extension NetworkStrings: LogMessage {
case let .retrying_request(httpMethod, path):
return "Retrying request \(httpMethod) \(path)"

case let .retrying_request_with_next_host(httpMethod, path, nextHost):
return "Retrying request \(httpMethod) \(path) with next fallback host: \(nextHost)"

case let .failing_url_resolved_to_host(url, resolvedHost):
return "Failing url '\(url)' resolved to host '\(resolvedHost)'"

Expand Down
89 changes: 68 additions & 21 deletions Sources/Networking/HTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ internal extension HTTPClient {
var headers: HTTPClient.RequestHeaders
var verificationMode: Signing.ResponseVerificationMode
var completionHandler: HTTPClient.Completion<Data>?
var currentHostIndex: Int = 0

/// Whether the request has been retried.
var retried: Bool {
Expand Down Expand Up @@ -281,20 +282,35 @@ internal extension HTTPClient {
var method: HTTPRequest.Method { self.httpRequest.method }
var path: String { self.httpRequest.path.relativePath }

func getCurrentRequestURL(proxyURL: URL?) -> URL? {
return self.httpRequest.path.url(hostURLIndex: self.currentHostIndex, proxyURL: proxyURL)
}

func retriedRequest() -> Self {
var copy = self
copy.retryCount += 1
copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)"
return copy
}

func requestWithNextHost() -> Self? {
let nextHostIndex = self.currentHostIndex + 1
guard self.httpRequest.path.url(hostURLIndex: nextHostIndex) != nil else {
// No more hosts available
return nil
}
var copy = self
copy.currentHostIndex = nextHostIndex
return copy
}

var description: String {
"""
<\(type(of: self)): httpMethod=\(self.method.httpMethod)
path=\(self.path)
headers=\(self.headers.description )
headers=\(self.headers.description)
retried=\(self.retried)
currentHostIndex=\(self.currentHostIndex)
>
"""
}
Expand Down Expand Up @@ -433,18 +449,16 @@ private extension HTTPClient {
requestStartTime: Date) {
RCTestAssertNotMainThread()

let response = self.parse(
urlResponse: urlResponse,
request: request,
urlRequest: urlRequest,
data: data,
error: networkError,
requestStartTime: requestStartTime
)
let response = self.parse(urlResponse: urlResponse,
request: request,
urlRequest: urlRequest,
data: data,
error: networkError,
requestStartTime: requestStartTime)

if let response = response {
let httpURLResponse = urlResponse as? HTTPURLResponse
var requestRetryScheduled = false
var retryScheduled = false

switch response {
case let .success(response):
Expand All @@ -463,26 +477,28 @@ private extension HTTPClient {
case let .failure(error):
let httpURLResponse = urlResponse as? HTTPURLResponse

Logger.debug(Strings.network.api_request_failed(
request.httpRequest,
httpCode: httpURLResponse?.httpStatusCode,
error: error,
metadata: httpURLResponse?.metadata)
)
Logger.debug(Strings.network.api_request_failed(request.httpRequest,
httpCode: httpURLResponse?.httpStatusCode,
error: error,
metadata: httpURLResponse?.metadata))

if httpURLResponse?.isLoadShedder == true {
Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path))
}

requestRetryScheduled = self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse)
retryScheduled = self.retryRequestWithNextHostIfNeeded(request: request,
httpURLResponse: httpURLResponse)
if !retryScheduled {
retryScheduled = self.retryRequestIfNeeded(request: request,
httpURLResponse: httpURLResponse)
}
Copy link
Member Author

Choose a reason for hiding this comment

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

These lines are the actual changes in this method. The other changes in this method are to accommodate the linter (function_body_length).

}

if !requestRetryScheduled {
if !retryScheduled {
request.completionHandler?(response)
}
} else {
Logger.debug(Strings.network.retrying_request(httpMethod: request.method.httpMethod,
path: request.path))
Logger.debug(Strings.network.retrying_request(httpMethod: request.method.httpMethod, path: request.path))

self.state.modify {
$0.queuedRequests.insert(request.retriedRequest(), at: 0)
Expand Down Expand Up @@ -540,7 +556,7 @@ private extension HTTPClient {
}

func convert(request: Request) -> URLRequest? {
guard let requestURL = request.httpRequest.path.url(proxyURL: SystemInfo.proxyURL) else {
guard let requestURL = request.getCurrentRequestURL(proxyURL: SystemInfo.proxyURL) else {
return nil
}
var urlRequest = URLRequest(url: requestURL)
Expand Down Expand Up @@ -623,6 +639,37 @@ private extension HTTPClient {
// MARK: - Request Retry Logic
extension HTTPClient {

/// Evaluates whether a request should be retried with the next host in the list.
///
/// This function checks the HTTP response status code to determine if the request should be retried
/// with the next host in the list. If the retry conditions are met, it schedules the request immediately and
/// returns `true` to indicate that the request was retried.
///
/// - Parameters:
/// - request: The original `HTTPClient.Request` that may need to be retried.
/// - httpURLResponse: An optional `HTTPURLResponse` that contains the status code of the response.
/// - Returns: A Boolean value indicating whether the request was retried.
internal func retryRequestWithNextHostIfNeeded(
request: HTTPClient.Request,
httpURLResponse: HTTPURLResponse?
) -> Bool {

guard let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError,
let nextRequest = request.requestWithNextHost() else {
return false
}

Logger.debug(Strings.network.retrying_request_with_next_host(
httpMethod: nextRequest.method.httpMethod,
path: nextRequest.path,
nextHost: nextRequest.currentHostIndex
))
self.state.modify {
$0.queuedRequests.insert(nextRequest, at: 0)
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm nothing to do for this PR since I believe this could actually happen already, but I wonder if we should dedupe any queued requests here... In android we do something like that, and just "hook" the callbacks to the existing queued/in progress request if it exists.

}
return true
}

/// Evaluates whether a request should be retried and schedules a retry if necessary.
///
/// This function checks the HTTP response status code to determine if the request should be retried.
Expand Down
24 changes: 16 additions & 8 deletions Sources/Networking/HTTPClient/HTTPRequestPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import Foundation

protocol HTTPRequestPath {

/// The base URL for requests to this path.
static var serverHostURL: URL { get }
/// The base URLs for requests to this path, in order of preference.
static var serverHostURLs: [URL] { get }

/// Whether requests to this path are authenticated.
var authenticated: Bool { get }
Expand Down Expand Up @@ -45,12 +45,14 @@ extension HTTPRequestPath {
return "/v1/\(self.pathComponent)"
}

var url: URL? { return self.url(proxyURL: nil) }
var url: URL? { return self.url(hostURLIndex: 0, proxyURL: nil) }

func url(proxyURL: URL? = nil) -> URL? {
return URL(string: self.relativePath, relativeTo: proxyURL ?? Self.serverHostURL)
func url(hostURLIndex: Int, proxyURL: URL? = nil) -> URL? {
guard let baseURL = proxyURL ?? Self.serverHostURLs[safe: hostURLIndex] else {
return nil
}
return URL(string: self.relativePath, relativeTo: baseURL)
}

}

// MARK: - Main paths
Expand Down Expand Up @@ -91,8 +93,14 @@ extension HTTPRequest {

extension HTTPRequest.Path: HTTPRequestPath {

// swiftlint:disable:next force_unwrapping
static let serverHostURL = URL(string: "https://api.revenuecat.com")!
static let serverHostURLs = [
"https://api.revenuecat.com",
"https://api2.revenuecat.com", // TODO: Add real values
"https://api3.revenuecat.com"
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: add final hosts list

].map {
// swiftlint:disable:next force_unwrapping
URL(string: $0)!
}

var authenticated: Bool {
switch self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import Foundation
extension HTTPRequest.PaywallPath: HTTPRequestPath {

// swiftlint:disable:next force_unwrapping
static let serverHostURL = URL(string: "https://api-paywalls.revenuecat.com")!
static let serverHostURLs = [URL(string: "https://api-paywalls.revenuecat.com")!]
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: add final hosts list


var authenticated: Bool {
switch self {
Expand Down
2 changes: 1 addition & 1 deletion Tests/BackendIntegrationTests/OtherIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ class OtherIntegrationTests: BaseBackendIntegrationTests {
func testDoesntRetryUnsupportedURLPaths() async throws {
// Ensure that the each time POST /receipt is called, we mock a 429 error
var stubbedRequestCount = 0
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host)
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host)
stub(condition: isHost(host) && isPath("/v1/subscribers/identify")) { _ in
stubbedRequestCount += 1
return Self.emptyTooManyRequestsResponse()
Expand Down
6 changes: 3 additions & 3 deletions Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,7 @@ class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests {
func testVerifyPurchaseGrantsEntitlementsThroughOnRetryAfter429() async throws {
// Ensure that the first two times POST /receipt is called, we mock a 429 error
// and then proceed normally with the backend on subsequent requests
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host)
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host)
var stubbedRequestCount = 0
stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in
stubbedRequestCount += 1
Expand Down Expand Up @@ -988,7 +988,7 @@ class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests {
func testVerifyPurchaseDoesntGrantEntitlementsAfter429RetriesExhausted() async throws {
// Ensure that the each time POST /receipt is called, we mock a 429 error
var stubbedRequestCount = 0
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host)
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host)
stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in
stubbedRequestCount += 1
return Self.emptyTooManyRequestsResponse()
Expand All @@ -1009,7 +1009,7 @@ class StoreKit1IntegrationTests: BaseStoreKitIntegrationTests {
// Ensure that the each time POST /receipt is called, we mock a 429 error with the
// Is-Retryable header as "false"
var stubbedRequestCount = 0
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host)
let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host)
stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in
stubbedRequestCount += 1
return Self.emptyTooManyRequestsResponse(
Expand Down
Loading