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:
authored and GitHub committed 2026-08-03 14:51:50 +02:00
1 parent 47ee54a127
commit e7384b8282
19 files changed
+1186 -349

No files matched your search

+46 -23
View File
@@ -34,7 +34,7 @@ class HTTPClient {
private let dateProvider: DateProvider
private let retriableStatusCodes: Set<HTTPStatusCode>
private let operationDispatcher: OperationDispatcher
private let requestTimeoutManager: HTTPRequestTimeoutManagerType
let requestTimeoutManager: HTTPRequestTimeoutManagerType
private let apiSourceFailover: APISourceFailoverType?
private let retryBackoffIntervals: [TimeInterval] = [
@@ -53,16 +53,16 @@ class HTTPClient {
diagnosticsTracker: DiagnosticsTrackerType?,
dnsChecker: DNSCheckerType.Type = DNSChecker.self,
retriableStatusCodes: Set<HTTPStatusCode> = Set([.tooManyRequests]),
requestTimeout: TimeInterval = Configuration.networkTimeoutDefault,
networkTimeout: NetworkTimeout,
dateProvider: DateProvider = DateProvider(),
operationDispatcher: OperationDispatcher,
apiSourceFailover: APISourceFailoverType?,
timeoutManager: HTTPRequestTimeoutManagerType? = nil
timeoutManager: HTTPRequestTimeoutManagerType
) {
let config = URLSessionConfiguration.ephemeral
config.httpMaximumConnectionsPerHost = 1
config.timeoutIntervalForRequest = requestTimeout
config.timeoutIntervalForResource = requestTimeout
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(),
@@ -73,15 +73,12 @@ class HTTPClient {
self.diagnosticsTracker = diagnosticsTracker
self.dnsChecker = dnsChecker
self.retriableStatusCodes = retriableStatusCodes
self.timeout = requestTimeout
self.timeout = networkTimeout.timeoutInterval
self.authHeaders = HTTPClient.authorizationHeader(withAPIKey: systemInfo.apiKey)
self.dateProvider = dateProvider
self.operationDispatcher = operationDispatcher
self.apiSourceFailover = apiSourceFailover
self.requestTimeoutManager = timeoutManager ?? HTTPRequestTimeoutManager(
defaultTimeout: timeout,
dateProvider: dateProvider
)
self.requestTimeoutManager = timeoutManager
}
/// - Parameter verificationMode: if `nil`, this will default to `SystemInfo.responseVerificationMode`
@@ -309,6 +306,13 @@ internal extension HTTPClient {
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
@@ -563,6 +567,14 @@ private extension HTTPClient {
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
@@ -580,11 +592,6 @@ private extension HTTPClient {
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,
@@ -602,11 +609,14 @@ private extension HTTPClient {
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 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
@@ -618,6 +628,10 @@ private extension HTTPClient {
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,
@@ -627,6 +641,7 @@ private extension HTTPClient {
response: .failure(error),
retryScheduled: retryScheduled,
requestTimeoutResult: requestTimeoutResult,
recordRequestTimeoutResult: false,
urlRequest: urlRequest,
requestStartTime: requestStartTime)
}
@@ -698,13 +713,16 @@ private extension HTTPClient {
response: VerifiedHTTPResponse<Data>.Result?,
retryScheduled: Bool,
requestTimeoutResult: HTTPRequestTimeoutManager.RequestResult,
recordRequestTimeoutResult: Bool = true,
urlRequest: URLRequest,
requestStartTime: Date) {
if !retryScheduled, let response = response {
request.completionHandler?(response)
}
self.requestTimeoutManager.recordRequestResult(requestTimeoutResult)
if recordRequestTimeoutResult {
self.requestTimeoutManager.recordRequestResult(host: urlRequest.url?.host, requestTimeoutResult)
}
self.trackHttpRequestPerformedIfNeeded(request: request,
host: urlRequest.url?.host,
@@ -769,8 +787,13 @@ private extension HTTPClient {
#endif
finalURLRequest.timeoutInterval = requestTimeoutManager.timeout(
isFallback: request.isFallbackURLRequest,
fallbackAvailable: request.httpRequest.path.supportsFallbackURLs && SystemInfo.proxyURL == nil
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