| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- //
- // OfflineRetrier.swift
- //
- // Copyright (c) 2025 Alamofire Software Foundation (http://alamofire.org/)
- //
- // Permission is hereby granted, free of charge, to any person obtaining a copy
- // of this software and associated documentation files (the "Software"), to deal
- // in the Software without restriction, including without limitation the rights
- // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- // copies of the Software, and to permit persons to whom the Software is
- // furnished to do so, subject to the following conditions:
- //
- // The above copyright notice and this permission notice shall be included in
- // all copies or substantial portions of the Software.
- //
- // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- // THE SOFTWARE.
- //
- #if canImport(Network)
- import Foundation
- import Network
- /// `RequestRetrier` which uses `NWPathMonitor` to detect when connectivity is restored to retry failed requests.
- @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
- public final class OfflineRetrier: RequestAdapter, RequestRetrier, RequestInterceptor, Sendable {
- /// Default amount of time to wait for connectivity to be restored before failure. `.seconds(5)` by default.
- public static let defaultWait: DispatchTimeInterval = .seconds(5)
- /// Default `Set<URLError.Code>` used to check for offline errors. `[.notConnectedToInternet]` by default.
- public static let defaultURLErrorOfflineCodes: Set<URLError.Code> = [
- .notConnectedToInternet
- ]
- /// Default method of detecting whether a particular `any Error` means connectivity is offline.
- public static let defaultIsOfflineError: @Sendable (_ error: any Error) -> Bool = { error in
- if let error = error.asAFError?.underlyingError {
- defaultIsOfflineError(error)
- } else if let error = error as? URLError {
- defaultURLErrorOfflineCodes.contains(error.code)
- } else {
- false
- }
- }
- private static let monitorQueue = DispatchQueue(label: "org.alamofire.offlineRetrier.monitorQueue")
- fileprivate struct State {
- let maximumWait: DispatchTimeInterval
- let isOfflineError: (_ error: any Error) -> Bool
- let monitorCreator: () -> PathMonitor
- var timeoutWorkItem: DispatchWorkItem?
- var currentMonitor: PathMonitor?
- var pendingCompletions: [@Sendable (_ retryResult: RetryResult) -> Void] = []
- }
- private let state: Protected<State>
- /// Creates an instance from the provided `NWPathMonitor`, maximum wait for connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `NWPathMonitor()` to use to detect connectivity. A new instance is created each time a
- /// request fails and retry may be needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
- /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- public init(monitor: @autoclosure @escaping () -> NWPathMonitor = NWPathMonitor(),
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
- state = Protected(State(maximumWait: maximumWait, isOfflineError: isOfflineError) { PathMonitor(monitor()) })
- }
- /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`, maximum wait for
- /// connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `NWInterface.InterfaceType` used to configured the `NWPathMonitor` each time one is needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
- /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- public convenience init(requiredInterfaceType: NWInterface.InterfaceType,
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
- self.init(monitor: NWPathMonitor(requiredInterfaceType: requiredInterfaceType), maximumWait: maximumWait, isOfflineError: isOfflineError)
- }
- /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`s, maximum wait for
- /// connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `[NWInterface.InterfaceType]` used to configured the `NWPathMonitor` each time one is needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
- /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *)
- public convenience init(prohibitedInterfaceTypes: [NWInterface.InterfaceType],
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
- self.init(monitor: NWPathMonitor(prohibitedInterfaceTypes: prohibitedInterfaceTypes), maximumWait: maximumWait, isOfflineError: isOfflineError)
- }
- init(monitor: @autoclosure @escaping () -> PathMonitor,
- maximumWait: DispatchTimeInterval,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
- state = Protected(State(maximumWait: maximumWait, isOfflineError: isOfflineError, monitorCreator: monitor))
- }
- deinit {
- state.write { state in
- state.cleanupMonitor()
- }
- }
- public func retry(_ request: Request,
- for session: Session,
- dueTo error: any Error,
- completion: @escaping @Sendable (RetryResult) -> Void) {
- state.write { state in
- guard state.isOfflineError(error) else { completion(.doNotRetry); return }
- state.pendingCompletions.append(completion)
- guard state.currentMonitor == nil else { return }
- state.startListening { [unowned self] result in
- let retryResult: RetryResult = switch result {
- case .pathAvailable:
- .retry
- case .timeout:
- // Do not retry, keep original error.
- .doNotRetry
- }
- performResult(retryResult)
- }
- }
- }
- private func performResult(_ result: RetryResult) {
- state.write { state in
- let completions = state.pendingCompletions
- state.cleanupMonitor()
- for completion in completions {
- Self.monitorQueue.async {
- completion(result)
- }
- }
- }
- }
- }
- @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
- extension OfflineRetrier.State {
- fileprivate mutating func startListening(onResult: @escaping @Sendable (_ result: PathMonitor.Result) -> Void) {
- let timeout = DispatchWorkItem {
- onResult(.timeout)
- }
- timeoutWorkItem = timeout
- OfflineRetrier.monitorQueue.asyncAfter(deadline: .now() + maximumWait, execute: timeout)
- currentMonitor = monitorCreator()
- currentMonitor?.startListening(on: OfflineRetrier.monitorQueue, onResult: onResult)
- }
- fileprivate mutating func cleanupMonitor() {
- pendingCompletions.removeAll()
- timeoutWorkItem?.cancel()
- timeoutWorkItem = nil
- currentMonitor?.stopListening()
- currentMonitor = nil
- }
- }
- @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
- extension RequestInterceptor where Self == OfflineRetrier {
- /// Creates an instance from the provided `NWPathMonitor`, maximum wait for connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `NWPathMonitor()` to use to detect connectivity. A new instance is created each time a
- /// request fails and retry may be needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
- /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- public static func offlineRetrier(
- monitor: @autoclosure @escaping () -> NWPathMonitor = NWPathMonitor(),
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
- ) -> OfflineRetrier {
- OfflineRetrier(monitor: monitor(), maximumWait: maximumWait, isOfflineError: isOfflineError)
- }
- /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`, maximum wait for
- /// connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `NWInterface.InterfaceType` used to configured the `NWPathMonitor` each time one is needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
- /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- public static func offlineRetrier(
- requiredInterfaceType: NWInterface.InterfaceType,
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
- ) -> OfflineRetrier {
- OfflineRetrier(requiredInterfaceType: requiredInterfaceType, maximumWait: maximumWait, isOfflineError: isOfflineError)
- }
- /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`s, maximum wait for
- /// connectivity, and offline error predicate.
- ///
- /// - Parameters:
- /// - monitor: `[NWInterface.InterfaceType]` used to configured the `NWPathMonitor` each time one is needed.
- /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
- /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
- /// is offline. Returning `false` moves to the next retrier, if any.
- ///
- @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *)
- public static func offlineRetrier(
- prohibitedInterfaceTypes: [NWInterface.InterfaceType],
- maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
- isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
- ) -> OfflineRetrier {
- OfflineRetrier(prohibitedInterfaceTypes: prohibitedInterfaceTypes, maximumWait: maximumWait, isOfflineError: isOfflineError)
- }
- static func offlineRetrier(
- monitor: @autoclosure @escaping () -> PathMonitor,
- maximumWait: DispatchTimeInterval
- ) -> OfflineRetrier {
- OfflineRetrier(monitor: monitor(), maximumWait: maximumWait)
- }
- }
- /// Internal abstraction for starting and stopping a path monitor. Used for testing.
- @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
- struct PathMonitor {
- enum Result {
- case pathAvailable, timeout
- }
- /// Starts the listener's work. Ensure work is properly cancellable in `stop()` in case of cancellation
- var start: (_ queue: DispatchQueue, _ onResult: @escaping @Sendable (_ result: Result) -> Void) -> Void
- /// Stops the listener. Ensure ongoing work is cancelled.
- var stop: () -> Void
- func startListening(on queue: DispatchQueue, onResult: @escaping @Sendable (_ result: Result) -> Void) {
- start(queue, onResult)
- }
- func stopListening() {
- stop()
- }
- }
- @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
- extension PathMonitor {
- init(_ pathMonitor: NWPathMonitor) {
- start = { queue, onResult in
- pathMonitor.pathUpdateHandler = { path in
- if path.status != .unsatisfied {
- onResult(.pathAvailable)
- }
- }
- pathMonitor.start(queue: queue)
- }
- stop = {
- pathMonitor.cancel()
- pathMonitor.pathUpdateHandler = nil
- }
- }
- }
- #endif
|