| 1 | import Foundation |
| 2 | |
| 3 | /// Retry delays for sends and stream reconnects: 0.5 s doubling to 10 s, each |
| 4 | /// with equal jitter (a random point in [d/2, d]), and Retry-After honoured up |
| 5 | /// to 5 minutes. These are callscore's numbers, so a suite of apps retrying |
| 6 | /// against one server spreads out instead of arriving in lockstep. |
| 7 | /// |
| 8 | /// A delay is only a timer. It must never run while the network path is down: |
| 9 | /// wait on `NativeConnectivity` instead, so an offline phone makes no timed |
| 10 | /// attempts at all. |
| 11 | public struct Backoff: Sendable { |
| 12 | public static let initial: TimeInterval = 0.5 |
| 13 | public static let cap: TimeInterval = 10 |
| 14 | public static let retryAfterCap: TimeInterval = 300 |
| 15 | /// A connection that lived longer than this was working; the next failure |
| 16 | /// starts again from `initial` rather than where the last outage left off. |
| 17 | public static let stableConnection: TimeInterval = 5 |
| 18 | |
| 19 | private var attempt = 0 |
| 20 | private let random: @Sendable (ClosedRange<Double>) -> Double |
| 21 | |
| 22 | public init(random: @escaping @Sendable (ClosedRange<Double>) -> Double = { Double.random(in: $0) }) { |
| 23 | self.random = random |
| 24 | } |
| 25 | |
| 26 | /// The delay before the next attempt. A server's Retry-After is a floor. |
| 27 | public mutating func next(retryAfter: TimeInterval? = nil) -> TimeInterval { |
| 28 | let delay = min(Self.cap, Self.initial * pow(2, Double(attempt))) |
| 29 | attempt = min(attempt + 1, 32) |
| 30 | let jittered = random((delay / 2)...delay) |
| 31 | guard let retryAfter, retryAfter > 0 else { return jittered } |
| 32 | return max(jittered, min(retryAfter, Self.retryAfterCap)) |
| 33 | } |
| 34 | |
| 35 | /// After a success, a path change or a return to the foreground. |
| 36 | public mutating func reset() { attempt = 0 } |
| 37 | |
| 38 | /// A stream was accepted: its `onOpen` ran. `at` is any monotonic time in |
| 39 | /// seconds, such as `ProcessInfo.processInfo.systemUptime`. |
| 40 | public mutating func opened(at time: TimeInterval) { openedAt = time } |
| 41 | |
| 42 | /// The attempt is over. Resets only if a stream opened and then stayed |
| 43 | /// open longer than `stableConnection`. A slow refusal, a connect that |
| 44 | /// timed out, or an access check after a failure never opened anything, |
| 45 | /// so however long it took it is not evidence the server was working. |
| 46 | public mutating func ended(at time: TimeInterval) { |
| 47 | if let openedAt, time - openedAt > Self.stableConnection { reset() } |
| 48 | openedAt = nil |
| 49 | } |
| 50 | |
| 51 | private var openedAt: TimeInterval? |
| 52 | } |
| 53 | |