import Foundation /// Retry delays for sends and stream reconnects: 0.5 s doubling to 10 s, each /// with equal jitter (a random point in [d/2, d]), and Retry-After honoured up /// to 5 minutes. These are callscore's numbers, so a suite of apps retrying /// against one server spreads out instead of arriving in lockstep. /// /// A delay is only a timer. It must never run while the network path is down: /// wait on `NativeConnectivity` instead, so an offline phone makes no timed /// attempts at all. public struct Backoff: Sendable { public static let initial: TimeInterval = 0.5 public static let cap: TimeInterval = 10 public static let retryAfterCap: TimeInterval = 300 /// A connection that lived longer than this was working; the next failure /// starts again from `initial` rather than where the last outage left off. public static let stableConnection: TimeInterval = 5 private var attempt = 0 private let random: @Sendable (ClosedRange) -> Double public init(random: @escaping @Sendable (ClosedRange) -> Double = { Double.random(in: $0) }) { self.random = random } /// The delay before the next attempt. A server's Retry-After is a floor. public mutating func next(retryAfter: TimeInterval? = nil) -> TimeInterval { let delay = min(Self.cap, Self.initial * pow(2, Double(attempt))) attempt = min(attempt + 1, 32) let jittered = random((delay / 2)...delay) guard let retryAfter, retryAfter > 0 else { return jittered } return max(jittered, min(retryAfter, Self.retryAfterCap)) } /// After a success, a path change or a return to the foreground. public mutating func reset() { attempt = 0 } /// A stream was accepted: its `onOpen` ran. `at` is any monotonic time in /// seconds, such as `ProcessInfo.processInfo.systemUptime`. public mutating func opened(at time: TimeInterval) { openedAt = time } /// The attempt is over. Resets only if a stream opened and then stayed /// open longer than `stableConnection`. A slow refusal, a connect that /// timed out, or an access check after a failure never opened anything, /// so however long it took it is not evidence the server was working. public mutating func ended(at time: TimeInterval) { if let openedAt, time - openedAt > Self.stableConnection { reset() } openedAt = nil } private var openedAt: TimeInterval? }