mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* 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>
1294 lines
53 KiB
Swift
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
|