mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
Re-tier HTTP request timeouts around per-host memory (#7179)
* Re-tier HTTP request timeouts around per-source memory Replaces the single global "recent timeout" slot in `HTTPRequestTimeoutManager` with a per-host map (`host -> lastTimeoutDate`, 10-minute per-entry expiry, pruned on access) and re-tiers the request timeouts: - Main-source, endpoint with no fallback-URL support: 15s base / 5s reduced - Main-source, endpoint with fallback-URL support: 5s base / 2s reduced - Fallback-host and proxied requests: flat 30s (never consult the memory) A main-source request to host H uses the reduced tier iff H has a non-expired timeout entry. Timeouts are now recorded for any non-fallback request (dropping the previous fallback-support-only condition), keyed by attempt host; a successful main-source response clears only that host's entry. Renames the `timeoutOnMainBackendForFallbackSupportedEndpoint` result to `mainSourceTimedOut`. Co-authored-by: Cursor <cursoragent@cursor.com> * Define flat timeout tier as a Timeout constant Co-authored-by: Cursor <cursoragent@cursor.com> * Add exhaustive multi-host tests for timeout manager Co-authored-by: Cursor <cursoragent@cursor.com> * Honor developer networkTimeout via internal NetworkTimeout enum Introduce an internal NetworkTimeout enum (.default/.custom) threaded from Configuration down to HTTPRequestTimeoutManager. When a developer sets networkTimeout, it replaces the built-in base/flat tiers while the reduced fail-fast tiers stay fixed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add NetworkTimeout.swift to RevenueCat.xcodeproj The file was only picked up by the SPM/Tuist directory globs, so the hand-maintained Xcode project build failed to resolve the type. Co-authored-by: Cursor <cursoragent@cursor.com> * Gate re-tiered no-fallback timeouts behind usesRemoteConfigAPISources The aggressive no-fallback fail-fast timeouts and their per-host timeout recording now apply to main-API requests only when the usesRemoteConfigAPISources dangerous setting is enabled. With it disabled (the default) the legacy network timeout is preserved. The gating is a per-call timeout(...) parameter so blob-source downloads can opt into the re-tiered timeouts independently. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the legacy flat timeout when re-tiered timeouts are off, and share one manager The fallback-host and proxied tiers returned the new 30s flat timeout regardless of `usesRemoteConfigAPISources`, halving the legacy 60s (or custom) network timeout for proxy users and every fallback-host request. They now honor `reTieredTimeoutsEnabled` like the no-fallback tier does, so only the fallback-supported tiers behave the same either way. `Backend` also built one timeout manager per HTTPClient, so the main and remote-config lanes kept independent per-host memories despite talking to the same hosts. They now share one, and `HTTPClient` requires both the manager and the network timeout so this wiring can't be dropped silently. Co-authored-by: Cursor <cursoragent@cursor.com> * Clear a host's fail-fast memory as soon as it answers The memory exists to remember how quickly a host responds, so a non-error response is evidence the host recovered even when decoding or verifying it fails afterwards. Deciding off the raw status code instead of the parsed result keeps those failures from leaving a responsive host stuck on the reduced timeout tier. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the requestTimeoutManager in the fail-fast comment Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten comments on the Backend-shared failover and timeout manager Name the instances the comments describe so it's clear what is shared. Co-authored-by: Cursor <cursoragent@cursor.com> * Record API source timeouts before health checks * Test shared timeout state across backend lanes * Normalize URL timeout classification across platforms * Remove unused RemoteConfigAPI test hook * Skip HTTP stubs lane tests on watchOS * Treat the remote config fallback path as a fallback host request `FallbackPath` bakes the fallback host into its `serverHostURL`, but `fallbackUrlIndex` is nil on those requests, so `isFallbackURLRequest` was false and they were classified as main-source: with API sources enabled they drew the aggressive 15s/5s no-fallback tiers instead of the flat fallback timeout, their timeouts armed the per-host fail-fast memory for the fallback host, and their successes cleared the main source's entry. Add `isFallbackHostPath` so a path can declare that it targets a fallback host on its own, and use it alongside the index when picking the tier and updating the memory. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com>
This commit is contained in:
19 files changed
+1186
-349
No files matched your search
@@ -8,105 +8,159 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol HTTPRequestTimeoutManagerType {
|
||||
protocol HTTPRequestTimeoutManagerType: AnyObject {
|
||||
|
||||
/// Determines the timeout to be used by the HTTP Request for the given path.
|
||||
/// Determines the timeout to be used by an HTTP request attempt.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - isFallback: Whether this is a fallback request
|
||||
/// - fallbackAvailable: Whether fallback URLs are available for this request
|
||||
/// - Returns: The timeout interval in seconds
|
||||
func timeout(isFallback: Bool, fallbackAvailable: Bool) -> TimeInterval
|
||||
/// - 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.
|
||||
/// - reTieredTimeoutsEnabled: Whether this caller opts into the re-tiered timeouts. Main-API
|
||||
/// requests opt in only when remote-config API sources are enabled (so the default configuration
|
||||
/// keeps its legacy timeouts), while blob-source downloads always opt in. Callers that opt out get
|
||||
/// the legacy flat timeout for the no-fallback, fallback-host and proxied tiers; only the
|
||||
/// fallback-supported tiers are the same either way.
|
||||
/// This parameter should be removed once the `usesRemoteConfigAPISources` dangerous setting is
|
||||
/// removed and the re-tiered timeouts become the only behavior.
|
||||
/// - Returns: The timeout interval in seconds.
|
||||
func timeout(host: String?,
|
||||
isFallbackHostRequest: Bool,
|
||||
endpointSupportsFallbackURLs: Bool,
|
||||
isProxied: Bool,
|
||||
reTieredTimeoutsEnabled: Bool) -> TimeInterval
|
||||
|
||||
/// Updates the internal state in response to the result received from the backend.
|
||||
/// Updates the internal state in response to the result of an HTTP request attempt.
|
||||
///
|
||||
/// - Parameter result: The result of the HTTP request
|
||||
func recordRequestResult(_ result: HTTPRequestTimeoutManager.RequestResult)
|
||||
/// - 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 backend
|
||||
/// Request succeeded on the main source.
|
||||
case successOnMainBackend
|
||||
|
||||
/// Request timed out on the main backend endpoint and supports fallback URLs
|
||||
case timeoutOnMainBackendForFallbackSupportedEndpoint
|
||||
/// Request timed out on the main source.
|
||||
case mainSourceTimedOut
|
||||
|
||||
/// Any other result (non-main backend, non-timeout errors, etc.)
|
||||
/// Any other result (fallback-host request, non-timeout errors, etc.)
|
||||
case other
|
||||
}
|
||||
|
||||
enum Timeout: TimeInterval {
|
||||
/// Timeout tiers, in seconds.
|
||||
enum Timeout {
|
||||
|
||||
/// The default timeout for backend requests that support a fallback
|
||||
case mainBackendRequestSupportingFallback = 5
|
||||
/// Main-source request to an endpoint with no fallback-URL support.
|
||||
static let mainSourceNoFallback: TimeInterval = 15
|
||||
|
||||
/// The reduced timeout for requests with fallback support after timeout
|
||||
case reduced = 2
|
||||
/// 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). Callers that opt out of the
|
||||
/// re-tiered timeouts get `legacyFlatTimeout` for these instead.
|
||||
static let flat: TimeInterval = 30
|
||||
}
|
||||
|
||||
// The amount of time after which the 'last timeout request received' state can be reset
|
||||
// The amount of time after which a per-host timeout entry expires.
|
||||
private static let timeoutResetInterval: TimeInterval = 600 // 10 minutes
|
||||
|
||||
// The last time at which a timeout was received from the main backend
|
||||
private var lastTimeoutRequestTime: Date?
|
||||
// The last time a timeout was recorded, keyed by resolved host string. Guarded by `lock`.
|
||||
private var lastTimeoutByHost: [String: Date] = [:]
|
||||
|
||||
// The default timeout to use
|
||||
private let defaultTimeout: TimeInterval
|
||||
// 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(
|
||||
defaultTimeout: TimeInterval,
|
||||
networkTimeout: NetworkTimeout,
|
||||
dateProvider: DateProvider = .init()
|
||||
) {
|
||||
self.defaultTimeout = defaultTimeout
|
||||
self.networkTimeout = networkTimeout
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
func timeout(isFallback: Bool, fallbackAvailable: Bool) -> TimeInterval {
|
||||
if shouldResetTimeout {
|
||||
resetLastTimeoutRequestTime()
|
||||
func timeout(host: String?,
|
||||
isFallbackHostRequest: Bool,
|
||||
endpointSupportsFallbackURLs: Bool,
|
||||
isProxied: Bool,
|
||||
reTieredTimeoutsEnabled: Bool) -> TimeInterval {
|
||||
// Fallback-host and proxied requests use a flat timeout and never consult the per-host memory.
|
||||
guard !isFallbackHostRequest, !isProxied else {
|
||||
return reTieredTimeoutsEnabled ? self.baseTimeout(default: Timeout.flat) : self.legacyFlatTimeout
|
||||
}
|
||||
|
||||
let timeout: TimeInterval
|
||||
|
||||
if isFallback || !fallbackAvailable {
|
||||
timeout = self.defaultTimeout
|
||||
}
|
||||
// Main backend request that supports fallback when a timeout was previously received from the main backend
|
||||
else if lastTimeoutRequestTime != nil {
|
||||
timeout = Timeout.reduced.rawValue
|
||||
}
|
||||
// Main backend request that supports fallback, no timeout received recently
|
||||
else {
|
||||
timeout = Timeout.mainBackendRequestSupportingFallback.rawValue
|
||||
// The re-tiered no-fallback fail-fast timeouts only apply to callers that opt in. Otherwise the
|
||||
// no-fallback tiers fall back to the legacy flat network timeout so the default configuration's
|
||||
// behavior stays unchanged.
|
||||
guard reTieredTimeoutsEnabled || endpointSupportsFallbackURLs else {
|
||||
return self.legacyFlatTimeout
|
||||
}
|
||||
|
||||
return timeout
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func recordRequestResult(_ result: RequestResult) {
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// The single flat timeout used for every request kind other than the fallback-supported tiers before
|
||||
/// the re-tiered timeouts existed. Callers that opt out of the re-tiered timeouts keep getting it, so
|
||||
/// their behavior stays unchanged.
|
||||
/// This should be removed along with the `reTieredTimeoutsEnabled` parameter.
|
||||
private var legacyFlatTimeout: TimeInterval {
|
||||
return self.networkTimeout.timeoutInterval
|
||||
}
|
||||
|
||||
func recordRequestResult(host: String?, _ result: RequestResult) {
|
||||
guard let host else { return }
|
||||
|
||||
switch result {
|
||||
case .successOnMainBackend:
|
||||
resetLastTimeoutRequestTime()
|
||||
case .timeoutOnMainBackendForFallbackSupportedEndpoint:
|
||||
lastTimeoutRequestTime = dateProvider.now()
|
||||
self.lock.perform { _ = self.lastTimeoutByHost.removeValue(forKey: host) }
|
||||
case .mainSourceTimedOut:
|
||||
self.lock.perform { self.lastTimeoutByHost[host] = self.dateProvider.now() }
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func resetLastTimeoutRequestTime() {
|
||||
lastTimeoutRequestTime = nil
|
||||
}
|
||||
/// 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 }
|
||||
|
||||
private var shouldResetTimeout: Bool {
|
||||
guard let lastTimeoutRequestTime else { return false }
|
||||
if self.dateProvider.now().timeIntervalSince(lastTimeout) >= Self.timeoutResetInterval {
|
||||
self.lastTimeoutByHost.removeValue(forKey: host)
|
||||
return false
|
||||
}
|
||||
|
||||
let timeElapsed = dateProvider.now().timeIntervalSince(lastTimeoutRequestTime)
|
||||
return timeElapsed >= Self.timeoutResetInterval
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user