Add NativeConnectivity, Backoff and the offline banner
K2 of the offline-kit spec (§3.2, §3.4 Backoff). Stream loops slept a fixed 2 s whatever happened: a restored network waited out the sleep, and an offline phone kept retrying on a timer. - Backoff (RastrilloNative): callscore's numbers, 0.5 s doubling to 10 s with equal jitter, Retry-After as a floor capped at 5 minutes, reset on success and after a stream that lived longer than 5 s. - NativeConnectivity (RastrilloApple, @MainActor @Observable, shared per process): the NWPathMonitor path behind an injectable NativePathSource, serverReachable per origin from NativeHTTP's outcome observer (reset on a path change), isOffline(origin), and changes on a path change, a server becoming reachable, or foreground. wait(upTo:) returns on the delay or a change, and runs no timer while the path is down. - NativeConnection wires every client it makes to its connectivity. - NativeOfflineBanner(origin:): a thin bar while offline, announced on entry. Its default copy claims nothing about saving, since edits are not durable until K3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+420
−4
README.md+17 −0Sources/RastrilloApple/NativeConnection.swift+9 −4Sources/RastrilloApple/NativeConnectivity.swift+148 −0Sources/RastrilloApple/NativeOfflineBanner.swift+33 −0Sources/RastrilloNative/Backoff.swift+42 −0Tests/RastrilloAppleTests/ConnectivityTests.swift+135 −0Tests/RastrilloNativeTests/BackoffTests.swift+36 −0
CI failed — run details
diff --git a/README.md b/README.md| index 00ab329..a8ecd69 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -108,6 +108,23 @@ from a script: a status and body, offline, a hang until cancelled, or "commit, |
| then lose the response". Scripted streams are framed by the same byte-level parser |
| as `NativeHTTP`. Use it in test targets in place of fixture servers. |
| +### Connectivity and backoff (in-progress primitive) |
| + |
| +`NativeConnectivity.shared` (one per process) combines `NWPathMonitor`'s path |
| +with what real traffic proves: every client `NativeConnection` makes reports its |
| +responses and offline-class failures, so `serverReachable[origin]` is learned |
| +without a probe. `isOffline(origin)` is true while the path is down, or while it |
| +is up and that server just failed to answer. `changes` fires on a path change, |
| +on a server becoming reachable and on return to the foreground. |
| + |
| +Loops that retry call `await connectivity.wait(upTo: backoff.next())`. It returns |
| +when the delay elapses or connectivity changes, and while the path is down it |
| +runs no timer at all. `Backoff` (in `RastrilloNative`) is 0.5 s doubling to 10 s |
| +with equal jitter, honours Retry-After up to 5 minutes, and for a stream resets |
| +only after a connection that lived longer than 5 s. `NativeOfflineBanner(origin:)` |
| +is a thin bar shown while that origin is offline; pass durable wording only once |
| +edits survive a relaunch. |
| + |
| `tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources` |
| and reads the minimum macOS version from the package manifest. A SwiftPM |
| consumer with resources should first locate its resource bundle with |
diff --git a/Sources/RastrilloApple/NativeConnection.swift b/Sources/RastrilloApple/NativeConnection.swift| index dfc7ce0..76ee1ec 100644 |
| --- a/Sources/RastrilloApple/NativeConnection.swift |
| +++ b/Sources/RastrilloApple/NativeConnection.swift |
| @@ -52,7 +52,7 @@ import Security |
| serverAddress = account.origin.absoluteString |
| activeAccountID = account.id |
| defaults.set(account.id, forKey: "native.activeAccount") |
| - client = NativeHTTP(origin: account.origin, credential: credential) |
| + client = NativeHTTP(origin: account.origin, credential: credential, observer: connectivity.observer(for: account.origin)) |
| } |
| /// Adopts a session returned by a consumer's proof-bound sign-in protocol. |
| @@ -71,7 +71,7 @@ import Security |
| defaults.set(account.id, forKey: "native.activeAccount") |
| defaults.set(origin.absoluteString, forKey: "native.server") |
| serverAddress = origin.absoluteString |
| - client = NativeHTTP(origin: origin, credential: credential) |
| + client = NativeHTTP(origin: origin, credential: credential, observer: connectivity.observer(for: origin)) |
| } |
| public func handleOpenURL(_ url: URL) { |
| @@ -96,7 +96,12 @@ import Security |
| } |
| } |
| - public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard) { |
| + /// Every client this connection makes reports its outcomes here. |
| + public let connectivity: NativeConnectivity |
| + |
| + public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard, |
| + connectivity: NativeConnectivity? = nil) { |
| + self.connectivity = connectivity ?? .shared |
| self.defaults = defaults |
| self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")" |
| serverAddress = defaults.string(forKey: "native.server") ?? defaultServer |
| @@ -124,7 +129,7 @@ import Security |
| task = Task { |
| do { |
| let origin = try NativeHTTP.origin(serverAddress) |
| - let http = NativeHTTP(origin: origin) |
| + let http = NativeHTTP(origin: origin, observer: connectivity.observer(for: origin)) |
| var random = [UInt8](repeating: 0, count: 32) |
| guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else { |
| throw NativeClientError.message("Could not start secure sign-in.") |
diff --git a/Sources/RastrilloApple/NativeConnectivity.swift b/Sources/RastrilloApple/NativeConnectivity.swift| new file mode 100644 |
| index 0000000..ce71828 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeConnectivity.swift |
| @@ -0,0 +1,148 @@ |
| +import Foundation |
| +import Network |
| +import Observation |
| +#if canImport(UIKit) |
| +import UIKit |
| +#elseif canImport(AppKit) |
| +import AppKit |
| +#endif |
| + |
| +/// The network path as `NWPathMonitor` reports it. |
| +public struct NativePath: Sendable, Equatable { |
| + public enum Status: Sendable, Equatable { case satisfied, unsatisfied, requiresConnection } |
| + public var status: Status |
| + public var isExpensive: Bool |
| + public var isConstrained: Bool |
| + public init(status: Status, isExpensive: Bool = false, isConstrained: Bool = false) { |
| + self.status = status; self.isExpensive = isExpensive; self.isConstrained = isConstrained |
| + } |
| +} |
| + |
| +/// Where path updates come from: `NativeNetworkPathSource` in apps, a fake in tests. |
| +public protocol NativePathSource: Sendable { |
| + func start(_ update: @escaping @Sendable (NativePath) -> Void) |
| + func cancel() |
| +} |
| + |
| +public final class NativeNetworkPathSource: NativePathSource, @unchecked Sendable { |
| + private let monitor = NWPathMonitor() |
| + private let queue = DispatchQueue(label: "net.amadan.rastrillo.native.path") |
| + public init() {} |
| + public func start(_ update: @escaping @Sendable (NativePath) -> Void) { |
| + monitor.pathUpdateHandler = { path in |
| + let status: NativePath.Status |
| + switch path.status { |
| + case .satisfied: status = .satisfied |
| + case .requiresConnection: status = .requiresConnection |
| + default: status = .unsatisfied |
| + } |
| + update(NativePath(status: status, isExpensive: path.isExpensive, isConstrained: path.isConstrained)) |
| + } |
| + monitor.start(queue: queue) |
| + } |
| + public func cancel() { monitor.cancel() } |
| +} |
| + |
| +/// Whether this device can reach a server, learned without probing: the path |
| +/// from `NWPathMonitor`, and reachability per origin from real traffic |
| +/// (`NativeHTTP`'s outcome observer). The Docs and Sheets streams are the |
| +/// heartbeat; other apps learn on their next request. Nothing pings. |
| +/// |
| +/// Loops that retry wait on `wait(upTo:)`, which returns when the delay |
| +/// elapses or connectivity changes, whichever is first, and never runs a |
| +/// timer while the path is down: a restored network is acted on at once, and |
| +/// an offline phone makes no timed attempts. |
| +@MainActor @Observable public final class NativeConnectivity { |
| + /// One per process. Starts `NWPathMonitor` on first use. |
| + public static let shared = NativeConnectivity(source: NativeNetworkPathSource()) |
| + |
| + /// nil until the first update; unknown is treated as satisfied, so a |
| + /// slow first update never blocks a request that would have worked. |
| + public private(set) var path: NativePath? |
| + /// true after a response from that origin, false after an offline-class |
| + /// failure, absent (unknown) after a path change. |
| + public private(set) var serverReachable: [URL: Bool] = [:] |
| + |
| + @ObservationIgnored private let source: NativePathSource |
| + @ObservationIgnored private var subscribers: [UUID: AsyncStream<Void>.Continuation] = [:] |
| + @ObservationIgnored private var foregroundObserver: NSObjectProtocol? |
| + |
| + public init(source: NativePathSource) { |
| + self.source = source |
| + source.start { [weak self] path in Task { @MainActor in self?.pathChanged(path) } } |
| + #if canImport(UIKit) |
| + let name = UIApplication.didBecomeActiveNotification |
| + #elseif canImport(AppKit) |
| + let name = NSApplication.didBecomeActiveNotification |
| + #endif |
| + foregroundObserver = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in |
| + Task { @MainActor in self?.foregrounded() } |
| + } |
| + } |
| + |
| + deinit { |
| + source.cancel() |
| + if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } |
| + } |
| + |
| + public var isPathSatisfied: Bool { path.map { $0.status == .satisfied } ?? true } |
| + |
| + /// The path is down, or it is up and that server was just unreachable. |
| + public func isOffline(_ origin: URL) -> Bool { !isPathSatisfied || serverReachable[origin] == false } |
| + |
| + /// Fires on a path change, on a server becoming reachable, and on return |
| + /// to the foreground. Each access is a new subscription. |
| + public var changes: AsyncStream<Void> { |
| + let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) |
| + let id = UUID() |
| + subscribers[id] = continuation |
| + continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.subscribers[id] = nil } } |
| + return stream |
| + } |
| + |
| + public func record(_ outcome: NativeTransportOutcome, origin: URL) { |
| + switch outcome { |
| + case .responded: |
| + let was = serverReachable[origin] |
| + serverReachable[origin] = true |
| + // A stream that opens must wake whatever is waiting to send (M2). |
| + if was != true { notify() } |
| + case .offline: |
| + serverReachable[origin] = false |
| + } |
| + } |
| + |
| + /// For `NativeHTTP(origin:credential:observer:)`. |
| + public nonisolated func observer(for origin: URL) -> @Sendable (NativeTransportOutcome) -> Void { |
| + { [weak self] outcome in Task { @MainActor in self?.record(outcome, origin: origin) } } |
| + } |
| + |
| + public func foregrounded() { notify() } |
| + |
| + /// Returns after `delay` or on the next change, whichever is first. While |
| + /// the path is down it waits for the change only. Returns at once if the |
| + /// task is cancelled. |
| + public func wait(upTo delay: TimeInterval) async { |
| + let changed = changes |
| + if !isPathSatisfied { |
| + for await _ in changed { break } |
| + return |
| + } |
| + await withTaskGroup(of: Void.self) { group in |
| + group.addTask { try? await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) } |
| + group.addTask { for await _ in changed { break } } |
| + await group.next() |
| + group.cancelAll() |
| + } |
| + } |
| + |
| + func pathChanged(_ new: NativePath) { |
| + guard new != path else { return } |
| + path = new |
| + // What was learned about servers belonged to the old path. |
| + serverReachable = [:] |
| + notify() |
| + } |
| + |
| + private func notify() { for continuation in subscribers.values { continuation.yield() } } |
| +} |
diff --git a/Sources/RastrilloApple/NativeOfflineBanner.swift b/Sources/RastrilloApple/NativeOfflineBanner.swift| new file mode 100644 |
| index 0000000..e4bb586 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeOfflineBanner.swift |
| @@ -0,0 +1,33 @@ |
| +import SwiftUI |
| + |
| +/// A thin inset bar shown while `origin` is offline. It says only what is |
| +/// true without a durable outbox: pass the durable wording ("Changes are saved |
| +/// on this device…") once the app's edits survive a relaunch. Entering the |
| +/// offline state is announced to assistive technologies; leaving it is not. |
| +public struct NativeOfflineBanner: View { |
| + private let connectivity: NativeConnectivity |
| + private let origin: URL |
| + private let message: String |
| + |
| + @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, |
| + message: String = "You’re offline. Waiting for a connection.") { |
| + self.connectivity = connectivity ?? .shared; self.origin = origin; self.message = message |
| + } |
| + |
| + public var body: some View { |
| + let offline = connectivity.isOffline(origin) |
| + Group { |
| + if offline { |
| + Label(message, systemImage: "wifi.slash") |
| + .font(.callout) |
| + .frame(maxWidth: .infinity) |
| + .padding(.horizontal, 12).padding(.vertical, 6) |
| + .background(.bar) |
| + .accessibilityElement(children: .combine) |
| + } |
| + } |
| + .onChange(of: offline) { _, now in |
| + if now { AccessibilityNotification.Announcement(message).post() } |
| + } |
| + } |
| +} |
diff --git a/Sources/RastrilloNative/Backoff.swift b/Sources/RastrilloNative/Backoff.swift| new file mode 100644 |
| index 0000000..65423bb |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/Backoff.swift |
| @@ -0,0 +1,42 @@ |
| +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>) -> Double |
| + |
| + public init(random: @escaping @Sendable (ClosedRange<Double>) -> 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 } |
| + |
| + /// For a stream: resets only if it stayed open long enough to have worked. |
| + public mutating func connectionEnded(after lived: TimeInterval) { |
| + if lived > Self.stableConnection { reset() } |
| + } |
| +} |
diff --git a/Tests/RastrilloAppleTests/ConnectivityTests.swift b/Tests/RastrilloAppleTests/ConnectivityTests.swift| new file mode 100644 |
| index 0000000..d9373cd |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/ConnectivityTests.swift |
| @@ -0,0 +1,135 @@ |
| +import Foundation |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class FakePathSource: NativePathSource, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var update: (@Sendable (NativePath) -> Void)? |
| + private(set) var cancelled = false |
| + func start(_ update: @escaping @Sendable (NativePath) -> Void) { lock.withLock { self.update = update } } |
| + func cancel() { lock.withLock { cancelled = true } } |
| + func send(_ status: NativePath.Status) { lock.withLock { update }?(NativePath(status: status)) } |
| +} |
| + |
| +@MainActor private final class Counter { |
| + var count = 0 |
| + var task: Task<Void, Never>? |
| + init(_ connectivity: NativeConnectivity) { |
| + let changes = connectivity.changes |
| + task = Task { @MainActor [weak self] in for await _ in changes { self?.count += 1 } } |
| + } |
| +} |
| + |
| +@MainActor private func settle(until condition: () -> Bool, seconds: Double = 2) async { |
| + let deadline = Date().addingTimeInterval(seconds) |
| + while !condition() && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) } |
| +} |
| + |
| +@MainActor final class ConnectivityTests: XCTestCase { |
| + let origin = URL(string: "https://docs.example")! |
| + let other = URL(string: "https://sheets.example")! |
| + |
| + func testOfflineFromThePathOrFromAFailedRequest() async { |
| + let source = FakePathSource() |
| + let connectivity = NativeConnectivity(source: source) |
| + XCTAssertFalse(connectivity.isOffline(origin), "An unknown path must not block requests") |
| + source.send(.unsatisfied) |
| + await settle { connectivity.path?.status == .unsatisfied } |
| + XCTAssertTrue(connectivity.isOffline(origin)) |
| + XCTAssertTrue(connectivity.isOffline(other)) |
| + source.send(.satisfied) |
| + await settle { connectivity.isPathSatisfied } |
| + XCTAssertFalse(connectivity.isOffline(origin)) |
| + |
| + connectivity.record(.offline(.cannotConnectToHost), origin: origin) |
| + XCTAssertTrue(connectivity.isOffline(origin), "The path is up but that server did not answer") |
| + XCTAssertFalse(connectivity.isOffline(other), "One unreachable server says nothing about another") |
| + connectivity.record(.responded(status: 503), origin: origin) |
| + XCTAssertFalse(connectivity.isOffline(origin), "Any response proves the server is reachable") |
| + |
| + connectivity.record(.offline(.timedOut), origin: origin) |
| + source.send(.requiresConnection) |
| + await settle { connectivity.path?.status == .requiresConnection } |
| + source.send(.satisfied) |
| + await settle { connectivity.isPathSatisfied } |
| + XCTAssertNil(connectivity.serverReachable[origin], "A path change must forget what the old path learned") |
| + } |
| + |
| + func testChangesFireOnPathReachabilityAndForeground() async { |
| + let source = FakePathSource() |
| + let connectivity = NativeConnectivity(source: source) |
| + let counter = Counter(connectivity) |
| + defer { counter.task?.cancel() } |
| + source.send(.satisfied) |
| + await settle { counter.count == 1 } |
| + XCTAssertEqual(counter.count, 1, "A path change must fire") |
| + connectivity.record(.responded(status: 200), origin: origin) |
| + await settle { counter.count == 2 } |
| + XCTAssertEqual(counter.count, 2, "A server becoming reachable must wake waiting senders") |
| + connectivity.record(.responded(status: 200), origin: origin) |
| + connectivity.record(.offline(.timedOut), origin: origin) |
| + try? await Task.sleep(nanoseconds: 50_000_000) |
| + XCTAssertEqual(counter.count, 2, "Repeated responses and failures are not changes") |
| + connectivity.foregrounded() |
| + await settle { counter.count == 3 } |
| + XCTAssertEqual(counter.count, 3) |
| + } |
| + |
| + func testWaitEndsEarlyWhenConnectivityChanges() async { |
| + let source = FakePathSource() |
| + let connectivity = NativeConnectivity(source: source) |
| + source.send(.satisfied) |
| + await settle { connectivity.path != nil } |
| + let start = Date() |
| + let waiting = Task { await connectivity.wait(upTo: 30) } |
| + try? await Task.sleep(nanoseconds: 50_000_000) |
| + connectivity.foregrounded() |
| + await waiting.value |
| + XCTAssertLessThan(Date().timeIntervalSince(start), 2, "A change must cut the backoff short") |
| + |
| + let timed = Date() |
| + await connectivity.wait(upTo: 0.05) |
| + XCTAssertLessThan(Date().timeIntervalSince(timed), 1, "The delay must elapse on its own") |
| + } |
| + |
| + /// The battery rule: while the path is down, no timer runs at all. |
| + func testNoTimedAttemptsWhileThePathIsDown() async { |
| + let source = FakePathSource() |
| + let connectivity = NativeConnectivity(source: source) |
| + source.send(.unsatisfied) |
| + await settle { connectivity.path != nil } |
| + var returned = false |
| + let waiting = Task { @MainActor in await connectivity.wait(upTo: 0.01); returned = true } |
| + try? await Task.sleep(nanoseconds: 300_000_000) |
| + XCTAssertFalse(returned, "A backoff delay elapsed while the path was down") |
| + source.send(.satisfied) |
| + await settle { returned } |
| + XCTAssertTrue(returned, "A restored path must end the wait at once") |
| + waiting.cancel() |
| + } |
| + |
| + func testNativeHTTPReportsThroughTheObserver() async throws { |
| + let fd = socket(AF_INET, SOCK_STREAM, 0) |
| + var address = sockaddr_in() |
| + address.sin_family = sa_family_t(AF_INET) |
| + address.sin_addr.s_addr = inet_addr("127.0.0.1") |
| + var length = socklen_t(MemoryLayout<sockaddr_in>.size) |
| + _ = withUnsafeMutablePointer(to: &address) { |
| + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, length) == 0 ? getsockname(fd, $0, &length) : -1 } |
| + } |
| + close(fd) |
| + let closed = URL(string: "http://127.0.0.1:\(UInt16(bigEndian: address.sin_port))")! |
| + let connectivity = NativeConnectivity(source: FakePathSource()) |
| + let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed)) |
| + _ = try? await client.responseDetails("/") |
| + await settle { connectivity.serverReachable[closed] != nil } |
| + XCTAssertEqual(connectivity.serverReachable[closed], false) |
| + XCTAssertTrue(connectivity.isOffline(closed)) |
| + } |
| + |
| + func testNetworkPathSourceDeliversAPath() async { |
| + let connectivity = NativeConnectivity(source: NativeNetworkPathSource()) |
| + await settle(until: { connectivity.path != nil }, seconds: 5) |
| + XCTAssertNotNil(connectivity.path, "NWPathMonitor never reported a path") |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/BackoffTests.swift b/Tests/RastrilloNativeTests/BackoffTests.swift| new file mode 100644 |
| index 0000000..76345f3 |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/BackoffTests.swift |
| @@ -0,0 +1,36 @@ |
| +import Foundation |
| +import XCTest |
| +import RastrilloNative |
| + |
| +final class BackoffTests: XCTestCase { |
| + func testDoublesFromHalfASecondToTenWithEqualJitter() { |
| + var high = Backoff(random: { $0.upperBound }) |
| + XCTAssertEqual((0..<7).map { _ in high.next() }, [0.5, 1, 2, 4, 8, 10, 10]) |
| + var low = Backoff(random: { $0.lowerBound }) |
| + XCTAssertEqual((0..<6).map { _ in low.next() }, [0.25, 0.5, 1, 2, 4, 5]) |
| + var real = Backoff() |
| + for expected in [0.5, 1.0, 2.0, 4.0] { |
| + let delay = real.next() |
| + XCTAssertTrue((expected / 2...expected).contains(delay), "\(delay) outside [\(expected / 2), \(expected)]") |
| + } |
| + } |
| + |
| + func testRetryAfterIsAFloorCappedAtFiveMinutes() { |
| + var backoff = Backoff(random: { $0.upperBound }) |
| + XCTAssertEqual(backoff.next(retryAfter: 30), 30) |
| + XCTAssertEqual(backoff.next(retryAfter: 3600), 300) |
| + XCTAssertEqual(backoff.next(retryAfter: 0.1), 2, "A short Retry-After must not undercut the backoff") |
| + } |
| + |
| + func testResetsOnSuccessAndAfterAStableConnectionOnly() { |
| + var backoff = Backoff(random: { $0.upperBound }) |
| + _ = backoff.next(); _ = backoff.next(); _ = backoff.next() |
| + backoff.connectionEnded(after: 3) |
| + XCTAssertEqual(backoff.next(), 4, "A connection that dropped quickly must keep backing off") |
| + backoff.connectionEnded(after: 6) |
| + XCTAssertEqual(backoff.next(), 0.5) |
| + _ = backoff.next() |
| + backoff.reset() |
| + XCTAssertEqual(backoff.next(), 0.5) |
| + } |
| +} |