diff --git a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift index 44540d3109..e63220292a 100644 --- a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift +++ b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift @@ -46,10 +46,10 @@ extension HTTPRequest.DiagnosticsPath: HTTPRequestPath { } } - var pathComponent: String { + var relativePath: String { switch self { case .postDiagnostics: - return "diagnostics" + return "/v1/diagnostics" } } diff --git a/Sources/Logging/Strings/NetworkStrings.swift b/Sources/Logging/Strings/NetworkStrings.swift index ef35f77c51..327dd808b8 100644 --- a/Sources/Logging/Strings/NetworkStrings.swift +++ b/Sources/Logging/Strings/NetworkStrings.swift @@ -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_fallback_path(httpMethod: String, path: String) 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) @@ -119,6 +120,9 @@ extension NetworkStrings: LogMessage { case let .retrying_request(httpMethod, path): return "Retrying request \(httpMethod) \(path)" + case let .retrying_request_with_fallback_path(httpMethod, path): + return "Retrying request using fallback host: \(httpMethod) \(path)" + case let .failing_url_resolved_to_host(url, resolvedHost): return "Failing url '\(url)' resolved to host '\(resolvedHost)'" diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index 60cd28dbb3..7fe757c140 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -245,6 +245,7 @@ internal extension HTTPClient { var headers: HTTPClient.RequestHeaders var verificationMode: Signing.ResponseVerificationMode var completionHandler: HTTPClient.Completion? + private(set) var fallbackHostIndex: Int? /// Whether the request has been retried. var retried: Bool { @@ -281,11 +282,28 @@ 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(proxyURL: proxyURL, fallbackHostIndex: self.fallbackHostIndex) + } + func retriedRequest() -> Self { var copy = self copy.retryCount += 1 copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)" + return copy + } + func requestWithNextFallbackHost(proxyURL: URL?) -> Self? { + guard proxyURL == nil else { + // Don't fallback to next host if proxyURL is set + return nil + } + var copy = self + copy.fallbackHostIndex = self.fallbackHostIndex?.advanced(by: 1) ?? 0 + guard copy.getCurrentRequestURL(proxyURL: nil) != nil else { + // No more fallback hosts available + return nil + } return copy } @@ -293,7 +311,7 @@ internal extension HTTPClient { """ <\(type(of: self)): httpMethod=\(self.method.httpMethod) path=\(self.path) - headers=\(self.headers.description ) + headers=\(self.headers.description) retried=\(self.retried) > """ @@ -433,18 +451,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): @@ -463,26 +479,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.retryRequestWithNextFallbackHostIfNeeded(request: request, + httpURLResponse: httpURLResponse) + if !retryScheduled { + retryScheduled = self.retryRequestIfNeeded(request: request, + httpURLResponse: httpURLResponse) + } } - 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) @@ -540,7 +558,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) @@ -623,6 +641,36 @@ private extension HTTPClient { // MARK: - Request Retry Logic extension HTTPClient { + /// Evaluates whether a request should be retried with the next host in the list of fallback hosts. + /// + /// This function checks the HTTP response status code to determine if the request should be retried + /// with the next fallback hosts. 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 retryRequestWithNextFallbackHostIfNeeded( + request: HTTPClient.Request, + httpURLResponse: HTTPURLResponse? + ) -> Bool { + + guard let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError, + let nextRequest = request.requestWithNextFallbackHost(proxyURL: SystemInfo.proxyURL) else { + return false + } + + Logger.debug(Strings.network.retrying_request_with_fallback_path( + httpMethod: nextRequest.method.httpMethod, + path: nextRequest.path + )) + self.state.modify { + $0.queuedRequests.insert(nextRequest, at: 0) + } + 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. diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index 0517e16378..2fe647665d 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -18,6 +18,11 @@ protocol HTTPRequestPath { /// The base URL for requests to this path. static var serverHostURL: URL { get } + /// The fallback hosts to use when the main server is down. + /// + /// Not all endpoints have a fallback host, but some do. + var fallbackHosts: [URL] { get } + /// Whether requests to this path are authenticated. var authenticated: Bool { get } @@ -30,27 +35,35 @@ protocol HTTPRequestPath { /// Whether endpoint requires a nonce for signature verification. var needsNonceForSigning: Bool { get } - /// The path component for this endpoint. - var pathComponent: String { get } - /// The name of the endpoint. var name: String { get } + /// The full relative path for this endpoint. + var relativePath: String { get } } extension HTTPRequestPath { - /// The full relative path for this endpoint. - var relativePath: String { - return "/v1/\(self.pathComponent)" + var fallbackHosts: [URL] { + return [] } var url: URL? { return self.url(proxyURL: nil) } - func url(proxyURL: URL? = nil) -> URL? { - return URL(string: self.relativePath, relativeTo: proxyURL ?? Self.serverHostURL) + func url(proxyURL: URL? = nil, fallbackHostIndex: Int? = nil) -> URL? { + let baseURL: URL + if let proxyURL { + baseURL = proxyURL + } else if let fallbackHostIndex { + guard let fallbackHost = self.fallbackHosts[safe: fallbackHostIndex] else { + return nil + } + baseURL = fallbackHost + } else { + baseURL = Self.serverHostURL + } + return URL(string: self.relativePath, relativeTo: baseURL) } - } // MARK: - Main paths @@ -95,6 +108,16 @@ extension HTTPRequest.Path: HTTPRequestPath { // swiftlint:disable:next force_unwrapping static let serverHostURL = URL(string: "https://api.revenuecat.com")! + var fallbackHosts: [URL] { + switch self { + case .getOfferings, .getProductEntitlementMapping: + // swiftlint:disable:next force_unwrapping + return [URL(string: "https://api-production.8-lives-cat.io")!] + default: + return [] + } + } + var authenticated: Bool { switch self { case .getCustomerInfo, @@ -180,6 +203,10 @@ extension HTTPRequest.Path: HTTPRequestPath { } } + var relativePath: String { + return "/v1/\(self.pathComponent)" + } + var pathComponent: String { switch self { case let .getCustomerInfo(appUserID): diff --git a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift index 9092ecebf9..6efc88fb3c 100644 --- a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift +++ b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift @@ -46,10 +46,10 @@ extension HTTPRequest.PaywallPath: HTTPRequestPath { } } - var pathComponent: String { + var relativePath: String { switch self { case .postEvents: - return "events" + return "/v1/events" } } diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index 093bcba1fa..ca41b06003 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -1814,13 +1814,36 @@ extension HTTPClientTests { expect(secondRetriedRequest.retryCount).to(equal(2)) } + func testRetryingRequestKeepsFallbackHostIndex() throws { + let request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) + let nextFallbackHostRequest = try XCTUnwrap(request.requestWithNextFallbackHost(proxyURL: nil)) + + let retriedRequest = nextFallbackHostRequest.retriedRequest() + let secondRetriedRequest = nextFallbackHostRequest.retriedRequest() + + expect(retriedRequest.fallbackHostIndex).to(equal(0)) + expect(secondRetriedRequest.fallbackHostIndex).to(equal(0)) + } + private func buildEmptyRequest( - isRetryable: Bool + isRetryable: Bool, + hasFallbackHosts: Bool = false ) -> HTTPClient.Request { let completionHandler: HTTPClient.Completion = { _ in return } + let path: HTTPRequest.Path + if hasFallbackHosts { + path = .getOfferings(appUserID: "abc123") + expect(path.fallbackHosts).toNot( + beEmpty(), + description: "This test requires a path that has at least 1 fallback host" + ) + } else { + path = .getCustomerInfo(appUserID: "abc123") + } + let request: HTTPClient.Request = .init( - httpRequest: .init(method: .get, path: .getCustomerInfo(appUserID: "abc123"), isRetryable: isRetryable), + httpRequest: .init(method: .get, path: path, isRetryable: isRetryable), authHeaders: .init(), defaultHeaders: .init(), verificationMode: .default, @@ -2372,6 +2395,226 @@ extension HTTPClientTests { expect(mockOperationDispatcher.invokedDispatchOnWorkerThreadWithTimeIntervalCount).to(equal(1)) expect(mockOperationDispatcher.invokedDispatchOnWorkerThreadWithTimeIntervalParam).to(equal(0)) } + + // MARK: - Fallback Host Retry Tests + + func testNewRequestStartsWithMainPath() { + let request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) + expect(request.fallbackHostIndex).to(beNil()) + } + + func testNextFallbackHostRequestIncrementsFallbackHostIndex() throws { + var request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) + + let fallbacksCount = request.httpRequest.path.fallbackHosts.count + for iteration in 0..