// // Copyright RevenueCat Inc. All Rights Reserved. // // Licensed under the MIT License (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://opensource.org/licenses/MIT // // HTTPClient.swift // // Created by César de la Vega on 7/22/21. import Foundation // swiftlint:disable file_length class HTTPClient { typealias RequestHeaders = HTTPRequest.Headers typealias ResponseHeaders = HTTPResponse.Headers typealias Completion = (VerifiedHTTPResponse.Result) -> Void let systemInfo: SystemInfo let timeout: TimeInterval let authHeaders: RequestHeaders let tokenManager: TokenManager private let session: URLSession private let state: Atomic = .init(.initial) private let eTagManager: ETagManager private let dnsChecker: DNSCheckerType.Type private let signing: SigningType private let diagnosticsTracker: DiagnosticsTrackerType? private let dateProvider: DateProvider private let retriableStatusCodes: Set private let operationDispatcher: OperationDispatcher let requestTimeoutManager: HTTPRequestTimeoutManagerType private let apiSourceFailover: APISourceFailoverType? private let retryBackoffIntervals: [TimeInterval] = [ TimeInterval(0), TimeInterval(0.75), TimeInterval(3) ] /// Defensive cap on API source attempts within one logical request, in case the source list is /// re-armed (topic rebuild or interval restart) while a request is walking it. private static let maxAPISourceAttempts = 5 init(systemInfo: SystemInfo, eTagManager: ETagManager, tokenManager: TokenManager, signing: SigningType, diagnosticsTracker: DiagnosticsTrackerType?, dnsChecker: DNSCheckerType.Type = DNSChecker.self, retriableStatusCodes: Set = Set([.tooManyRequests]), networkTimeout: NetworkTimeout, dateProvider: DateProvider = DateProvider(), operationDispatcher: OperationDispatcher, apiSourceFailover: APISourceFailoverType?, timeoutManager: HTTPRequestTimeoutManagerType ) { let config = URLSessionConfiguration.ephemeral config.httpMaximumConnectionsPerHost = 1 config.timeoutIntervalForRequest = networkTimeout.timeoutInterval config.timeoutIntervalForResource = networkTimeout.timeoutInterval config.urlCache = nil // We implement our own caching with `ETagManager`. self.session = URLSession(configuration: config, delegate: RedirectLoggerSessionDelegate(), delegateQueue: nil) self.systemInfo = systemInfo self.eTagManager = eTagManager self.tokenManager = tokenManager self.signing = signing self.diagnosticsTracker = diagnosticsTracker self.dnsChecker = dnsChecker self.retriableStatusCodes = retriableStatusCodes self.timeout = networkTimeout.timeoutInterval self.authHeaders = HTTPClient.authorizationHeader(withAPIKey: systemInfo.apiKey) self.dateProvider = dateProvider self.operationDispatcher = operationDispatcher self.apiSourceFailover = apiSourceFailover self.requestTimeoutManager = timeoutManager } /// - Parameter verificationMode: if `nil`, this will default to `SystemInfo.responseVerificationMode` func perform( _ request: HTTPRequest, with verificationMode: Signing.ResponseVerificationMode? = nil, completionHandler: Completion? ) { // NOTE: these authHeaders will not include any token-based Authorization headers // Those are applied immediately before the request begins executing self.perform(request: .init(httpRequest: request, authHeaders: self.authHeaders, defaultHeaders: self.defaultHeaders, verificationMode: verificationMode ?? self.systemInfo.responseVerificationMode, preferIAMPath: self.tokenManager.enabled, internalSettings: self.systemInfo.dangerousSettings.internalSettings, completionHandler: completionHandler)) } func clearCaches() { self.eTagManager.clearCaches() } var signatureVerificationEnabled: Bool { return self.systemInfo.responseVerificationMode.isEnabled } // Visible for tests var defaultHeaders: RequestHeaders { let preferredLocales = self.systemInfo.preferredLocales.prefix(3).map { $0.replacingOccurrences(of: "-", with: "_") }.joined(separator: ",") var headers: RequestHeaders = [ "content-type": "application/json", "X-Version": SystemInfo.frameworkVersion, "X-Platform": SystemInfo.platformHeader, "X-Platform-Version": SystemInfo.systemVersion, "X-Platform-Flavor": self.systemInfo.platformFlavor, "X-Platform-Device": SystemInfo.deviceVersion, "X-Client-Version": SystemInfo.appVersion, "X-Client-Build-Version": SystemInfo.buildVersion, "X-Client-Bundle-ID": SystemInfo.bundleIdentifier, "X-Preferred-Locales": preferredLocales, "X-StoreKit2-Enabled": "\(self.systemInfo.storeKitVersion.isStoreKit2EnabledAndAvailable)", "X-StoreKit-Version": "\(self.systemInfo.storeKitVersion.effectiveVersion)", "X-Observer-Mode-Enabled": "\(self.systemInfo.observerMode)", RequestHeader.retryCount.rawValue: "0", RequestHeader.sandbox.rawValue: "\(self.systemInfo.isSandbox)", "X-Is-Backgrounded": "\(self.systemInfo.isAppBackgroundedState)", "X-Is-Debug-Build": "\(self.systemInfo.isDebugBuild)", "X-Installation-Method": SystemInfo.installationMethod ] if let storefront = self.systemInfo.storefront { headers["X-Storefront"] = storefront.countryCode } if let platformFlavorVersion = self.systemInfo.platformFlavorVersion { headers["X-Platform-Flavor-Version"] = platformFlavorVersion } if let idfv = self.systemInfo.identifierForVendor { headers["X-Apple-Device-Identifier"] = idfv } if self.systemInfo.dangerousSettings.customEntitlementComputation { headers["X-Custom-Entitlements-Computation"] = "\(true)" } if self.systemInfo.dangerousSettings.uiPreviewMode { headers["X-UI-Preview-Mode"] = "\(true)" } return headers } } extension HTTPClient { static let rcContainerFormatAcceptHeaderValue = "application/x-rc-format" static var rcContainerFormatElementEncodingHeaderValue: String { return RCContainer.Element.ContentEncoding.requestElementEncodingHeaderValue } static func authorizationHeader(withAPIKey apiKey: String) -> RequestHeaders { return [RequestHeader.authorization.rawValue: "Bearer \(apiKey)"] } static func nonceHeader(with data: Data) -> RequestHeaders { return [RequestHeader.nonce.rawValue: data.base64EncodedString()] } static func postParametersHeaderForSigning(with body: HTTPRequestBody) -> RequestHeaders { if let header = body.postParameterHeader { return [RequestHeader.postParameters.rawValue: header] } else { return [:] } } static func headerParametersForSignatureHeader( with headers: RequestHeaders, path: HTTPRequestPath ) -> RequestHeaders { if let header = HTTPRequest.headerParametersForSignatureHeader( headers: headers, path: path ) { return [RequestHeader.headerParametersForSignature.rawValue: header] } else { return [:] } } enum RequestHeader: String { case authorization = "Authorization" case accept = "Accept" case acceptRCElementEncoding = "Accept-RC-Element-Encoding" case nonce = "X-Nonce" case eTag = "X-RevenueCat-ETag" case lastRefreshTime = "X-RC-Last-Refresh-Time" case postParameters = "X-Post-Params-Hash" case headerParametersForSignature = "X-Headers-Hash" case sandbox = "X-Is-Sandbox" case retryCount = "X-Retry-Count" } enum ResponseHeader: String { case eTag = "X-RevenueCat-ETag" case location = "Location" case signature = "X-Signature" case requestDate = "X-RevenueCat-Request-Time" case contentType = "Content-Type" case isLoadShedder = "X-RevenueCat-Fortress" case requestID = "X-Request-ID" case amazonTraceID = "X-Amzn-Trace-ID" case retryAfter = "Retry-After" case isRetryable = "Is-Retryable" } } // @unchecked because: // - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. extension HTTPClient: @unchecked Sendable {} // MARK: - CDN internal extension HTTPClient { /// Fetches raw data from an arbitrary URL using the client's configured `URLSession`. /// /// Use this for CDN or other non-RC-API requests where the SDK's path-based request building /// is not applicable, but we still want the same session configuration (timeouts, connection limits). func fetchRawData(from url: URL, completion: @escaping (Result) -> Void) { self.session.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(error)) } else if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) { completion(.failure(URLError(.badServerResponse))) } else if let data = data { completion(.success(data)) } else { completion(.failure(URLError(.unknown))) } }.resume() } } // MARK: - Private internal extension HTTPClient { struct State { var paused: Bool var queuedRequests: [Request] var currentSerialRequest: Request? static let initial: Self = .init(paused: false, queuedRequests: [], currentSerialRequest: nil) } /// The API-source resolution state of a request, modeled as one value so an inconsistent /// combination (e.g. a targeted source without an attempt count) is unrepresentable. enum APISourceState { /// Source resolution hasn't run yet for this logical request. case unresolved /// Resolution ran and API sources don't apply (setting disabled, proxy or overridden /// base URL, endpoint fallback attempt, opted-out path, or an exhausted source list). case noSource /// The request targets `source`; `attemptCount` is the number of source attempts made /// by this logical request so far (1 when a source is first resolved, +1 per failover). case source(APISourceFailover.ResolvedSource, attemptCount: Int) var source: APISourceFailover.ResolvedSource? { guard case let .source(source, _) = self else { return nil } return source } } struct Request: CustomStringConvertible { var httpRequest: HTTPRequest var headers: HTTPClient.RequestHeaders var verificationMode: Signing.ResponseVerificationMode var preferIAMPath: Bool var completionHandler: HTTPClient.Completion? private(set) var fallbackUrlIndex: Int? /// Whether the request has been retried. var retried: Bool { return self.retryCount > 0 } /// The number of times that we have retried the request var retryCount: UInt = 0 /// Whether the request is being made to a fallback URL. var isFallbackURLRequest: Bool { return self.fallbackUrlIndex != nil } /// Whether the attempt is aimed at a fallback host, either because the fallback walk moved it /// there or because the path targets one on its own. Such attempts get the flat fallback /// timeout and never take part in the main source's per-host fail-fast memory. var targetsFallbackHost: Bool { return self.isFallbackURLRequest || self.httpRequest.path.isFallbackHostPath } /// Carried on the request so a failure can report the exact handle unhealthy, and so /// retries of any kind (ETag refresh, status-code backoff) keep targeting the same host. private(set) var apiSourceState: APISourceState = .unresolved init(httpRequest: HTTPRequest, authHeaders: HTTPClient.RequestHeaders, defaultHeaders: HTTPClient.RequestHeaders, verificationMode: Signing.ResponseVerificationMode, preferIAMPath: Bool, internalSettings: InternalDangerousSettingsType, completionHandler: HTTPClient.Completion?) { self.httpRequest = httpRequest.requestAddingNonceIfRequired(with: verificationMode) self.headers = self.httpRequest.headers( with: authHeaders, defaultHeaders: defaultHeaders, verificationMode: verificationMode, internalSettings: internalSettings ) self.verificationMode = verificationMode self.preferIAMPath = preferIAMPath if let completionHandler = completionHandler { self.completionHandler = { result in completionHandler(result.parseResponse()) } } else { self.completionHandler = nil } } var method: HTTPRequest.Method { self.httpRequest.method } var path: String { if self.preferIAMPath { return self.httpRequest.path.relativeIAMPath } else { return self.httpRequest.path.relativePath } } func getCurrentRequestURL(proxyURL: URL?, apiSourceURL: URL?) -> URL? { return self.httpRequest.path.url( proxyURL: proxyURL, apiSourceURL: apiSourceURL, fallbackUrlIndex: self.fallbackUrlIndex, preferIAMPath: self.preferIAMPath ) } func retriedRequest() -> Self { var copy = self copy.retryCount += 1 copy.headers[RequestHeader.retryCount.rawValue] = "\(copy.retryCount)" return copy } /// Resolves the API source once per logical request. Retries of any kind keep the carried /// source instead of re-resolving, so they deterministically target the same host. mutating func resolveAPISourceIfNeeded(with failover: APISourceFailoverType?) { guard case .unresolved = self.apiSourceState else { return } if let source = failover?.currentSource(for: self.httpRequest.path, isFallbackAttempt: self.isFallbackURLRequest) { self.apiSourceState = .source(source, attemptCount: 1) } else { self.apiSourceState = .noSource } } /// Only meaningful on a request that is already targeting a source (a failover decision can /// only come from one); the previous attempt count carries over and increments. func requestWith(nextAPISource: APISourceFailover.ResolvedSource) -> Self { guard case let .source(_, attemptCount) = self.apiSourceState else { assertionFailure("Retrying on a next API source requires a current one") return self } var copy = self copy.apiSourceState = .source(nextAPISource, attemptCount: attemptCount + 1) 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.fallbackUrlIndex = self.fallbackUrlIndex?.advanced(by: 1) ?? 0 // The static fallback walk drops the API source: fallback attempts pin their own host and // must not re-enter source failover. copy.apiSourceState = .noSource guard copy.getCurrentRequestURL(proxyURL: nil, apiSourceURL: nil) != nil else { // No more fallback hosts available return nil } return copy } var description: String { """ <\(type(of: self)): httpMethod=\(self.method.httpMethod) path=\(self.path) headers=\(self.headers.description) retried=\(self.retried) > """ } } } private extension HTTPClient { static let serverErrorResponse: ErrorResponse = .init(code: .internalServerError, originalCode: BackendErrorCode.unknownBackendError.rawValue) func perform(request: Request) { if !request.retried { let requestEnqueued: Bool = self.state.modify { if $0.currentSerialRequest != nil { Logger.debug(Strings.network.serial_request_queued(httpMethod: request.method.httpMethod, path: request.path, queuedRequestsCount: $0.queuedRequests.count)) $0.queuedRequests.append(request) return true } else if $0.paused == true { Logger.debug(Strings.network.serial_request_paused(httpMethod: request.method.httpMethod, path: request.path)) $0.queuedRequests.append(request) return true } else { Logger.debug(Strings.network.starting_request(httpMethod: request.method.httpMethod, path: request.path)) $0.currentSerialRequest = request return false } } guard !requestEnqueued else { return } } self.start(request: request) } /// - Returns: `nil` if the request must be retried // swiftlint:disable:next function_parameter_count func parse(urlResponse: URLResponse?, request: Request, urlRequest: URLRequest, data: Data?, error networkError: Error?, requestStartTime: Date) -> VerifiedHTTPResponse.Result? { if let networkError = networkError { return .failure(NetworkError(networkError, dnsChecker: self.dnsChecker)) } guard let httpURLResponse = urlResponse as? HTTPURLResponse else { return .failure(.unexpectedResponse(urlResponse)) } let statusCode: HTTPStatusCode = .init(rawValue: httpURLResponse.statusCode) let dataIfAvailable = Self.responseBodyData(statusCode: statusCode, data: data) return self.createVerifiedResponse(request: request, urlRequest: urlRequest, data: dataIfAvailable, response: httpURLResponse, requestStartTime: requestStartTime) } /// - Returns `Result, NetworkError>?` // swiftlint:disable:next function_body_length private func createVerifiedResponse( request: Request, urlRequest: URLRequest, data: Data?, response httpURLResponse: HTTPURLResponse, requestStartTime: Date ) -> VerifiedHTTPResponse.Result? { #if DEBUG let requestHeaders: HTTPClient.RequestHeaders if self.systemInfo.dangerousSettings.internalSettings.disableHeaderSignatureVerification { Logger.warn(Strings.network.api_request_disabling_header_parameter_signature_verification( request.httpRequest )) requestHeaders = [:] } else { requestHeaders = urlRequest.allHTTPHeaderFields ?? [:] } #else let requestHeaders = urlRequest.allHTTPHeaderFields ?? [:] #endif let result = Result .success(data) .mapToResponse(response: httpURLResponse, request: request.httpRequest) // Verify response .map { cachedResponse -> VerifiedHTTPResponse in let isLoadShedderResponse = httpURLResponse.isLoadShedder let isFallbackUrlResponse = request.isFallbackURLRequest #if DEBUG if isFallbackUrlResponse && isLoadShedderResponse { Logger.warn( Strings.network.api_request_response_both_fallback_and_load_shedder(request.httpRequest) ) } #endif return cachedResponse.verify( signing: self.signing(for: request.httpRequest), request: request.httpRequest, requestHeaders: requestHeaders, publicKey: request.verificationMode.publicKey, isLoadShedderResponse: isLoadShedderResponse, isFallbackUrlResponse: isFallbackUrlResponse, iamEnabled: request.preferIAMPath ) } // Fetch from ETagManager if available .map { (response) -> VerifiedHTTPResponse? in guard request.httpRequest.path.shouldSendEtag else { return response.asOptionalResponse } return self.eTagManager.httpResultFromCacheOrBackend( with: response, request: urlRequest, retried: request.retried, isFallbackURLRequest: request.isFallbackURLRequest ) } // Upgrade to error in enforced mode .flatMap { response -> Result?, NetworkError> in if let response = response, response.verificationResult == .failed { if case .enforced = request.verificationMode { return .failure(.signatureVerificationFailed(path: request.httpRequest.path, code: response.httpStatusCode)) } else { // Any other mode gets forwarded as a success, but we log the error Logger.error(Strings.signing.request_failed_verification(request.httpRequest)) return .success(response) } } else { return .success(response) } } .asOptionalResult? .convertUnsuccessfulResponseToError() return result } // swiftlint:disable:next function_parameter_count function_body_length func handle(urlResponse: URLResponse?, request: Request, urlRequest: URLRequest, data: Data?, error networkError: Error?, requestStartTime: Date) { RCTestAssertNotMainThread() let response = self.parse(urlResponse: urlResponse, request: request, urlRequest: urlRequest, data: data, error: networkError, requestStartTime: requestStartTime) var requestTimeoutResult: HTTPRequestTimeoutManager.RequestResult = .other // The requestTimeoutManager tracks how fast a host answers, so a non-error response clears the // entry even when parsing or verifying that response fails afterwards. if !request.targetsFallbackHost, networkError == nil, (urlResponse as? HTTPURLResponse)?.httpStatusCode.isSuccessfulResponse == true { requestTimeoutResult = .successOnMainBackend } if let response = response { let httpURLResponse = urlResponse as? HTTPURLResponse switch response { case let .success(response): Logger.debug(Strings.network.api_request_completed( request.httpRequest, // Getting status code from the original response to detect 304s // If that can't be extracted, get status code from the parsed response. httpCode: httpURLResponse?.httpStatusCode ?? response.httpStatusCode, metadata: Logger.verboseLogsEnabled ? response.metadata : nil )) if response.isLoadShedder { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } self.finish(request: request, response: .success(response), retryScheduled: false, requestTimeoutResult: requestTimeoutResult, urlRequest: urlRequest, requestStartTime: requestStartTime) case let .failure(error): 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)) } // A timeout on a non-fallback (main-source) request arms the per-host fail-fast memory. // With API sources enabled this covers every main-source timeout; otherwise it stays // limited to fallback-supporting endpoints, matching the legacy behavior. if networkError?.isURLRequestTimeout == true, !request.targetsFallbackHost, self.systemInfo.dangerousSettings.internalSettings.usesRemoteConfigAPISources || request.httpRequest.path.supportsFallbackURLs { requestTimeoutResult = .mainSourceTimedOut } // A failure that points at the host (never a device-connectivity error or a 4xx) on a // request targeting an API source may fail over to the next source, but only after the // current source's health check fails. The decision is asynchronous: the serial pipeline // stays stalled (as if the request were still in flight) until it completes. if case let .source(source, attemptCount) = request.apiSourceState, attemptCount < Self.maxAPISourceAttempts, error.isAllowedToRetryWithFallbackHost, let apiSourceFailover = self.apiSourceFailover { let requestTimeoutResult = requestTimeoutResult // Make sure to already update the shared timeout state before making any other requests self.requestTimeoutManager.recordRequestResult(host: urlRequest.url?.host, requestTimeoutResult) apiSourceFailover.onRequestFailure(source) { decision in let retryScheduled = self.handleAPISourceFailureDecision(decision, request: request, error: error, httpURLResponse: httpURLResponse) self.finish(request: request, response: .failure(error), retryScheduled: retryScheduled, requestTimeoutResult: requestTimeoutResult, recordRequestTimeoutResult: false, urlRequest: urlRequest, requestStartTime: requestStartTime) } return } // if we got back a 401 Unauthorized and we are running with access token support, // then get a new access token and try again var retryScheduled = self.reauthorizeRequestIfNeeded(request: request, basedOn: response, response: httpURLResponse) if !retryScheduled { retryScheduled = self.retryRequestWithNextFallbackHostIfNeeded(request: request, error: error) } if !retryScheduled { retryScheduled = self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse) } self.finish(request: request, response: .failure(error), retryScheduled: retryScheduled, requestTimeoutResult: requestTimeoutResult, urlRequest: urlRequest, requestStartTime: requestStartTime) } } else { Logger.debug(Strings.network.retrying_request(httpMethod: request.method.httpMethod, path: request.path)) self.state.modify { $0.queuedRequests.insert(request.retriedRequest(), at: 0) } self.finish(request: request, response: nil, retryScheduled: true, requestTimeoutResult: requestTimeoutResult, urlRequest: urlRequest, requestStartTime: requestStartTime) } } /// Schedules the retry (if any) that a failover `decision` calls for, returning whether one was /// scheduled. A source that failed its health check is retried on the next source; otherwise the /// pre-existing chain runs: the static fallback host walk, then the status-code backoff retry. private func handleAPISourceFailureDecision(_ decision: APISourceFailover.FailureDecision, request: Request, error: NetworkError, httpURLResponse: HTTPURLResponse?) -> Bool { switch decision { case let .retryNextSource(nextSource): Logger.debug(Strings.network.retrying_request_with_next_api_source( httpMethod: request.method.httpMethod, path: request.path, host: nextSource.handle.url )) self.state.modify { $0.queuedRequests.insert(request.requestWith(nextAPISource: nextSource), at: 0) } return true case .sourceHealthy, .sourcesExhausted: // Declining to switch sources doesn't disable the pre-existing static fallback: the // request still failed, so endpoints with fallback hosts keep retrying there as before. return self.retryRequestWithNextFallbackHostIfNeeded(request: request, error: error) || self.retryRequestIfNeeded(request: request, httpURLResponse: httpURLResponse) } } /// The shared tail of `handle(...)`: completes the request (unless a retry was scheduled), records /// timeout bookkeeping and diagnostics for the attempt, and unblocks the serial pipeline. // swiftlint:disable:next function_parameter_count private func finish(request: Request, response: VerifiedHTTPResponse.Result?, retryScheduled: Bool, requestTimeoutResult: HTTPRequestTimeoutManager.RequestResult, recordRequestTimeoutResult: Bool = true, urlRequest: URLRequest, requestStartTime: Date) { if !retryScheduled, let response = response { request.completionHandler?(response) } if recordRequestTimeoutResult { self.requestTimeoutManager.recordRequestResult(host: urlRequest.url?.host, requestTimeoutResult) } self.trackHttpRequestPerformedIfNeeded(request: request, host: urlRequest.url?.host, requestStartTime: requestStartTime, result: response) self.beginNextRequest() } func beginNextRequest() { let nextRequest: Request? = self.state.modify { if $0.paused == true { return nil } Logger.debug(Strings.network.serial_request_done(httpMethod: $0.currentSerialRequest?.method.httpMethod, path: $0.currentSerialRequest?.path, queuedRequestsCount: $0.queuedRequests.count)) $0.currentSerialRequest = $0.queuedRequests.popFirst() return $0.currentSerialRequest } if let nextRequest = nextRequest { Logger.debug(Strings.network.starting_next_request(request: nextRequest.description)) self.start(request: nextRequest) } } func start(request: Request) { var request = request request.resolveAPISourceIfNeeded(with: self.apiSourceFailover) let urlRequest = self.convert(request: request) guard let urlRequest = urlRequest else { let error: NetworkError = .unableToCreateRequest(request.httpRequest.path) Logger.error(error.description) request.completionHandler?(.failure(error)) return } Logger.debug(Strings.network.api_request_started(request.httpRequest)) var finalURLRequest = urlRequest let requestStartTime = self.dateProvider.now() #if DEBUG // Meant only for testing error handling behavior of the SDK. if let (fakeResponse, fakeData) = self.applyForceServerErrorStrategy(to: &finalURLRequest, request: request) { // `FB13133387`: when computing offline CustomerInfo, `StoreKit.Transaction.unfinished` // might be empty if called immediately after `Product.purchase()`. // This introduces a delay to simulate a real API request, and avoid that race condition. DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(300)) { self.handle(urlResponse: fakeResponse, request: request, urlRequest: urlRequest, data: fakeData, error: nil, requestStartTime: requestStartTime) } return } #endif finalURLRequest.timeoutInterval = requestTimeoutManager.timeout( host: finalURLRequest.url?.host, isFallbackHostRequest: request.targetsFallbackHost, endpointSupportsFallbackURLs: request.httpRequest.path.supportsFallbackURLs, isProxied: SystemInfo.proxyURL != nil, // The re-tiered fail-fast timeouts for main-API requests only apply when API sources are // enabled. Blob-source downloads opt in independently of this setting. reTieredTimeoutsEnabled: self.systemInfo.dangerousSettings.internalSettings.usesRemoteConfigAPISources ) // swiftlint:disable:next redundant_void_return let task = self.session.dataTask(with: finalURLRequest) { (data, urlResponse, error) -> Void in self.handle(urlResponse: urlResponse, request: request, urlRequest: urlRequest, data: data, error: error, requestStartTime: requestStartTime) } task.resume() } #if DEBUG /// Applies `forceServerErrorStrategy`, if configured, to `urlRequest`. /// - Returns: the response to return without performing the request, if the strategy fakes one. func applyForceServerErrorStrategy( to urlRequest: inout URLRequest, request: Request ) -> (HTTPURLResponse, Data)? { guard let strategy = self.systemInfo.dangerousSettings.internalSettings.forceServerErrorStrategy else { return nil } switch strategy.action(request) { case let .fakeResponse(fakeResponse, fakeData): Logger.warn(Strings.network.api_request_faking_response(request.httpRequest, statusCode: fakeResponse.statusCode)) return (fakeResponse, fakeData) case let .serverErrorURL(serverErrorURL): Logger.warn(Strings.network.api_request_forcing_server_error(request.httpRequest, serverErrorURL: serverErrorURL)) urlRequest = URLRequest(url: serverErrorURL) case let .appendQueryItems(queryItems): Logger.warn(Strings.network.api_request_appending_query_items(request.httpRequest, queryItems: queryItems)) if let url = urlRequest.url?.appendingQueryItems(queryItems) { urlRequest.url = url } case .performRequest: break } return nil } #endif func convert(request: Request) -> URLRequest? { guard let requestURL = request.getCurrentRequestURL( proxyURL: SystemInfo.proxyURL, apiSourceURL: request.apiSourceState.source?.url ) else { return nil } var urlRequest = URLRequest(url: requestURL) urlRequest.httpMethod = request.method.httpMethod urlRequest.allHTTPHeaderFields = self.headers(for: request, urlRequest: urlRequest) do { urlRequest.httpBody = try request.httpRequest.requestBody?.jsonEncodedData } catch { Logger.error(Strings.network.creating_json_error(error: error.localizedDescription)) return nil } return urlRequest } private func headers(for request: Request, urlRequest: URLRequest) -> HTTPClient.RequestHeaders { var headers = request.headers if request.httpRequest.path.shouldSendEtag { let eTagHeader = self.eTagManager.eTagHeader( for: urlRequest, withSignatureVerification: request.verificationMode.isEnabled, refreshETag: request.retried ) headers = headers.merging(eTagHeader) } if request.httpRequest.path.authenticated { // NOTE: it's almost guaranteed that the request will already have an "Authorization" header, // because it's how the API key is sent up // if the TokenManager produces a new authorization header, it will override // any existing authorization header present let authorization = self.tokenManager.authorizationHeaders(for: request) headers = headers.merging(authorization) } return headers } private func signing(for request: HTTPRequest) -> SigningType { #if DEBUG if self.systemInfo.dangerousSettings.internalSettings.forceSignatureFailures { Logger.warn(Strings.network.api_request_forcing_signature_failure(request)) return FakeSigning.default } #endif return self.signing } private func trackHttpRequestPerformedIfNeeded(request: Request, host: String?, requestStartTime: Date, result: Result, NetworkError>?) { if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { guard let diagnosticsTracker = self.diagnosticsTracker, let result else { return } let responseTime = self.dateProvider.now().timeIntervalSince(requestStartTime) let requestPathName = request.httpRequest.path.name switch result { case let .success(response): let httpStatusCode = response.httpStatusCode.rawValue let verificationResult = response.verificationResult diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName, host: host, responseTime: responseTime, wasSuccessful: true, responseCode: httpStatusCode, backendErrorCode: nil, resultOrigin: response.origin, verificationResult: verificationResult, isRetry: request.retried, connectionErrorReason: nil) case let .failure(error): var responseCode = -1 var backendErrorCode: Int? if case let .errorResponse(errorResponse, code, _) = error { responseCode = code.rawValue backendErrorCode = errorResponse.code.rawValue } diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName, host: host, responseTime: responseTime, wasSuccessful: false, responseCode: responseCode, backendErrorCode: backendErrorCode, resultOrigin: nil, verificationResult: .notRequested, isRetry: request.retried, connectionErrorReason: .init(from: error)) } } } } // MARK: - Request Reauthorize Logic extension HTTPClient { internal func reauthorizeRequestIfNeeded(request: HTTPClient.Request, basedOn originalResult: VerifiedHTTPResponse.Result, response: HTTPURLResponse?) -> Bool { let reauthAction = self.tokenManager.tokenRefreshRequest(for: request, response: response, duplicateRequestHandler: { wasSuccessful in self.reauthorizationDidComplete(wasSuccessful: wasSuccessful, originalRequest: request, originalResult: originalResult) }) switch reauthAction { case .noAction: return false case .refresh(let reauthRequest): let clientRequest = Request(httpRequest: reauthRequest, authHeaders: self.authHeaders, defaultHeaders: self.defaultHeaders, verificationMode: self.systemInfo.responseVerificationMode, preferIAMPath: self.tokenManager.enabled, internalSettings: self.systemInfo.dangerousSettings.internalSettings, completionHandler: { (result: VerifiedHTTPResponse.Result) in let wasSuccessful = self.tokenManager.handleTokenRefreshResponse(result) self.reauthorizationDidComplete(wasSuccessful: wasSuccessful, originalRequest: request, originalResult: originalResult) }) self.state.modify { $0.queuedRequests.insert(clientRequest, at: 0) } return true case .waitingForOtherRequest: self.state.modify { $0.paused = true } // return true to indicate that the request *will* be retried return true } } private func reauthorizationDidComplete(wasSuccessful: Bool, originalRequest: HTTPClient.Request, originalResult: VerifiedHTTPResponse.Result) { let needsToRestart: Bool if wasSuccessful { let retried = originalRequest.retriedRequest() needsToRestart = self.state.modify { let shouldRestart = ($0.paused == true) $0.paused = false $0.queuedRequests.insert(retried, at: 0) return shouldRestart } } else { // the token manager did not get new tokens // therefore, the original request should be failed needsToRestart = self.state.modify { let shouldRestart = ($0.paused == true) $0.paused = false return shouldRestart } // the original request needs to be failed // re-use the original 401 Unauthorized originalRequest.completionHandler?(originalResult) } if needsToRestart { self.beginNextRequest() } } } // 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. /// - error: The `HTTPClient.NetworkError` that was received. /// - Returns: A Boolean value indicating whether the request was retried. internal func retryRequestWithNextFallbackHostIfNeeded( request: HTTPClient.Request, error: NetworkError ) -> Bool { // The request must be able to be retried with a fallback host guard error.isAllowedToRetryWithFallbackHost, 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. /// If the retry conditions are met, it schedules the request to be retried after a backoff interval. /// /// - 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 scheduled for a retry. internal func retryRequestIfNeeded( request: HTTPClient.Request, httpURLResponse: HTTPURLResponse? ) -> Bool { guard request.httpRequest.isRetryable, let httpURLResponse = httpURLResponse, isResponseRetryable(httpURLResponse) else { return false } // At this point, retryCount hasn't been incremented yet, so we'll need to do it early here // to determine if another retry is appropriate. let nextRetryCount = request.retryCount + 1 guard nextRetryCount <= self.retryBackoffIntervals.count else { Logger.error( NetworkStrings.api_request_failed_all_retries( httpMethod: request.method.httpMethod, path: request.path, retryCount: request.retryCount ) ) return false } let retryBackoffInterval: TimeInterval = calculateRetryBackoffTime( forResponse: httpURLResponse, retryCount: nextRetryCount ) Logger.debug( NetworkStrings.api_request_queued_for_retry( httpMethod: request.method.httpMethod, retryNumber: nextRetryCount, path: request.path, backoffInterval: retryBackoffInterval ) ) self.operationDispatcher.dispatchOnWorkerThread(after: retryBackoffInterval) { let retriedRequest = request.retriedRequest() self.state.modify { $0.queuedRequests.insert(retriedRequest, at: 0) } self.beginNextRequest() } return true } internal func isResponseRetryable(_ urlResponse: HTTPURLResponse) -> Bool { let isStatusCodeRetryable = self.retriableStatusCodes.contains(urlResponse.httpStatusCode) let doesServerAllowRetryString = urlResponse.value(forHTTPHeaderField: ResponseHeader.isRetryable.rawValue) let doesServerAllowRetry: Bool if let doesServerAllowRetryString = doesServerAllowRetryString { doesServerAllowRetry = Bool(doesServerAllowRetryString.lowercased()) ?? true } else { doesServerAllowRetry = true } return isStatusCodeRetryable && doesServerAllowRetry } internal func calculateRetryBackoffTime( forResponse httpURLResponse: HTTPURLResponse, retryCount: UInt ) -> TimeInterval { // Use the retry after value from the backend if present if let retryAfterHeaderValue = httpURLResponse.allHeaderFields[ResponseHeader.retryAfter.rawValue] as? String, let retryAfterSeconds = Double(retryAfterHeaderValue) { // Ensure that the retry interval is not negative or greater than 1 hour let nonNegativeRetryAfterSeconds = max(0, retryAfterSeconds) let cappedRetryInterval = min( nonNegativeRetryAfterSeconds, 3_600 // 1 hour in seconds ) return TimeInterval(cappedRetryInterval) } // Otherwise, use a default value let backoffIntervalIndex = Int(max(retryCount - 1, 0)) let backoffIntervalIndexIsWithinBounds = backoffIntervalIndex < self.retryBackoffIntervals.count return backoffIntervalIndexIsWithinBounds ? self.retryBackoffIntervals[backoffIntervalIndex] : 0 } } // MARK: - Extensions extension HTTPClient { /// Information from a response to help identify a request. struct ResponseMetadata { var requestID: String? var amazonTraceID: String? } } extension HTTPRequest { func requestAddingNonceIfRequired( with verificationMode: Signing.ResponseVerificationMode ) -> HTTPRequest { var result = self if result.nonce == nil, result.path.needsNonceForSigning, verificationMode.isEnabled { result.addRandomNonce() } return result } func headers( with authHeaders: HTTPClient.RequestHeaders, defaultHeaders: HTTPClient.RequestHeaders, verificationMode: Signing.ResponseVerificationMode, internalSettings: InternalDangerousSettingsType ) -> HTTPClient.RequestHeaders { var result: HTTPClient.RequestHeaders = defaultHeaders result += self.path.additionalHeaders result += self.additionalHeaders if self.path.authenticated { result += authHeaders } if let nonce = self.nonce { result += HTTPClient.nonceHeader(with: nonce) } if verificationMode.isEnabled, self.path.supportsSignatureVerification { let headerParametersSignature = HTTPClient.headerParametersForSignatureHeader( with: result, path: self.path ) #if DEBUG if !internalSettings.disableHeaderSignatureVerification { result += headerParametersSignature } #else result += headerParametersSignature #endif if let body = self.requestBody { result += HTTPClient.postParametersHeaderForSigning(with: body) } } return result } /// Add a nonce to the request private mutating func addRandomNonce() { self.nonce = Data.randomNonce() } } private extension NetworkError { /// Creates a `NetworkError` from any request `Error`. init(_ error: Error, dnsChecker: DNSCheckerType.Type) { if let blockedError = dnsChecker.errorWithBlockedHostFromError(error) { Logger.error(blockedError.description) self = blockedError } else { self = .networkError(error as NSError) } } } extension HTTPClient { static func responseBodyData(statusCode: HTTPStatusCode, data: Data?) -> Data? { switch statusCode { case .notModified: // `nil` if status code is 304, since the response will be empty and fetched from the eTag. return nil case .noContent: return data ?? Data() default: return data } } } extension Result where Success == Data?, Failure == NetworkError { /// Converts a `Result` into `Result, NetworkError>` func mapToResponse( response: HTTPURLResponse, request: HTTPRequest ) -> Result, Failure> { return self.flatMap { body in return .success( .init( httpStatusCode: response.httpStatusCode, responseHeaders: response.allHeaderFields, body: body ) ) } } } extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { // Parses a `Result>` to `Result>` func parseResponse() -> VerifiedHTTPResponse.Result { return self.parseResponse { data, statusCode in try Value.create(with: data, httpStatusCode: statusCode) } } /// Parses a response using a request-specific decoder while preserving standard network decoding errors. func parseResponse( using create: (Data, HTTPStatusCode) throws -> Value ) -> VerifiedHTTPResponse.Result { return self.flatMap { response in // Convert the `Result` type Result, Error> { // Create a new `Result` try response.mapBody { data in // Convert the from `Data` -> `Value` try create(data, response.httpStatusCode) } .copyWithNewRequestDate() // Update request date for 304 responses } // Convert decoding errors into `NetworkError.decoding` .mapError { NetworkError.decoding($0, response.response.body) } } } } extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { // Converts an unsuccessful response into a `Result.failure` fileprivate func convertUnsuccessfulResponseToError() -> Self { return self.flatMap { $0.response.httpStatusCode.isSuccessfulResponse ? .success($0) : .failure($0.response.parseUnsuccessfulResponse()) } } } private extension VerifiedHTTPResponse { func copyWithNewRequestDate() -> Self { // Update request time from server unless it failed verification. guard self.verificationResult != .failed, let requestDate = self.requestDate else { return self } return self.mapBody { return $0.copy(with: requestDate) } } } extension HTTPResponseType { var isLoadShedder: Bool { return self.value(forHeaderField: HTTPClient.ResponseHeader.isLoadShedder) == "true" } } private extension HTTPResponseType { var metadata: HTTPClient.ResponseMetadata { return .init( requestID: self.value(forHeaderField: HTTPClient.ResponseHeader.requestID), amazonTraceID: self.value(forHeaderField: HTTPClient.ResponseHeader.amazonTraceID) ) } } private extension HTTPResponse where Body == Data { func parseUnsuccessfulResponse() -> NetworkError { let contentType = self.value(forHeaderField: HTTPClient.ResponseHeader.contentType) ?? "" let isJSON = contentType.starts(with: "application/json") return .errorResponse( isJSON ? .from(self.body) : .defaultResponse, self.httpStatusCode ) } } #if DEBUG private extension URL { /// Returns this URL with `queryItems` added to the ones it already contains, if any. func appendingQueryItems(_ queryItems: [URLQueryItem]) -> URL? { // Request URLs are relative to a base URL, so the base needs resolving to keep the host. guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { return nil } components.queryItems = (components.queryItems ?? []) + queryItems return components.url } } #endif