mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* feat(remote-config): health-check-gated API source failover in HTTPClient Wires the API source failover into the request pipeline, still behind the usesRemoteConfigAPISources dangerous setting (internal and defaulting to false, so released behavior is unchanged): - Requests resolve their API source once (in start) and carry the handle, so a failure can report the exact source unhealthy and ETag/backoff retries keep targeting the same host. - On a failure that points at the host, the source is health-checked before failing over: a healthy source surfaces the original error, an unhealthy one is reported and the request transparently retries on the next source (capped at 5 source attempts per request). The static per-endpoint fallback hosts stay the last resort. - Unlike Android, device-connectivity errors (reusing NetworkError.isAllowedToRetryWithFallbackHost) skip the health check entirely and surface the original error: switching hosts can't fix a device that is offline. - Both Backend HTTPClients share one APISourceFailover, so they walk one source list and share one health-check cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(remote-config): end-to-end API source failover integration tests Port of purchases-android's BackendAPISourceFailoverIntegrationTest: real Backend.getCustomerInfo through the production wiring (real HTTPClients, shared APISourceFailover, real SourceHealthChecker probing over the URL loading system) and a real RemoteConfigSourceProvider fed by an in-memory sources topic, with OHHTTPStubs standing in for the source hosts and recording exact per-host request order. Adds the iOS-only device-connectivity scenario and a dangerous-setting-off wiring check on top of Android's seven scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: model the request's API source as a single state enum Replaces the three loosely coupled Request fields (apiSource, apiSourceResolved, apiSourceAttemptCount) with one APISourceState enum (unresolved / noSource / source(_, attemptCount:)), making inconsistent combinations unrepresentable. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: adopt the narrowed APISourceFailover initializer Passes just the usesRemoteConfigAPISources flag at the two construction sites, following the signature change in the base branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: clarify why static fallback still runs when the source is healthy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(remote-config): raise the API failover restart interval to 2 hours The api source list now returns to the primary source 2 hours after it first failed over instead of 10 minutes. Timer tests derive their time values from the constant so future changes are single-line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(remote-config): cover shared failover and probe-in-flight ordering Addresses review feedback: - Both HTTPClient lanes share one APISourceFailover: a failover on the main lane advances the source list the remote-config lane resolves. - A request enqueued while a health check is in flight stays queued until the probe resolves and runs after the front-queued failover retry. - Bind the source and attempt count in one pattern match at the failover guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: drop the APISourceState.attemptCount accessor Its only caller pattern-matches instead, surfacing the "must already target a source" assumption at the one place it matters rather than papering over impossible states with a 0 default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1228 lines
50 KiB
Swift
1228 lines
50 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
|
|
private 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]),
|
|
requestTimeout: TimeInterval = Configuration.networkTimeoutDefault,
|
|
dateProvider: DateProvider = DateProvider(),
|
|
operationDispatcher: OperationDispatcher,
|
|
apiSourceFailover: APISourceFailoverType?,
|
|
timeoutManager: HTTPRequestTimeoutManagerType? = nil
|
|
) {
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.httpMaximumConnectionsPerHost = 1
|
|
config.timeoutIntervalForRequest = requestTimeout
|
|
config.timeoutIntervalForResource = requestTimeout
|
|
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 = requestTimeout
|
|
self.authHeaders = HTTPClient.authorizationHeader(withAPIKey: systemInfo.apiKey)
|
|
self.dateProvider = dateProvider
|
|
self.operationDispatcher = operationDispatcher
|
|
self.apiSourceFailover = apiSourceFailover
|
|
self.requestTimeoutManager = timeoutManager ?? HTTPRequestTimeoutManager(
|
|
defaultTimeout: timeout,
|
|
dateProvider: dateProvider
|
|
)
|
|
}
|
|
|
|
/// - 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 eTagValidationTime = "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
|
|
}
|
|
|
|
/// 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
|
|
|
|
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))
|
|
}
|
|
|
|
// Record successful response from the main backend
|
|
if !request.isFallbackURLRequest {
|
|
requestTimeoutResult = .successOnMainBackend
|
|
}
|
|
|
|
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 main backend URL for a request that has a fallback URL
|
|
if let error = networkError as? URLError, case .timedOut = error.code,
|
|
!request.isFallbackURLRequest,
|
|
request.httpRequest.path.supportsFallbackURLs {
|
|
requestTimeoutResult = .timeoutOnMainBackendForFallbackSupportedEndpoint
|
|
}
|
|
|
|
// 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
|
|
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,
|
|
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,
|
|
urlRequest: URLRequest,
|
|
requestStartTime: Date) {
|
|
if !retryScheduled, let response = response {
|
|
request.completionHandler?(response)
|
|
}
|
|
|
|
self.requestTimeoutManager.recordRequestResult(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 forceErrorStrategy = self.systemInfo.dangerousSettings.internalSettings.forceServerErrorStrategy {
|
|
|
|
if let (fakeResponse, fakeData) = forceErrorStrategy.fakeResponseWithoutPerformingRequest(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.
|
|
|
|
Logger.warn(Strings.network.api_request_faking_error_response(request.httpRequest))
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(300)) {
|
|
self.handle(urlResponse: fakeResponse,
|
|
request: request,
|
|
urlRequest: urlRequest,
|
|
data: fakeData,
|
|
error: nil,
|
|
requestStartTime: requestStartTime)
|
|
}
|
|
return
|
|
}
|
|
|
|
if forceErrorStrategy.shouldForceServerError(request) {
|
|
Logger.warn(Strings.network.api_request_forcing_server_error(request.httpRequest))
|
|
finalURLRequest = URLRequest(url: forceErrorStrategy.serverErrorURL)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
finalURLRequest.timeoutInterval = requestTimeoutManager.timeout(
|
|
isFallback: request.isFallbackURLRequest,
|
|
fallbackAvailable: request.httpRequest.path.supportsFallbackURLs && SystemInfo.proxyURL == nil
|
|
)
|
|
|
|
// 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()
|
|
}
|
|
|
|
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
|
|
|
|
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: defaultHeaders,
|
|
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
|
|
)
|
|
}
|
|
|
|
}
|