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

141 lines
5.3 KiB
Swift
Raw Normal View History

//
// HTTPRequestTimeoutManager.swift
// RevenueCat
//
// Created by Rick van der Linden on 06/11/2025.
// Copyright © 2025 RevenueCat, Inc. All rights reserved.
//
import Foundation
protocol HTTPRequestTimeoutManagerType: AnyObject {
/// Determines the timeout to be used by an HTTP request attempt.
///
/// - Parameters:
/// - host: The resolved host string of the attempt, used to look up the per-host fail-fast memory.
/// - isFallbackHostRequest: Whether this attempt targets a fallback host.
/// - endpointSupportsFallbackURLs: Whether the endpoint has fallback-URL support.
/// - isProxied: Whether a proxy URL is set.
/// - Returns: The timeout interval in seconds.
func timeout(host: String?,
isFallbackHostRequest: Bool,
endpointSupportsFallbackURLs: Bool,
isProxied: Bool) -> TimeInterval
/// Updates the internal state in response to the result of an HTTP request attempt.
///
/// - Parameters:
/// - host: The resolved host string of the attempt.
/// - result: The result of the HTTP request.
func recordRequestResult(host: String?, _ result: HTTPRequestTimeoutManager.RequestResult)
}
class HTTPRequestTimeoutManager: HTTPRequestTimeoutManagerType {
enum RequestResult {
/// Request succeeded on the main source.
case successOnMainBackend
/// Request timed out on the main source.
case mainSourceTimedOut
/// Any other result (fallback-host request, non-timeout errors, etc.)
case other
}
/// Timeout tiers, in seconds.
enum Timeout {
/// Main-source request to an endpoint with no fallback-URL support.
static let mainSourceNoFallback: TimeInterval = 15
/// Main-source request to an endpoint with no fallback-URL support, when the source recently timed out.
static let mainSourceNoFallbackReduced: TimeInterval = 5
/// Main-source request to an endpoint with fallback-URL support.
static let mainSourceSupportingFallback: TimeInterval = 5
/// Main-source request to an endpoint with fallback-URL support, when the source recently timed out.
static let mainSourceSupportingFallbackReduced: TimeInterval = 2
/// Fallback-host and proxied requests (flat, never reduced).
static let flat: TimeInterval = 30
}
// The amount of time after which a per-host timeout entry expires.
private static let timeoutResetInterval: TimeInterval = 600 // 10 minutes
// The last time a timeout was recorded, keyed by resolved host string. Guarded by `lock`.
private var lastTimeoutByHost: [String: Date] = [:]
// When the developer sets a custom `networkTimeout`, it replaces the built-in base/flat tiers.
private let networkTimeout: NetworkTimeout
private let dateProvider: DateProvider
private let lock = Lock()
init(
networkTimeout: NetworkTimeout,
dateProvider: DateProvider = .init()
) {
self.networkTimeout = networkTimeout
self.dateProvider = dateProvider
}
func timeout(host: String?,
isFallbackHostRequest: Bool,
endpointSupportsFallbackURLs: Bool,
isProxied: Bool) -> TimeInterval {
// Fallback-host and proxied requests use a flat timeout and never consult the per-host memory.
guard !isFallbackHostRequest, !isProxied else {
return self.baseTimeout(default: Timeout.flat)
}
let sourceRecentlyTimedOut = host.map { self.hasRecentTimeout(forHost: $0) } ?? false
// Base tiers honor a custom `networkTimeout`; the reduced fail-fast tiers stay fixed.
switch (endpointSupportsFallbackURLs, sourceRecentlyTimedOut) {
case (true, false): return self.baseTimeout(default: Timeout.mainSourceSupportingFallback)
case (true, true): return Timeout.mainSourceSupportingFallbackReduced
case (false, false): return self.baseTimeout(default: Timeout.mainSourceNoFallback)
case (false, true): return Timeout.mainSourceNoFallbackReduced
}
}
/// The base/flat timeout to use: the developer's custom value when set, otherwise the built-in tier.
private func baseTimeout(default defaultValue: TimeInterval) -> TimeInterval {
switch self.networkTimeout {
case .default: return defaultValue
case let .custom(value): return value
}
}
func recordRequestResult(host: String?, _ result: RequestResult) {
guard let host else { return }
switch result {
case .successOnMainBackend:
self.lock.perform { _ = self.lastTimeoutByHost.removeValue(forKey: host) }
case .mainSourceTimedOut:
self.lock.perform { self.lastTimeoutByHost[host] = self.dateProvider.now() }
case .other:
break
}
}
/// Whether `host` has a non-expired timeout entry. Prunes the entry if it has expired.
private func hasRecentTimeout(forHost host: String) -> Bool {
return self.lock.perform {
guard let lastTimeout = self.lastTimeoutByHost[host] else { return false }
if self.dateProvider.now().timeIntervalSince(lastTimeout) >= Self.timeoutResetInterval {
self.lastTimeoutByHost.removeValue(forKey: host)
return false
}
return true
}
}
}