Files
purchases-ios/Sources/Networking/HTTPClient/HTTPClient.swift
T
e7384b8282 Re-tier HTTP request timeouts around per-host memory (#7179)
* Re-tier HTTP request timeouts around per-source memory

Replaces the single global "recent timeout" slot in `HTTPRequestTimeoutManager`
with a per-host map (`host -> lastTimeoutDate`, 10-minute per-entry expiry,
pruned on access) and re-tiers the request timeouts:

- Main-source, endpoint with no fallback-URL support: 15s base / 5s reduced
- Main-source, endpoint with fallback-URL support: 5s base / 2s reduced
- Fallback-host and proxied requests: flat 30s (never consult the memory)

A main-source request to host H uses the reduced tier iff H has a non-expired
timeout entry. Timeouts are now recorded for any non-fallback request (dropping
the previous fallback-support-only condition), keyed by attempt host; a
successful main-source response clears only that host's entry. Renames the
`timeoutOnMainBackendForFallbackSupportedEndpoint` result to `mainSourceTimedOut`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Define flat timeout tier as a Timeout constant

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add exhaustive multi-host tests for timeout manager

Co-authored-by: Cursor <cursoragent@cursor.com>

* Honor developer networkTimeout via internal NetworkTimeout enum

Introduce an internal NetworkTimeout enum (.default/.custom) threaded from
Configuration down to HTTPRequestTimeoutManager. When a developer sets
networkTimeout, it replaces the built-in base/flat tiers while the reduced
fail-fast tiers stay fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add NetworkTimeout.swift to RevenueCat.xcodeproj

The file was only picked up by the SPM/Tuist directory globs, so the
hand-maintained Xcode project build failed to resolve the type.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate re-tiered no-fallback timeouts behind usesRemoteConfigAPISources

The aggressive no-fallback fail-fast timeouts and their per-host timeout
recording now apply to main-API requests only when the usesRemoteConfigAPISources
dangerous setting is enabled. With it disabled (the default) the legacy network
timeout is preserved. The gating is a per-call timeout(...) parameter so
blob-source downloads can opt into the re-tiered timeouts independently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the legacy flat timeout when re-tiered timeouts are off, and share one manager

The fallback-host and proxied tiers returned the new 30s flat timeout regardless of
`usesRemoteConfigAPISources`, halving the legacy 60s (or custom) network timeout for
proxy users and every fallback-host request. They now honor `reTieredTimeoutsEnabled`
like the no-fallback tier does, so only the fallback-supported tiers behave the same
either way.

`Backend` also built one timeout manager per HTTPClient, so the main and remote-config
lanes kept independent per-host memories despite talking to the same hosts. They now
share one, and `HTTPClient` requires both the manager and the network timeout so this
wiring can't be dropped silently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clear a host's fail-fast memory as soon as it answers

The memory exists to remember how quickly a host responds, so a non-error
response is evidence the host recovered even when decoding or verifying it
fails afterwards. Deciding off the raw status code instead of the parsed result
keeps those failures from leaving a responsive host stuck on the reduced
timeout tier.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the requestTimeoutManager in the fail-fast comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten comments on the Backend-shared failover and timeout manager

Name the instances the comments describe so it's clear what is shared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Record API source timeouts before health checks

* Test shared timeout state across backend lanes

* Normalize URL timeout classification across platforms

* Remove unused RemoteConfigAPI test hook

* Skip HTTP stubs lane tests on watchOS

* Treat the remote config fallback path as a fallback host request

`FallbackPath` bakes the fallback host into its `serverHostURL`, but `fallbackUrlIndex` is
nil on those requests, so `isFallbackURLRequest` was false and they were classified as
main-source: with API sources enabled they drew the aggressive 15s/5s no-fallback tiers
instead of the flat fallback timeout, their timeouts armed the per-host fail-fast memory
for the fallback host, and their successes cleared the main source's entry.

Add `isFallbackHostPath` so a path can declare that it targets a fallback host on its own,
and use it alongside the index when picking the tier and updating the memory.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com>
2026-08-03 14:51:50 +02:00

1294 lines
53 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
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,
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.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>?
) {
self.perform(request: .init(httpRequest: request,
authHeaders: self.authHeaders,
defaultHeaders: self.defaultHeaders,
verificationMode: verificationMode ?? self.systemInfo.responseVerificationMode,
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<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 queuedRequests: [Request]
var currentSerialRequest: Request?
static let initial: Self = .init(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 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,
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
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 { self.httpRequest.path.relativePath }
func getCurrentRequestURL(proxyURL: URL?, apiSourceURL: URL?) -> URL? {
return self.httpRequest.path.url(
proxyURL: proxyURL,
apiSourceURL: apiSourceURL,
fallbackUrlIndex: self.fallbackUrlIndex
)
}
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 {
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<Data>.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<VerifiedHTTPResponse<Data>, NetworkError>?`
// swiftlint:disable:next function_body_length
private func createVerifiedResponse(
request: Request,
urlRequest: URLRequest,
data: Data?,
response httpURLResponse: HTTPURLResponse,
requestStartTime: Date
) -> VerifiedHTTPResponse<Data>.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 = request.headers
}
#else
let requestHeaders = request.headers
#endif
let result = 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
)
}
// 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
)
}
// Upgrade to error in enforced mode
.flatMap { response -> Result<VerifiedHTTPResponse<Data>?, 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
}
var 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<Data>.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 {
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 {
if request.httpRequest.path.shouldSendEtag {
let eTagHeader = self.eTagManager.eTagHeader(
for: urlRequest,
withSignatureVerification: request.verificationMode.isEnabled,
refreshETag: request.retried
)
return request.headers.merging(eTagHeader)
} else {
return request.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<VerifiedHTTPResponse<Data>, 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 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.
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