Files
purchases-ios/Sources/Networking/HTTPClient/HTTPRequestTimeoutManager.swift
T
Rick b49a054843 Add client side timeout logic for endpoints that support fallback URLs (#5760)
* Implemented initial HTTPRequestTimeoutManager + added first couple of tests

* Cleaned up the code and added a bunch more unit tests based on the Android test suite

* Updated doc format

* Add new files to workspace

* Added HTTPClient tests testing interaction with timeout manager

* Swiftlint fixes

* Updated comment

* Add missing file to workspace

* Fix after rebase

* Added extra HTTPClient <-> HTTPRequestTimeoutManager tests asserting on the recorded events

* Implemented PR feedback

* Fixed compile issue

* Fix compile issue

* Implemented PR feedback

* Removed deleted file reference from workspace

* Fixed test after changing reset timeout

* Make sure the default HTTPRequistTimeoutManager is using the default value from the HTTPClient

* Add a sophisticated test testing the full timeout flow against an unroutable IP for a real world scenario

* Updated timeout case name

* Fixed case where .timeoutOnMainBackendForFallbackSupportedEndpoint was also recorded for non timeouts and added tests to prevent regression

* Fixed only stubbing the HTTP response based on path, since that could be the same across hosts, resulting in flaky tests
2025-11-26 11:16:04 +01:00

114 lines
3.6 KiB
Swift

//
// HTTPRequestTimeoutManager.swift
// RevenueCat
//
// Created by Rick van der Linden on 06/11/2025.
// Copyright © 2025 RevenueCat, Inc. All rights reserved.
//
import Foundation
protocol HTTPRequestTimeoutManagerType {
/// Determines the timeout to be used by the HTTP Request for the given path.
///
/// - Parameters:
/// - path: The HTTP request path for which to determine the timeout
/// - isFallback: Whether this is a fallback request
/// - Returns: The timeout interval in seconds
func timeout(for path: HTTPRequestPath, isFallback: Bool) -> TimeInterval
/// Updates the internal state in response to the result received from the backend.
///
/// - Parameter result: The result of the HTTP request
func recordRequestResult(_ result: HTTPRequestTimeoutManager.RequestResult)
}
class HTTPRequestTimeoutManager: HTTPRequestTimeoutManagerType {
enum RequestResult {
/// Request succeeded on the main backend
case successOnMainBackend
/// Request timed out on the main backend endpoint and supports fallback URLs
case timeoutOnMainBackendForFallbackSupportedEndpoint
/// Any other result (non-main backend, non-timeout errors, etc.)
case other
}
enum Timeout: TimeInterval {
/// The default timeout for backend requests that support a fallback
case mainBackendRequestSupportingFallback = 5
/// The reduced timeout for requests with fallback support after timeout
case reduced = 2
}
// The amount of time after which the 'last timeout request received' state can be reset
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 default timeout to use
private let defaultTimeout: TimeInterval
private let dateProvider: DateProvider
init(
defaultTimeout: TimeInterval,
dateProvider: DateProvider = .init()
) {
self.defaultTimeout = defaultTimeout
self.dateProvider = dateProvider
}
func timeout(for path: HTTPRequestPath, isFallback: Bool) -> TimeInterval {
if shouldResetTimeout {
resetLastTimeoutRequestTime()
}
let timeout: TimeInterval
// A fallback request or a request that doesn't support a fallback
if isFallback || !path.supportsFallbackURLs {
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
}
return timeout
}
func recordRequestResult(_ result: RequestResult) {
switch result {
case .successOnMainBackend:
resetLastTimeoutRequestTime()
case .timeoutOnMainBackendForFallbackSupportedEndpoint:
lastTimeoutRequestTime = dateProvider.now()
case .other:
break
}
}
private func resetLastTimeoutRequestTime() {
lastTimeoutRequestTime = nil
}
private var shouldResetTimeout: Bool {
guard let lastTimeoutRequestTime else { return false }
let timeElapsed = dateProvider.now().timeIntervalSince(lastTimeoutRequestTime)
return timeElapsed >= Self.timeoutResetInterval
}
}