Files
purchases-ios/Sources/Networking/HTTPClient/HTTPClient.swift
T

1448 lines
60 KiB
Swift

//
// 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<HTTPEmptyResponseBody>.Headers
typealias Completion<Value: HTTPResponseBody> = (VerifiedHTTPResponse<Value>.Result) -> Void
let systemInfo: SystemInfo
let timeout: TimeInterval
let authHeaders: RequestHeaders
let tokenManager: TokenManager
private let session: URLSession
private let state: Atomic<State> = .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<HTTPStatusCode>
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<HTTPStatusCode> = 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<Value: HTTPResponseBody>(
_ request: HTTPRequest,
with verificationMode: Signing.ResponseVerificationMode? = nil,
completionHandler: Completion<Value>?
) {
// 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 || self.systemInfo.isSimulatedStoreAPIKey)",
"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: HTTPRequest.Path
) -> 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<Data, Error>) -> 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<Data>?
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<Value: HTTPResponseBody>(httpRequest: HTTPRequest,
authHeaders: HTTPClient.RequestHeaders,
defaultHeaders: HTTPClient.RequestHeaders,
verificationMode: Signing.ResponseVerificationMode,
preferIAMPath: Bool,
internalSettings: InternalDangerousSettingsType,
completionHandler: HTTPClient.Completion<Value>?) {
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 {
/// The processed SDK result together with network context that can be lost while transforming it.
struct ResponseContext {
/// The final response or error after cache resolution, signature verification, and HTTP status handling.
let result: VerifiedHTTPResponse<Data>.Result?
/// Verification context captured before an enforced-verification failure becomes a `NetworkError`.
let verificationDiagnostics: VerificationDiagnostics?
}
struct VerificationDiagnostics {
let result: SignatureVerificationResult
let requestDate: Date?
}
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) -> ResponseContext {
if let networkError = networkError {
return .init(result: .failure(NetworkError(networkError, dnsChecker: self.dnsChecker)),
verificationDiagnostics: nil)
}
guard let httpURLResponse = urlResponse as? HTTPURLResponse else {
return .init(result: .failure(.unexpectedResponse(urlResponse)),
verificationDiagnostics: nil)
}
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<VerifiedHTTPResponse<Data>, NetworkError>?`
// swiftlint:disable:next function_body_length
private func createVerifiedResponse(
request: Request,
urlRequest: URLRequest,
data: Data?,
response httpURLResponse: HTTPURLResponse,
requestStartTime: Date
) -> ResponseContext {
#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 responseResult: Result<VerifiedHTTPResponse<Data>?, NetworkError> = Result
.success(data)
.mapToResponse(response: httpURLResponse, request: request.httpRequest)
// Verify response
.map { cachedResponse -> VerifiedHTTPResponse<Data?> 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<Data>? in
guard request.httpRequest.path.shouldSendEtag else {
return response.asOptionalResponse
}
return self.eTagManager.httpResultFromCacheOrBackend(
with: response,
request: urlRequest,
retried: request.retried,
isFallbackURLRequest: request.isFallbackURLRequest
)
}
let verificationDiagnostics: VerificationDiagnostics?
if case let .success(response?) = responseResult, response.verificationResult.isFailed {
verificationDiagnostics = .init(
result: response.verificationResult,
requestDate: response.requestDate
)
} else {
verificationDiagnostics = nil
}
let result = responseResult
// Upgrade to error in enforced mode
.flatMap { response -> Result<VerifiedHTTPResponse<Data>?, NetworkError> in
if let response = response, response.verificationResult.isFailed {
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 .init(result: result,
verificationDiagnostics: verificationDiagnostics)
}
// 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 responseContext = self.parse(urlResponse: urlResponse,
request: request,
urlRequest: urlRequest,
data: data,
error: networkError,
requestStartTime: requestStartTime)
let response = responseContext.result
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,
responseContext: responseContext,
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,
responseContext: responseContext,
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,
responseContext: responseContext,
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,
responseContext: responseContext,
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,
responseContext: ResponseContext,
retryScheduled: Bool,
requestTimeoutResult: HTTPRequestTimeoutManager.RequestResult,
recordRequestTimeoutResult: Bool = true,
urlRequest: URLRequest,
requestStartTime: Date) {
if !retryScheduled, let response = responseContext.result {
request.completionHandler?(response)
}
if recordRequestTimeoutResult {
self.requestTimeoutManager.recordRequestResult(host: urlRequest.url?.host, requestTimeoutResult)
}
self.trackHttpRequestPerformedIfNeeded(request: request,
host: urlRequest.url?.host,
requestStartTime: requestStartTime,
responseContext: responseContext)
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 .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,
responseContext: ResponseContext) {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) {
guard let diagnosticsTracker = self.diagnosticsTracker, let result = responseContext.result else { return }
let responseTime = self.dateProvider.now().timeIntervalSince(requestStartTime)
let requestPathName = request.httpRequest.path.name
switch result {
case let .success(response):
diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName,
host: host,
responseTime: responseTime,
wasSuccessful: true,
responseCode: response.httpStatusCode.rawValue,
backendErrorCode: nil,
resultOrigin: response.origin,
verificationResult: response.verificationResult,
responseRequestDate: response.requestDate,
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
}
let verificationDiagnostics = responseContext.verificationDiagnostics
let verificationResult = verificationDiagnostics?.result ?? .notRequested
diagnosticsTracker.trackHttpRequestPerformed(endpointName: requestPathName,
host: host,
responseTime: responseTime,
wasSuccessful: false,
responseCode: responseCode,
backendErrorCode: backendErrorCode,
resultOrigin: nil,
verificationResult: verificationResult,
responseRequestDate: verificationDiagnostics?.requestDate,
isRetry: request.retried,
connectionErrorReason: .init(from: error))
}
}
}
}
// MARK: - Request Reauthorize Logic
extension HTTPClient {
internal func reauthorizeRequestIfNeeded(request: HTTPClient.Request,
basedOn originalResult: VerifiedHTTPResponse<Data>.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<TokenResponse>.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<Data>.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<Data?, NetworkError>` into `Result<HTTPResponse<Data?>, NetworkError>`
func mapToResponse(
response: HTTPURLResponse,
request: HTTPRequest
) -> Result<HTTPResponse<Data?>, Failure> {
return self.flatMap { body in
return .success(
.init(
httpStatusCode: response.httpStatusCode,
responseHeaders: response.allHeaderFields,
body: body
)
)
}
}
}
extension Result where Success == VerifiedHTTPResponse<Data>, Failure == NetworkError {
// Parses a `Result<VerifiedHTTPResponse<Data>>` to `Result<VerifiedHTTPResponse<Value>>`
func parseResponse<Value: HTTPResponseBody>() -> VerifiedHTTPResponse<Value>.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<Value: HTTPResponseBody>(
using create: (Data, HTTPStatusCode) throws -> Value
) -> VerifiedHTTPResponse<Value>.Result {
return self.flatMap { response in // Convert the `Result` type
Result<VerifiedHTTPResponse<Value>, Error> { // Create a new `Result<Value>`
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<Data>, 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.
if self.verificationResult.isFailed {
return self
}
guard 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