import Foundation import RastrilloNative /// The pause between attempts of a long-lived stream, so every app gets the /// same rules: /// - a server's Retry-After is a deadline that nothing crosses, including a /// connectivity change or a return to the foreground; /// - otherwise it waits out `Backoff`, cut short (and reset) by a /// connectivity change, with no timer at all while the path is down; /// - the backoff resets after a stream that stayed open longer than 5 s, /// timed from `opened()`, never from the start of the attempt. /// /// Call `opened()` from the stream's `onOpen`, `failed(_:)` with the error /// that ended an attempt, and `pause()` before the next attempt. @MainActor public final class NativeReconnect { private let connectivity: NativeConnectivity private var backoff: Backoff private let now: @MainActor () -> TimeInterval private var notBefore: TimeInterval? public init(connectivity: NativeConnectivity, backoff: Backoff = Backoff(), now: @escaping @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { self.connectivity = connectivity self.backoff = backoff self.now = now } /// The stream was accepted. public func opened() { backoff.opened(at: now()) } /// Records a server's Retry-After as a deadline, capped at 5 minutes. public func failed(_ error: Error) { guard case NativeClientError.rateLimited(let after?) = error, after > 0 else { return } let deadline = now() + min(after, Backoff.retryAfterCap) notBefore = max(notBefore ?? 0, deadline) } /// Waits before the next attempt. Returns the backoff delay chosen, for tests. @discardableResult public func pause() async -> TimeInterval { let start = now() backoff.ended(at: start) let delay = backoff.next() var remaining = delay if let deadline = notBefore, deadline > start { // Only the server's deadline is uninterruptible; cancellation still ends it. try? await Task.sleep(nanoseconds: UInt64((deadline - start) * 1_000_000_000)) remaining -= now() - start } notBefore = nil if Task.isCancelled { return delay } if remaining > 0 || !connectivity.isPathSatisfied { if await connectivity.wait(upTo: max(0, remaining)) { backoff.reset() } } return delay } }