From 9577ba58ba46e2a197169d0093e68961bba2ae99 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Tue, 8 Apr 2025 18:41:20 +0200 Subject: [PATCH 01/14] feat: implement fallback host in all URL requests --- .../DiagnosticsHTTPRequestPath.swift | 2 +- Sources/Logging/Strings/NetworkStrings.swift | 4 ++ .../Networking/HTTPClient/HTTPClient.swift | 38 +++++++++++++++++-- .../HTTPClient/HTTPRequestPath.swift | 21 ++++++---- .../Networking/PaywallHTTPRequestPath.swift | 2 +- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift index 44540d3109..dab72cb894 100644 --- a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift +++ b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift @@ -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")!] var authenticated: Bool { switch self { diff --git a/Sources/Logging/Strings/NetworkStrings.swift b/Sources/Logging/Strings/NetworkStrings.swift index ef35f77c51..92bde823f5 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_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) @@ -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)'" diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index 60cd28dbb3..6c5bd3dbac 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? + var currentHostIndex: Int = 0 /// Whether the request has been retried. var retried: Bool { @@ -281,11 +282,25 @@ 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.currentHostIndex = 0 // TODO: Do we want this or should we keep the current host? copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)" + return copy + } + func requestWithNextHost() -> Self? { + var copy = self + copy.currentHostIndex += 1 + guard self.httpRequest.path.url(hostURLIndex: self.currentHostIndex) != nil else { + // No more hosts available + return nil + } return copy } @@ -293,8 +308,9 @@ internal extension HTTPClient { """ <\(type(of: self)): httpMethod=\(self.method.httpMethod) path=\(self.path) - headers=\(self.headers.description ) + headers=\(self.headers.description) retried=\(self.retried) + currentHostIndex=\(self.currentHostIndex) > """ } @@ -474,7 +490,23 @@ private extension HTTPClient { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } - requestRetryScheduled = self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse) + // TODO: Should we skip fallback host if isLoadShedder == true + // Try next host if we got a server error + if let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError, + let nextRequest = request.requestWithNextHost() { + 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) + } + requestRetryScheduled = true + } else { + requestRetryScheduled = self.retryRequestIfNeeded(request: request, + httpURLResponse: httpURLResponse) + } } if !requestRetryScheduled { @@ -540,7 +572,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) diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index bf6fe5ba12..a2fc2293b1 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -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 } @@ -45,12 +45,11 @@ 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? { + return URL(string: self.relativePath, relativeTo: proxyURL ?? Self.serverHostURLs[safe: hostURLIndex]) } - } // MARK: - Main paths @@ -91,8 +90,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" + ].map { + // swiftlint:disable:next force_unwrapping + URL(string: $0)! + } var authenticated: Bool { switch self { diff --git a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift index 9092ecebf9..2a8dac3010 100644 --- a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift +++ b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift @@ -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")!] var authenticated: Bool { switch self { From 85267dc65189d895cc64a29a72d2dea54a55bad1 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Wed, 9 Apr 2025 19:21:54 +0200 Subject: [PATCH 02/14] cleanup fallback host API implementation --- .../Networking/HTTPClient/HTTPClient.swift | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index 6c5bd3dbac..8d58b8ec46 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -289,7 +289,6 @@ internal extension HTTPClient { func retriedRequest() -> Self { var copy = self copy.retryCount += 1 - copy.currentHostIndex = 0 // TODO: Do we want this or should we keep the current host? copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)" return copy } @@ -490,20 +489,9 @@ private extension HTTPClient { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } - // TODO: Should we skip fallback host if isLoadShedder == true - // Try next host if we got a server error - if let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError, - let nextRequest = request.requestWithNextHost() { - 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) - } - requestRetryScheduled = true - } else { + requestRetryScheduled = self.retryRequestWithNextHostIfNeeded(request: request, + httpURLResponse: httpURLResponse) + if !requestRetryScheduled { requestRetryScheduled = self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse) } @@ -655,6 +643,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) + } + 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. From 720b3cda5bedeec495a8f23ddda451c56f2fc28f Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Wed, 9 Apr 2025 19:22:24 +0200 Subject: [PATCH 03/14] Adapt existing tests to changes in `HTTPRequestPath` --- .../OtherIntegrationTests.swift | 2 +- .../StoreKitIntegrationTests.swift | 6 +++--- .../Networking/HTTPClientTests.swift | 20 +++++++++---------- .../Networking/HTTPRequestTests.swift | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Tests/BackendIntegrationTests/OtherIntegrationTests.swift b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift index 4797515bad..3f51699e05 100644 --- a/Tests/BackendIntegrationTests/OtherIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift @@ -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() diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index f71deb5c04..44ef4cabcf 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -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 @@ -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.serverHostURL.first?.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse() @@ -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.serverHostURL.first?.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse( diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index 093bcba1fa..50790e7dc4 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -88,7 +88,7 @@ final class HTTPClientTests: BaseHTTPClientTests { func testUsesTheCorrectHost() throws { let hostCorrect: Atomic = false - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in hostCorrect.value = true return .emptySuccessResponse() @@ -2069,7 +2069,7 @@ extension HTTPClientTests { func testPerformsAllRetriesIfAlwaysGetsRetryableStatusCode() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in requestCount += 1 return .emptyTooManyRequestsResponse() @@ -2098,7 +2098,7 @@ extension HTTPClientTests { func testCorrectDelaysAreSentToOperationDispatcherForRetries() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in return .emptyTooManyRequestsResponse() } @@ -2124,7 +2124,7 @@ extension HTTPClientTests { func testRetryMessagesAreLoggedWhenRetriesExhausted() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in requestCount += 1 return .emptyTooManyRequestsResponse() @@ -2149,7 +2149,7 @@ extension HTTPClientTests { } func testRetryMessagesAreNotLoggedWhenNoRetriesOccur() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in return .emptySuccessResponse() } @@ -2175,7 +2175,7 @@ extension HTTPClientTests { func testRetryCountHeaderIsAccurateWithNoRetries() throws { var retryCountHeaderValues: [String?] = [] - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2193,7 +2193,7 @@ extension HTTPClientTests { } func testDoesNotRetryUnsupportedURLPaths() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) var requestCount = 0 stub(condition: isHost(host)) { _ in requestCount += 1 @@ -2213,7 +2213,7 @@ extension HTTPClientTests { var retryCountHeaderValues: [String?] = [] var retryCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2239,7 +2239,7 @@ extension HTTPClientTests { func testRetryCountHeaderIsAccurateWhenAllRetriesAreExhausted() throws { var retryCountHeaderValues: [String?] = [] - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2259,7 +2259,7 @@ extension HTTPClientTests { func testSucceedsIfAlwaysGetsSuccessAfterOneRetry() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host)) { _ in requestCount += 1 diff --git a/Tests/UnitTests/Networking/HTTPRequestTests.swift b/Tests/UnitTests/Networking/HTTPRequestTests.swift index 26d7e84c3e..a0cd62f68c 100644 --- a/Tests/UnitTests/Networking/HTTPRequestTests.swift +++ b/Tests/UnitTests/Networking/HTTPRequestTests.swift @@ -201,12 +201,12 @@ class HTTPRequestTests: TestCase { func testURLWithNoProxy() { let path: HTTPRequest.Path = .health expect(path.url?.absoluteString) == "https://api.revenuecat.com/v1/health" - expect(path.url(proxyURL: nil)?.absoluteString) == "https://api.revenuecat.com/v1/health" + expect(path.url(hostURLIndex: 0, proxyURL: nil)?.absoluteString) == "https://api.revenuecat.com/v1/health" } func testURLWithProxy() { let path: HTTPRequest.Path = .health - expect(path.url(proxyURL: URL(string: "https://test_url"))?.absoluteString) == "https://test_url/v1/health" + expect(path.url(hostURLIndex: 0, proxyURL: URL(string: "https://test_url"))?.absoluteString) == "https://test_url/v1/health" } func testAddNonceIfRequiredWithExistingNonceDoesNotReplaceNonce() throws { From b7ae27163c3849b3136ffd42bfea8a9ff71827fb Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Thu, 10 Apr 2025 08:56:53 +0200 Subject: [PATCH 04/14] fix implementation --- Sources/Networking/HTTPClient/HTTPClient.swift | 7 ++++--- Sources/Networking/HTTPClient/HTTPRequestPath.swift | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index 8d58b8ec46..7d2b183aba 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -294,12 +294,13 @@ internal extension HTTPClient { } func requestWithNextHost() -> Self? { - var copy = self - copy.currentHostIndex += 1 - guard self.httpRequest.path.url(hostURLIndex: self.currentHostIndex) != nil else { + 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 } diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index a2fc2293b1..45d2242fd1 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -48,7 +48,10 @@ extension HTTPRequestPath { var url: URL? { return self.url(hostURLIndex: 0, proxyURL: nil) } func url(hostURLIndex: Int, proxyURL: URL? = nil) -> URL? { - return URL(string: self.relativePath, relativeTo: proxyURL ?? Self.serverHostURLs[safe: hostURLIndex]) + guard let baseURL = proxyURL ?? Self.serverHostURLs[safe: hostURLIndex] else { + return nil + } + return URL(string: self.relativePath, relativeTo: baseURL) } } From f51040c2647d53eb6fc867853fd3719bf3732752 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Thu, 10 Apr 2025 08:57:17 +0200 Subject: [PATCH 05/14] add unit tests for fallback API host --- .../Networking/HTTPClientTests.swift | 207 ++++++++++++++++++ .../Networking/HTTPRequestTests.swift | 3 +- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index 50790e7dc4..165ddff95b 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -1814,6 +1814,20 @@ extension HTTPClientTests { expect(secondRetriedRequest.retryCount).to(equal(2)) } + func testRetryingRequestKeepsCurrentHostIndex() throws { + let request = buildEmptyRequest(isRetryable: true) + let hostCount = type(of: request.httpRequest.path).serverHostURLs.count + try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") + + let nextHostRequest = try XCTUnwrap(request.requestWithNextHost()) + + let retriedRequest = nextHostRequest.retriedRequest() + let secondRetriedRequest = nextHostRequest.retriedRequest() + + expect(retriedRequest.currentHostIndex).to(equal(1)) + expect(secondRetriedRequest.currentHostIndex).to(equal(1)) + } + private func buildEmptyRequest( isRetryable: Bool ) -> HTTPClient.Request { @@ -2372,6 +2386,199 @@ extension HTTPClientTests { expect(mockOperationDispatcher.invokedDispatchOnWorkerThreadWithTimeIntervalCount).to(equal(1)) expect(mockOperationDispatcher.invokedDispatchOnWorkerThreadWithTimeIntervalParam).to(equal(0)) } + + // MARK: - Fallback Host Retry Tests + + func testNewRequestHasStartsAtFirstHost() { + let request = buildEmptyRequest(isRetryable: true) + expect(request.currentHostIndex).to(equal(0)) + } + + func testNextHostRequestIncrementsCurrentHostIndex() throws { + var request = buildEmptyRequest(isRetryable: true) + let hostCount = type(of: request.httpRequest.path).serverHostURLs.count + try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") + + for iteration in 1.. Date: Thu, 10 Apr 2025 10:23:10 +0200 Subject: [PATCH 06/14] add extra unit test --- .../Networking/HTTPClientTests.swift | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index 165ddff95b..a8c69221ea 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -2428,6 +2428,9 @@ extension HTTPClientTests { func testRetriesWithNextHostOnServerError() throws { let request = HTTPRequest(method: .get, path: .mockPath) + let hostCount = type(of: request.path).serverHostURLs.count + try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") + let serverErrorResponse = HTTPStubsResponse( data: Data(), statusCode: HTTPStatusCode.internalServerError, @@ -2461,6 +2464,44 @@ extension HTTPClientTests { expect(result?.error).to(beNil()) } + func testReturnsLastErrorWhenRetriedWithNextHost() throws { + let request = HTTPRequest(method: .get, path: .mockPath) + let hostCount = type(of: request.path).serverHostURLs.count + try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") + + let serverErrorResponse = HTTPStubsResponse( + data: Data(), + statusCode: HTTPStatusCode.internalServerError, + headers: nil + ) + + let host1 = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + stub(condition: isHost(host1)) { _ in + return serverErrorResponse + } + + let host2 = try XCTUnwrap(HTTPRequest.Path.serverHostURLs[1].host) + stub(condition: isHost(host2)) { _ in + return .emptyTooManyRequestsResponse() + } + + let result = waitUntilValue { completion in + self.client.perform(request) { (response: EmptyResponse) in + completion(response) + } + } + + expect(result).toNot(beNil()) + expect(result).to(beFailure()) + expect(result?.error) == .errorResponse( + .init(code: .unknownError, + originalCode: 0, + message: nil), + .tooManyRequests + ) + expect(result?.error?.isServerDown) == false + } + func testRetriesWithNextHostImmediately() throws { let mockOperationDispatcher = MockOperationDispatcher() let client = self.createClient(self.systemInfo, operationDispatcher: mockOperationDispatcher) @@ -2518,24 +2559,6 @@ extension HTTPClientTests { expect(request.retryCount) == 0 } -// func testQueuesRequestAtFrontOnHostRetry() throws { -// let request = buildEmptyRequest(isRetryable: true) -// let httpURLResponse = HTTPURLResponse( -// url: URL(string: "https://api.revenuecat.com/v1/receipts")!, -// statusCode: HTTPStatusCode.internalServerError.rawValue, -// httpVersion: nil, -// headerFields: nil -// ) -// -// let didRetry = self.client.retryRequestWithNextHostIfNeeded( -// request: request, -// httpURLResponse: httpURLResponse -// ) -// -// expect(didRetry).to(beTrue()) -// expect(self.client.state.value.queuedRequests.first?.currentHostIndex) == 1 -// } - func testDoesNotRetryWithNextHostForNonServerError() throws { let request = buildEmptyRequest(isRetryable: true) let hostCount = type(of: request.httpRequest.path).serverHostURLs.count From be786936a9fd04c8cd7001eb7e4788066c91a01b Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Thu, 10 Apr 2025 10:42:18 +0200 Subject: [PATCH 07/14] fix tests --- Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 44ef4cabcf..57d769e9eb 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -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.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse() @@ -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.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse( From 5146304e20a5029a8923e507056f258045c4e531 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Thu, 10 Apr 2025 15:09:21 +0200 Subject: [PATCH 08/14] fix lint --- .../Networking/HTTPClient/HTTPClient.swift | 41 ++++++++----------- .../Networking/HTTPRequestTests.swift | 2 +- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index 7d2b183aba..c5a8357218 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -449,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): @@ -479,31 +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.retryRequestWithNextHostIfNeeded(request: request, - httpURLResponse: httpURLResponse) - if !requestRetryScheduled { - requestRetryScheduled = self.retryRequestIfNeeded(request: request, - httpURLResponse: httpURLResponse) + retryScheduled = self.retryRequestWithNextHostIfNeeded(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) diff --git a/Tests/UnitTests/Networking/HTTPRequestTests.swift b/Tests/UnitTests/Networking/HTTPRequestTests.swift index dbf57d81a0..5ba2b72ff7 100644 --- a/Tests/UnitTests/Networking/HTTPRequestTests.swift +++ b/Tests/UnitTests/Networking/HTTPRequestTests.swift @@ -206,7 +206,7 @@ class HTTPRequestTests: TestCase { func testURLWithProxy() { let path: HTTPRequest.Path = .health - expect(path.url(hostURLIndex: 0, + expect(path.url(hostURLIndex: 0, proxyURL: URL(string: "https://test_url"))?.absoluteString) == "https://test_url/v1/health" } From a18a3824137b634c4679c1a94fb313363dfa60dd Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Fri, 11 Apr 2025 13:51:42 +0200 Subject: [PATCH 09/14] change the logic from fallback hosts to fallback paths --- .../DiagnosticsHTTPRequestPath.swift | 6 +- Sources/Logging/Strings/NetworkStrings.swift | 6 +- .../Networking/HTTPClient/HTTPClient.swift | 43 ++--- .../HTTPClient/HTTPRequestPath.swift | 97 ++++++++--- .../Networking/PaywallHTTPRequestPath.swift | 6 +- .../Networking/HTTPClientTests.swift | 153 +++++++++--------- .../Networking/HTTPRequestTests.swift | 5 +- 7 files changed, 190 insertions(+), 126 deletions(-) diff --git a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift index dab72cb894..e63220292a 100644 --- a/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift +++ b/Sources/Diagnostics/Networking/DiagnosticsHTTPRequestPath.swift @@ -16,7 +16,7 @@ import Foundation extension HTTPRequest.DiagnosticsPath: HTTPRequestPath { // swiftlint:disable:next force_unwrapping - static let serverHostURLs = [URL(string: "https://api-diagnostics.revenuecat.com")!] + static let serverHostURL = URL(string: "https://api-diagnostics.revenuecat.com")! var authenticated: Bool { switch self { @@ -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 92bde823f5..327dd808b8 100644 --- a/Sources/Logging/Strings/NetworkStrings.swift +++ b/Sources/Logging/Strings/NetworkStrings.swift @@ -43,7 +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 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) @@ -120,8 +120,8 @@ 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 .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 c5a8357218..e0720a6810 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -245,7 +245,7 @@ internal extension HTTPClient { var headers: HTTPClient.RequestHeaders var verificationMode: Signing.ResponseVerificationMode var completionHandler: HTTPClient.Completion? - var currentHostIndex: Int = 0 + private(set) var fallbackPathIndex: Int? /// Whether the request has been retried. var retried: Bool { @@ -280,10 +280,10 @@ internal extension HTTPClient { } var method: HTTPRequest.Method { self.httpRequest.method } - var path: String { self.httpRequest.path.relativePath } + var path: String { (self.getCurrentPath() ?? self.httpRequest.path).relativePath } func getCurrentRequestURL(proxyURL: URL?) -> URL? { - return self.httpRequest.path.url(hostURLIndex: self.currentHostIndex, proxyURL: proxyURL) + return self.getCurrentPath()?.url(proxyURL: proxyURL) } func retriedRequest() -> Self { @@ -293,14 +293,13 @@ internal extension HTTPClient { return copy } - func requestWithNextHost() -> Self? { - let nextHostIndex = self.currentHostIndex + 1 - guard self.httpRequest.path.url(hostURLIndex: nextHostIndex) != nil else { - // No more hosts available + func requestWithNextFallbackPath() -> Self? { + var copy = self + copy.fallbackPathIndex = self.fallbackPathIndex?.advanced(by: 1) ?? 0 + guard copy.getCurrentPath() != nil else { + // No more fallback paths available return nil } - var copy = self - copy.currentHostIndex = nextHostIndex return copy } @@ -310,10 +309,17 @@ internal extension HTTPClient { path=\(self.path) headers=\(self.headers.description) retried=\(self.retried) - currentHostIndex=\(self.currentHostIndex) > """ } + + private func getCurrentPath() -> HTTPRequestPath? { + if let fallbackPathIndex = self.fallbackPathIndex { + return self.httpRequest.path.fallbackPaths[safe: fallbackPathIndex] + } else { + return self.httpRequest.path + } + } } } @@ -486,8 +492,8 @@ private extension HTTPClient { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } - retryScheduled = self.retryRequestWithNextHostIfNeeded(request: request, - httpURLResponse: httpURLResponse) + retryScheduled = self.retryRequestWithNextFallbackPathIfNeeded(request: request, + httpURLResponse: httpURLResponse) if !retryScheduled { retryScheduled = self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse) @@ -639,30 +645,29 @@ private extension HTTPClient { // MARK: - Request Retry Logic extension HTTPClient { - /// Evaluates whether a request should be retried with the next host in the list. + /// Evaluates whether a request should be retried with the next path in the list of fallback paths. /// /// 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 + /// with the next fallback path. 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( + internal func retryRequestWithNextFallbackPathIfNeeded( request: HTTPClient.Request, httpURLResponse: HTTPURLResponse? ) -> Bool { guard let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError, - let nextRequest = request.requestWithNextHost() else { + let nextRequest = request.requestWithNextFallbackPath() else { return false } - Logger.debug(Strings.network.retrying_request_with_next_host( + Logger.debug(Strings.network.retrying_request_with_fallback_path( httpMethod: nextRequest.method.httpMethod, - path: nextRequest.path, - nextHost: nextRequest.currentHostIndex + path: nextRequest.path )) self.state.modify { $0.queuedRequests.insert(nextRequest, at: 0) diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index 45d2242fd1..c112c2e8ce 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -15,8 +15,13 @@ import Foundation protocol HTTPRequestPath { - /// The base URLs for requests to this path, in order of preference. - static var serverHostURLs: [URL] { get } + /// The base URL for requests to this path. + static var serverHostURL: URL { get } + + /// The fallback paths to use when the main server is down. + /// + /// The structure of the response must be the same as that of the main path. + var fallbackPaths: [HTTPRequestPath] { get } /// Whether requests to this path are authenticated. var authenticated: Bool { get } @@ -30,28 +35,23 @@ 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 fallbackPaths: [HTTPRequestPath] { + return [] } - var url: URL? { return self.url(hostURLIndex: 0, proxyURL: nil) } + var url: URL? { return self.url(proxyURL: nil) } - 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) + func url(proxyURL: URL? = nil) -> URL? { + return URL(string: self.relativePath, relativeTo: proxyURL ?? Self.serverHostURL) } } @@ -93,13 +93,18 @@ extension HTTPRequest { extension HTTPRequest.Path: HTTPRequestPath { - static let serverHostURLs = [ - "https://api.revenuecat.com", - "https://api2.revenuecat.com", // TODO: Add real values - "https://api3.revenuecat.com" - ].map { - // swiftlint:disable:next force_unwrapping - URL(string: $0)! + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://api.revenuecat.com")! + + var fallbackPaths: [HTTPRequestPath] { + switch self { + case .getOfferings: + return [HTTPRequest.FallbackPath.getOfferings] + case .getProductEntitlementMapping: + return [HTTPRequest.FallbackPath.getProductEntitlementMapping] + default: + return [] + } } var authenticated: Bool { @@ -183,6 +188,10 @@ extension HTTPRequest.Path: HTTPRequestPath { } } + var relativePath: String { + return "/v1/\(self.pathComponent)" + } + var pathComponent: String { switch self { case let .getCustomerInfo(appUserID): @@ -275,3 +284,49 @@ extension HTTPRequest.Path: HTTPRequestPath { return appUserID.trimmedAndEscaped } } + +extension HTTPRequest { + + enum FallbackPath: HTTPRequestPath { + + case getOfferings + case getProductEntitlementMapping + + // swiftlint:disable:next force_unwrapping + static let serverHostURL = URL(string: "https://api-production.8-lives-cat.io")! + + var authenticated: Bool { + return true + } + + var shouldSendEtag: Bool { + return true + } + + var supportsSignatureVerification: Bool { + return false + } + + var needsNonceForSigning: Bool { + return false + } + + var relativePath: String { + switch self { + case .getOfferings: + return "/offerings" + case .getProductEntitlementMapping: + return "/product-entitlement-mapping" + } + } + + var name: String { + switch self { + case .getOfferings: + return "fallback_get_offerings" + case .getProductEntitlementMapping: + return "fallback_get_product_entitlement_mapping" + } + } + } +} diff --git a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift index 2a8dac3010..6efc88fb3c 100644 --- a/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift +++ b/Sources/Paywalls/Events/Networking/PaywallHTTPRequestPath.swift @@ -16,7 +16,7 @@ import Foundation extension HTTPRequest.PaywallPath: HTTPRequestPath { // swiftlint:disable:next force_unwrapping - static let serverHostURLs = [URL(string: "https://api-paywalls.revenuecat.com")!] + static let serverHostURL = URL(string: "https://api-paywalls.revenuecat.com")! var authenticated: Bool { switch self { @@ -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 a8c69221ea..b11d87802f 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -88,7 +88,7 @@ final class HTTPClientTests: BaseHTTPClientTests { func testUsesTheCorrectHost() throws { let hostCorrect: Atomic = false - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in hostCorrect.value = true return .emptySuccessResponse() @@ -1814,27 +1814,36 @@ extension HTTPClientTests { expect(secondRetriedRequest.retryCount).to(equal(2)) } - func testRetryingRequestKeepsCurrentHostIndex() throws { - let request = buildEmptyRequest(isRetryable: true) - let hostCount = type(of: request.httpRequest.path).serverHostURLs.count - try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") - - let nextHostRequest = try XCTUnwrap(request.requestWithNextHost()) + func testRetryingRequestKeepsFallbackPathIndex() throws { + let request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) + let nextFallbackPathRequest = try XCTUnwrap(request.requestWithNextFallbackPath()) - let retriedRequest = nextHostRequest.retriedRequest() - let secondRetriedRequest = nextHostRequest.retriedRequest() + let retriedRequest = nextFallbackPathRequest.retriedRequest() + let secondRetriedRequest = nextFallbackPathRequest.retriedRequest() - expect(retriedRequest.currentHostIndex).to(equal(1)) - expect(secondRetriedRequest.currentHostIndex).to(equal(1)) + expect(retriedRequest.fallbackPathIndex).to(equal(0)) + expect(secondRetriedRequest.fallbackPathIndex).to(equal(0)) } private func buildEmptyRequest( - isRetryable: Bool + isRetryable: Bool, + hasFallbackPaths: Bool = false ) -> HTTPClient.Request { let completionHandler: HTTPClient.Completion = { _ in return } + let path: HTTPRequest.Path + if hasFallbackPaths { + path = .getOfferings(appUserID: "abc123") + expect(path.fallbackPaths).toNot( + beEmpty(), + description: "This test requires a path that has at least 1 fallback path" + ) + } 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, @@ -2083,7 +2092,7 @@ extension HTTPClientTests { func testPerformsAllRetriesIfAlwaysGetsRetryableStatusCode() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in requestCount += 1 return .emptyTooManyRequestsResponse() @@ -2112,7 +2121,7 @@ extension HTTPClientTests { func testCorrectDelaysAreSentToOperationDispatcherForRetries() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in return .emptyTooManyRequestsResponse() } @@ -2138,7 +2147,7 @@ extension HTTPClientTests { func testRetryMessagesAreLoggedWhenRetriesExhausted() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in requestCount += 1 return .emptyTooManyRequestsResponse() @@ -2163,7 +2172,7 @@ extension HTTPClientTests { } func testRetryMessagesAreNotLoggedWhenNoRetriesOccur() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in return .emptySuccessResponse() } @@ -2189,7 +2198,7 @@ extension HTTPClientTests { func testRetryCountHeaderIsAccurateWithNoRetries() throws { var retryCountHeaderValues: [String?] = [] - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2207,7 +2216,7 @@ extension HTTPClientTests { } func testDoesNotRetryUnsupportedURLPaths() throws { - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) var requestCount = 0 stub(condition: isHost(host)) { _ in requestCount += 1 @@ -2227,7 +2236,7 @@ extension HTTPClientTests { var retryCountHeaderValues: [String?] = [] var retryCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2253,7 +2262,7 @@ extension HTTPClientTests { func testRetryCountHeaderIsAccurateWhenAllRetriesAreExhausted() throws { var retryCountHeaderValues: [String?] = [] - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { urlRequest in let retryCountHeaderValue = urlRequest.allHTTPHeaderFields?[HTTPClient.RequestHeader.retryCount.rawValue] retryCountHeaderValues.append(retryCountHeaderValue) @@ -2273,7 +2282,7 @@ extension HTTPClientTests { func testSucceedsIfAlwaysGetsSuccessAfterOneRetry() throws { var requestCount = 0 - let host = try XCTUnwrap(HTTPRequest.Path.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host)) { _ in requestCount += 1 @@ -2389,47 +2398,44 @@ extension HTTPClientTests { // MARK: - Fallback Host Retry Tests - func testNewRequestHasStartsAtFirstHost() { + func testNewRequestStartsWithMainPath() { let request = buildEmptyRequest(isRetryable: true) - expect(request.currentHostIndex).to(equal(0)) + expect(request.fallbackPathIndex).to(equal(0)) } - func testNextHostRequestIncrementsCurrentHostIndex() throws { - var request = buildEmptyRequest(isRetryable: true) - let hostCount = type(of: request.httpRequest.path).serverHostURLs.count - try XCTSkipIf(hostCount <= 1, "This test requires at least 2 hosts") + func testNextFallbackPathRequestIncrementsFallbackPathIndex() throws { + var request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) - for iteration in 1.. Date: Fri, 11 Apr 2025 13:54:33 +0200 Subject: [PATCH 10/14] fix test --- Tests/UnitTests/Networking/HTTPClientTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index b11d87802f..c94aabcf63 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -2399,8 +2399,8 @@ extension HTTPClientTests { // MARK: - Fallback Host Retry Tests func testNewRequestStartsWithMainPath() { - let request = buildEmptyRequest(isRetryable: true) - expect(request.fallbackPathIndex).to(equal(0)) + let request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) + expect(request.fallbackPathIndex).to(beNil()) } func testNextFallbackPathRequestIncrementsFallbackPathIndex() throws { From 0c9e623745cad159d28999407e7dc44b7b54e760 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 14 Apr 2025 08:39:52 +0200 Subject: [PATCH 11/14] fix fallback endpoint for get product entitlement mapping --- Sources/Networking/HTTPClient/HTTPRequestPath.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index c112c2e8ce..09485a6dfa 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -316,7 +316,7 @@ extension HTTPRequest { case .getOfferings: return "/offerings" case .getProductEntitlementMapping: - return "/product-entitlement-mapping" + return "/product_entitlement_mapping" } } From c2facde2213dd63f9f287ebe011c2bdd16a9a1e4 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 14 Apr 2025 08:53:32 +0200 Subject: [PATCH 12/14] fix backend integration tests not building after changes --- Tests/BackendIntegrationTests/OtherIntegrationTests.swift | 2 +- .../BackendIntegrationTests/StoreKitIntegrationTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/BackendIntegrationTests/OtherIntegrationTests.swift b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift index 3f51699e05..4797515bad 100644 --- a/Tests/BackendIntegrationTests/OtherIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/OtherIntegrationTests.swift @@ -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.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host) && isPath("/v1/subscribers/identify")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse() diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 57d769e9eb..f71deb5c04 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -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.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) var stubbedRequestCount = 0 stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 @@ -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.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse() @@ -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.serverHostURLs.first?.host) + let host = try XCTUnwrap(HTTPRequest.Path.serverHostURL.host) stub(condition: isHost(host) && isPath("/v1/receipts")) { _ in stubbedRequestCount += 1 return Self.emptyTooManyRequestsResponse( From a9275b69adc491d032f7b0f6c3db2812ef22ca46 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 14 Apr 2025 18:45:48 +0200 Subject: [PATCH 13/14] rewrite fallback host functionality knowing that only some specific requests support a fallback host For those requests that support a fallback host, the path, headers and response structure remain the same --- .../Networking/HTTPClient/HTTPClient.swift | 36 +++--- .../HTTPClient/HTTPRequestPath.swift | 78 ++++--------- .../Networking/HTTPClientTests.swift | 107 ++++++++++-------- 3 files changed, 95 insertions(+), 126 deletions(-) diff --git a/Sources/Networking/HTTPClient/HTTPClient.swift b/Sources/Networking/HTTPClient/HTTPClient.swift index e0720a6810..7fe757c140 100644 --- a/Sources/Networking/HTTPClient/HTTPClient.swift +++ b/Sources/Networking/HTTPClient/HTTPClient.swift @@ -245,7 +245,7 @@ internal extension HTTPClient { var headers: HTTPClient.RequestHeaders var verificationMode: Signing.ResponseVerificationMode var completionHandler: HTTPClient.Completion? - private(set) var fallbackPathIndex: Int? + private(set) var fallbackHostIndex: Int? /// Whether the request has been retried. var retried: Bool { @@ -280,10 +280,10 @@ internal extension HTTPClient { } var method: HTTPRequest.Method { self.httpRequest.method } - var path: String { (self.getCurrentPath() ?? self.httpRequest.path).relativePath } + var path: String { self.httpRequest.path.relativePath } func getCurrentRequestURL(proxyURL: URL?) -> URL? { - return self.getCurrentPath()?.url(proxyURL: proxyURL) + return self.httpRequest.path.url(proxyURL: proxyURL, fallbackHostIndex: self.fallbackHostIndex) } func retriedRequest() -> Self { @@ -293,11 +293,15 @@ internal extension HTTPClient { return copy } - func requestWithNextFallbackPath() -> Self? { + 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.fallbackPathIndex = self.fallbackPathIndex?.advanced(by: 1) ?? 0 - guard copy.getCurrentPath() != nil else { - // No more fallback paths available + copy.fallbackHostIndex = self.fallbackHostIndex?.advanced(by: 1) ?? 0 + guard copy.getCurrentRequestURL(proxyURL: nil) != nil else { + // No more fallback hosts available return nil } return copy @@ -312,14 +316,6 @@ internal extension HTTPClient { > """ } - - private func getCurrentPath() -> HTTPRequestPath? { - if let fallbackPathIndex = self.fallbackPathIndex { - return self.httpRequest.path.fallbackPaths[safe: fallbackPathIndex] - } else { - return self.httpRequest.path - } - } } } @@ -492,7 +488,7 @@ private extension HTTPClient { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } - retryScheduled = self.retryRequestWithNextFallbackPathIfNeeded(request: request, + retryScheduled = self.retryRequestWithNextFallbackHostIfNeeded(request: request, httpURLResponse: httpURLResponse) if !retryScheduled { retryScheduled = self.retryRequestIfNeeded(request: request, @@ -645,23 +641,23 @@ private extension HTTPClient { // MARK: - Request Retry Logic extension HTTPClient { - /// Evaluates whether a request should be retried with the next path in the list of fallback paths. + /// 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 path. If the retry conditions are met, it schedules the request immediately and + /// 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 retryRequestWithNextFallbackPathIfNeeded( + internal func retryRequestWithNextFallbackHostIfNeeded( request: HTTPClient.Request, httpURLResponse: HTTPURLResponse? ) -> Bool { guard let statusCode = httpURLResponse?.statusCode, HTTPStatusCode(rawValue: statusCode).isServerError, - let nextRequest = request.requestWithNextFallbackPath() else { + let nextRequest = request.requestWithNextFallbackHost(proxyURL: SystemInfo.proxyURL) else { return false } diff --git a/Sources/Networking/HTTPClient/HTTPRequestPath.swift b/Sources/Networking/HTTPClient/HTTPRequestPath.swift index 09485a6dfa..49ecc88990 100644 --- a/Sources/Networking/HTTPClient/HTTPRequestPath.swift +++ b/Sources/Networking/HTTPClient/HTTPRequestPath.swift @@ -18,10 +18,10 @@ protocol HTTPRequestPath { /// The base URL for requests to this path. static var serverHostURL: URL { get } - /// The fallback paths to use when the main server is down. + /// The fallback hosts to use when the main server is down. /// - /// The structure of the response must be the same as that of the main path. - var fallbackPaths: [HTTPRequestPath] { get } + /// 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 } @@ -44,14 +44,25 @@ protocol HTTPRequestPath { extension HTTPRequestPath { - var fallbackPaths: [HTTPRequestPath] { + 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) } } @@ -96,12 +107,11 @@ extension HTTPRequest.Path: HTTPRequestPath { // swiftlint:disable:next force_unwrapping static let serverHostURL = URL(string: "https://api.revenuecat.com")! - var fallbackPaths: [HTTPRequestPath] { + var fallbackHosts: [URL] { switch self { - case .getOfferings: - return [HTTPRequest.FallbackPath.getOfferings] - case .getProductEntitlementMapping: - return [HTTPRequest.FallbackPath.getProductEntitlementMapping] + case .getOfferings, .getProductEntitlementMapping: + // swiftlint:disable:next force_unwrapping + return [URL(string: "https://api-production.8-lives-cat.io")!] default: return [] } @@ -284,49 +294,3 @@ extension HTTPRequest.Path: HTTPRequestPath { return appUserID.trimmedAndEscaped } } - -extension HTTPRequest { - - enum FallbackPath: HTTPRequestPath { - - case getOfferings - case getProductEntitlementMapping - - // swiftlint:disable:next force_unwrapping - static let serverHostURL = URL(string: "https://api-production.8-lives-cat.io")! - - var authenticated: Bool { - return true - } - - var shouldSendEtag: Bool { - return true - } - - var supportsSignatureVerification: Bool { - return false - } - - var needsNonceForSigning: Bool { - return false - } - - var relativePath: String { - switch self { - case .getOfferings: - return "/offerings" - case .getProductEntitlementMapping: - return "/product_entitlement_mapping" - } - } - - var name: String { - switch self { - case .getOfferings: - return "fallback_get_offerings" - case .getProductEntitlementMapping: - return "fallback_get_product_entitlement_mapping" - } - } - } -} diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index c94aabcf63..e6666d5fae 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -1814,29 +1814,29 @@ extension HTTPClientTests { expect(secondRetriedRequest.retryCount).to(equal(2)) } - func testRetryingRequestKeepsFallbackPathIndex() throws { - let request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) - let nextFallbackPathRequest = try XCTUnwrap(request.requestWithNextFallbackPath()) + func testRetryingRequestKeepsFallbackHostIndex() throws { + let request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) + let nextFallbackHostRequest = try XCTUnwrap(request.requestWithNextFallbackHost(proxyURL: nil)) - let retriedRequest = nextFallbackPathRequest.retriedRequest() - let secondRetriedRequest = nextFallbackPathRequest.retriedRequest() + let retriedRequest = nextFallbackHostRequest.retriedRequest() + let secondRetriedRequest = nextFallbackHostRequest.retriedRequest() - expect(retriedRequest.fallbackPathIndex).to(equal(0)) - expect(secondRetriedRequest.fallbackPathIndex).to(equal(0)) + expect(retriedRequest.fallbackHostIndex).to(equal(0)) + expect(secondRetriedRequest.fallbackHostIndex).to(equal(0)) } private func buildEmptyRequest( isRetryable: Bool, - hasFallbackPaths: Bool = false + hasFallbackHosts: Bool = false ) -> HTTPClient.Request { let completionHandler: HTTPClient.Completion = { _ in return } let path: HTTPRequest.Path - if hasFallbackPaths { + if hasFallbackHosts { path = .getOfferings(appUserID: "abc123") - expect(path.fallbackPaths).toNot( + expect(path.fallbackHosts).toNot( beEmpty(), - description: "This test requires a path that has at least 1 fallback path" + description: "This test requires a path that has at least 1 fallback host" ) } else { path = .getCustomerInfo(appUserID: "abc123") @@ -2399,43 +2399,52 @@ extension HTTPClientTests { // MARK: - Fallback Host Retry Tests func testNewRequestStartsWithMainPath() { - let request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) - expect(request.fallbackPathIndex).to(beNil()) + let request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) + expect(request.fallbackHostIndex).to(beNil()) } - func testNextFallbackPathRequestIncrementsFallbackPathIndex() throws { - var request = buildEmptyRequest(isRetryable: true, hasFallbackPaths: true) + func testNextFallbackHostRequestIncrementsFallbackHostIndex() throws { + var request = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) - let fallbacksCount = request.httpRequest.path.fallbackPaths.count + let fallbacksCount = request.httpRequest.path.fallbackHosts.count for iteration in 0.. Date: Mon, 21 Apr 2025 13:58:52 +0200 Subject: [PATCH 14/14] fix text name --- Tests/UnitTests/Networking/HTTPClientTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/UnitTests/Networking/HTTPClientTests.swift b/Tests/UnitTests/Networking/HTTPClientTests.swift index e6666d5fae..ca41b06003 100644 --- a/Tests/UnitTests/Networking/HTTPClientTests.swift +++ b/Tests/UnitTests/Networking/HTTPClientTests.swift @@ -2430,7 +2430,7 @@ extension HTTPClientTests { expect(nextFallbackHostRequest.retryCount).to(equal(1)) } - func testrequestWithNextFallbackHostReturnsNilIfNoMoreHosts() throws { + func testRequestWithNextFallbackHostReturnsNilIfNoMoreHosts() throws { var nextRequest = buildEmptyRequest(isRetryable: true, hasFallbackHosts: true) let fallbacksCount = nextRequest.httpRequest.path.fallbackHosts.count