| 1 | import Foundation |
| 2 | import RastrilloNative |
| 3 | |
| 4 | /// The pause between attempts of a long-lived stream, so every app gets the |
| 5 | /// same rules: |
| 6 | /// - a server's Retry-After is a deadline that nothing crosses, including a |
| 7 | /// connectivity change or a return to the foreground; |
| 8 | /// - otherwise it waits out `Backoff`, cut short (and reset) by a |
| 9 | /// connectivity change, with no timer at all while the path is down; |
| 10 | /// - the backoff resets after a stream that stayed open longer than 5 s, |
| 11 | /// timed from `opened()`, never from the start of the attempt. |
| 12 | /// |
| 13 | /// Call `opened()` from the stream's `onOpen`, `failed(_:)` with the error |
| 14 | /// that ended an attempt, and `pause()` before the next attempt. |
| 15 | @MainActor public final class NativeReconnect { |
| 16 | private let connectivity: NativeConnectivity |
| 17 | private var backoff: Backoff |
| 18 | private let now: @MainActor () -> TimeInterval |
| 19 | private var notBefore: TimeInterval? |
| 20 | |
| 21 | public init(connectivity: NativeConnectivity, backoff: Backoff = Backoff(), |
| 22 | now: @escaping @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { |
| 23 | self.connectivity = connectivity |
| 24 | self.backoff = backoff |
| 25 | self.now = now |
| 26 | } |
| 27 | |
| 28 | /// The stream was accepted. |
| 29 | public func opened() { backoff.opened(at: now()) } |
| 30 | |
| 31 | /// Records a server's Retry-After as a deadline, capped at 5 minutes. |
| 32 | public func failed(_ error: Error) { |
| 33 | guard case NativeClientError.rateLimited(let after?) = error, after > 0 else { return } |
| 34 | let deadline = now() + min(after, Backoff.retryAfterCap) |
| 35 | notBefore = max(notBefore ?? 0, deadline) |
| 36 | } |
| 37 | |
| 38 | /// Waits before the next attempt. Returns the backoff delay chosen, for tests. |
| 39 | @discardableResult |
| 40 | public func pause() async -> TimeInterval { |
| 41 | let start = now() |
| 42 | backoff.ended(at: start) |
| 43 | let delay = backoff.next() |
| 44 | var remaining = delay |
| 45 | if let deadline = notBefore, deadline > start { |
| 46 | // Only the server's deadline is uninterruptible; cancellation still ends it. |
| 47 | try? await Task.sleep(nanoseconds: UInt64((deadline - start) * 1_000_000_000)) |
| 48 | remaining -= now() - start |
| 49 | } |
| 50 | notBefore = nil |
| 51 | if Task.isCancelled { return delay } |
| 52 | if remaining > 0 || !connectivity.isPathSatisfied { |
| 53 | if await connectivity.wait(upTo: max(0, remaining)) { backoff.reset() } |
| 54 | } |
| 55 | return delay |
| 56 | } |
| 57 | } |
| 58 | |