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>
334 lines
11 KiB
Swift
334 lines
11 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
|
|
//
|
|
// NetworkError.swift
|
|
//
|
|
// Created by Nacho Soto on 3/31/22.
|
|
|
|
// swiftlint:disable multiline_parameters
|
|
|
|
import Foundation
|
|
|
|
extension Swift.Error {
|
|
|
|
/// Foundation does not bridge URL loading errors consistently across Apple platforms: the same
|
|
/// URLSession timeout may arrive as `URLError` on iOS but remain `NSError` on macOS. Normalize to
|
|
/// `NSError` so callers classify the underlying URL error domain and code consistently.
|
|
var isURLRequestTimeout: Bool {
|
|
let error = self as NSError
|
|
return error.domain == NSURLErrorDomain && error.code == NSURLErrorTimedOut
|
|
}
|
|
|
|
}
|
|
|
|
/// Represents an error created by `HTTPClient`.
|
|
enum NetworkError: Swift.Error, Equatable {
|
|
|
|
case decoding(NSError, Source)
|
|
case networkError(NSError, Source)
|
|
case dnsError(failedURL: URL, resolvedHost: String?, Source)
|
|
case unableToCreateRequest(path: String, Source)
|
|
case unexpectedResponse(URLResponse?, Source)
|
|
case errorResponse(ErrorResponse, HTTPStatusCode, Source)
|
|
case signatureVerificationFailed(path: String, HTTPStatusCode, Source)
|
|
|
|
}
|
|
|
|
extension NetworkError {
|
|
|
|
static func decoding(
|
|
_ error: Error,
|
|
_ data: Data,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
// Explicitly logging errors since it might help debugging issues.
|
|
Logger.error(Strings.network.parsing_json_error(error: error))
|
|
Logger.error(Strings.network.json_data_received(
|
|
dataString: String(data: data, encoding: .utf8) ?? ""
|
|
))
|
|
|
|
return .decoding(error as NSError, .init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func networkError(
|
|
_ error: Error,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .networkError(error as NSError, .init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func dnsError(
|
|
failedURL: URL, resolvedHost: String?,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .dnsError(failedURL: failedURL, resolvedHost: resolvedHost,
|
|
.init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func unableToCreateRequest(
|
|
_ path: HTTPRequestPath,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .unableToCreateRequest(path: path.relativePath, .init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func unexpectedResponse(
|
|
_ response: URLResponse?,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .unexpectedResponse(response, .init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func errorResponse(
|
|
_ response: ErrorResponse, _ statusCode: HTTPStatusCode,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .errorResponse(response, statusCode, .init(file: file, function: function, line: line))
|
|
}
|
|
|
|
static func signatureVerificationFailed(
|
|
path: HTTPRequestPath,
|
|
code: HTTPStatusCode,
|
|
file: String = #fileID, function: String = #function, line: UInt = #line
|
|
) -> Self {
|
|
return .signatureVerificationFailed(
|
|
path: path.relativePath,
|
|
code,
|
|
.init(file: file, function: function, line: line)
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
extension NetworkError: PurchasesErrorConvertible {
|
|
|
|
var asPurchasesError: PurchasesError {
|
|
switch self {
|
|
case let .decoding(error, source):
|
|
return ErrorUtils.unexpectedBackendResponse(
|
|
withSubError: error,
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .networkError(error, source)
|
|
where error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet:
|
|
return ErrorUtils.offlineConnectionError(
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .networkError(error, source):
|
|
return ErrorUtils.networkError(
|
|
withUnderlyingError: error,
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .dnsError(failedURL, resolvedHost, source):
|
|
return ErrorUtils.networkError(
|
|
message: NetworkStrings.blocked_network(url: failedURL, newHost: resolvedHost).description,
|
|
withUnderlyingError: self,
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .unableToCreateRequest(path, source):
|
|
return ErrorUtils.networkError(
|
|
extraUserInfo: [
|
|
"request_path": path
|
|
],
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .unexpectedResponse(response, source):
|
|
return ErrorUtils.unexpectedBackendResponseError(
|
|
extraUserInfo: [
|
|
"response": response?.description ?? ""
|
|
],
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
|
|
case let .errorResponse(response, statusCode, source):
|
|
return response.asBackendError(with: statusCode,
|
|
file: source.file,
|
|
function: source.function,
|
|
line: source.line)
|
|
|
|
case let .signatureVerificationFailed(path, code, source):
|
|
return ErrorUtils.signatureVerificationFailedError(
|
|
path: path,
|
|
code: code,
|
|
fileName: source.file,
|
|
functionName: source.function,
|
|
line: source.line
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension NetworkError: DescribableError {
|
|
|
|
var description: String {
|
|
switch self {
|
|
case let .decoding(error, _):
|
|
return error.localizedDescription
|
|
|
|
case let .networkError(error, _)
|
|
where error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet:
|
|
return ErrorCode.offlineConnectionError.description
|
|
|
|
case let .networkError(error, _):
|
|
return error.localizedDescription
|
|
|
|
case let .dnsError(failedURL, resolvedHost, _):
|
|
return NetworkStrings.blocked_network(url: failedURL, newHost: resolvedHost).description
|
|
|
|
case let .unableToCreateRequest(path, _):
|
|
return "Could not create request to \(path)"
|
|
|
|
case let .unexpectedResponse(response, _):
|
|
return "Unexpected response type: \(response.debugDescription)"
|
|
|
|
case .errorResponse:
|
|
return self.asPurchasesError.localizedDescription
|
|
|
|
case .signatureVerificationFailed:
|
|
return self.asPurchasesError.localizedDescription
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension NetworkError: CustomNSError {
|
|
|
|
var errorUserInfo: [String: Any] {
|
|
return [
|
|
NSLocalizedDescriptionKey: self.description
|
|
]
|
|
}
|
|
|
|
}
|
|
|
|
extension NetworkError {
|
|
|
|
/// Whether the network request producing this error actually synced the data.
|
|
var successfullySynced: Bool {
|
|
return self.errorStatusCode?.isSuccessfullySynced ?? false
|
|
}
|
|
|
|
/// Whether the network request producing this error can be completed.
|
|
/// If `false`, the response was a server error.
|
|
var finishable: Bool {
|
|
if let statusCode = self.errorStatusCode {
|
|
return !statusCode.isServerError
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var errorStatusCode: HTTPStatusCode? {
|
|
switch self {
|
|
case let .errorResponse(_, statusCode, _):
|
|
return statusCode
|
|
|
|
case .decoding,
|
|
.networkError,
|
|
.dnsError,
|
|
.unableToCreateRequest,
|
|
.unexpectedResponse,
|
|
.signatureVerificationFailed:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var isServerDown: Bool {
|
|
return self.errorStatusCode?.isServerError == true
|
|
}
|
|
|
|
/// Whether to fall back to the last cached response for this error. A 5xx or a connection-level
|
|
/// failure (no status code) is transient, so serving the last cached copy is appropriate; a 4xx
|
|
/// is the backend authoritatively rejecting the request, so stale data should not be served.
|
|
var shouldFallBackToCache: Bool {
|
|
if let errorStatusCode {
|
|
return errorStatusCode.isServerError
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Whether this error is transient and therefore worth retrying: connection-level failures
|
|
/// (no HTTP status code) and `5xx` server responses. `4xx` responses are terminal — the backend
|
|
/// authoritatively rejected the request, so retrying won't yield a different result.
|
|
var isTransient: Bool {
|
|
switch self {
|
|
case .decoding, .unableToCreateRequest, .signatureVerificationFailed:
|
|
return false
|
|
case .dnsError, .networkError, .unexpectedResponse:
|
|
return true
|
|
case let .errorResponse(_, statusCode, _):
|
|
return statusCode.isServerError
|
|
}
|
|
}
|
|
|
|
/// Whether this error was caused by the *device* lacking connectivity, as opposed to the host being
|
|
/// unreachable.
|
|
///
|
|
/// Only device-side `URLError` codes qualify. Host-side or ambiguous failures — `.cannotConnectToHost`,
|
|
/// `.cannotFindHost`, `.dnsLookupFailed`, `.timedOut`, and DNS blocking (``NetworkError/dnsError``) — are
|
|
/// not considered device connectivity errors, since a different host may still succeed.
|
|
var isDeviceConnectivityError: Bool {
|
|
switch self {
|
|
case let .networkError(error, _):
|
|
return error.domain == NSURLErrorDomain
|
|
&& Self.deviceConnectivityURLErrorCodes.contains(error.code)
|
|
case .decoding,
|
|
.dnsError,
|
|
.unableToCreateRequest,
|
|
.unexpectedResponse,
|
|
.errorResponse,
|
|
.signatureVerificationFailed:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// `URLError` codes that indicate the device has no working connection to any host.
|
|
private static let deviceConnectivityURLErrorCodes: Set<Int> = [
|
|
NSURLErrorNotConnectedToInternet,
|
|
NSURLErrorNetworkConnectionLost,
|
|
NSURLErrorInternationalRoamingOff,
|
|
NSURLErrorCallIsActive,
|
|
NSURLErrorDataNotAllowed
|
|
]
|
|
|
|
/// A request may be retried against a fallback host only for transient failures (connection-level
|
|
/// errors and 5xx) that point at the host rather than the device. A device-connectivity failure
|
|
/// (see ``NetworkError/isDeviceConnectivityError``) can't be fixed by switching hosts, so it is
|
|
/// excluded. See ``NetworkError/isTransient``.
|
|
var isAllowedToRetryWithFallbackHost: Bool {
|
|
return self.isTransient && !self.isDeviceConnectivityError
|
|
}
|
|
|
|
}
|
|
|
|
extension NetworkError {
|
|
|
|
typealias Source = ErrorSource
|
|
|
|
}
|