OfflineRetrier.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. //
  2. // OfflineRetrier.swift
  3. //
  4. // Copyright (c) 2025 Alamofire Software Foundation (http://alamofire.org/)
  5. //
  6. // Permission is hereby granted, free of charge, to any person obtaining a copy
  7. // of this software and associated documentation files (the "Software"), to deal
  8. // in the Software without restriction, including without limitation the rights
  9. // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. // copies of the Software, and to permit persons to whom the Software is
  11. // furnished to do so, subject to the following conditions:
  12. //
  13. // The above copyright notice and this permission notice shall be included in
  14. // all copies or substantial portions of the Software.
  15. //
  16. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  22. // THE SOFTWARE.
  23. //
  24. #if canImport(Network)
  25. import Foundation
  26. import Network
  27. /// `RequestRetrier` which uses `NWPathMonitor` to detect when connectivity is restored to retry failed requests.
  28. @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
  29. public final class OfflineRetrier: RequestAdapter, RequestRetrier, RequestInterceptor, Sendable {
  30. /// Default amount of time to wait for connectivity to be restored before failure. `.seconds(5)` by default.
  31. public static let defaultWait: DispatchTimeInterval = .seconds(5)
  32. /// Default `Set<URLError.Code>` used to check for offline errors. `[.notConnectedToInternet]` by default.
  33. public static let defaultURLErrorOfflineCodes: Set<URLError.Code> = [
  34. .notConnectedToInternet
  35. ]
  36. /// Default method of detecting whether a particular `any Error` means connectivity is offline.
  37. public static let defaultIsOfflineError: @Sendable (_ error: any Error) -> Bool = { error in
  38. if let error = error.asAFError?.underlyingError {
  39. defaultIsOfflineError(error)
  40. } else if let error = error as? URLError {
  41. defaultURLErrorOfflineCodes.contains(error.code)
  42. } else {
  43. false
  44. }
  45. }
  46. private static let monitorQueue = DispatchQueue(label: "org.alamofire.offlineRetrier.monitorQueue")
  47. fileprivate struct State {
  48. let maximumWait: DispatchTimeInterval
  49. let isOfflineError: (_ error: any Error) -> Bool
  50. let monitorCreator: () -> PathMonitor
  51. var timeoutWorkItem: DispatchWorkItem?
  52. var currentMonitor: PathMonitor?
  53. var pendingCompletions: [@Sendable (_ retryResult: RetryResult) -> Void] = []
  54. }
  55. private let state: Protected<State>
  56. /// Creates an instance from the provided `NWPathMonitor`, maximum wait for connectivity, and offline error predicate.
  57. ///
  58. /// - Parameters:
  59. /// - monitor: `NWPathMonitor()` to use to detect connectivity. A new instance is created each time a
  60. /// request fails and retry may be needed.
  61. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
  62. /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
  63. /// is offline. Returning `false` moves to the next retrier, if any.
  64. ///
  65. public init(monitor: @autoclosure @escaping () -> NWPathMonitor = NWPathMonitor(),
  66. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  67. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
  68. state = Protected(State(maximumWait: maximumWait, isOfflineError: isOfflineError) { PathMonitor(monitor()) })
  69. }
  70. /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`, maximum wait for
  71. /// connectivity, and offline error predicate.
  72. ///
  73. /// - Parameters:
  74. /// - monitor: `NWInterface.InterfaceType` used to configured the `NWPathMonitor` each time one is needed.
  75. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
  76. /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
  77. /// is offline. Returning `false` moves to the next retrier, if any.
  78. ///
  79. public convenience init(requiredInterfaceType: NWInterface.InterfaceType,
  80. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  81. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
  82. self.init(monitor: NWPathMonitor(requiredInterfaceType: requiredInterfaceType), maximumWait: maximumWait, isOfflineError: isOfflineError)
  83. }
  84. /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`s, maximum wait for
  85. /// connectivity, and offline error predicate.
  86. ///
  87. /// - Parameters:
  88. /// - monitor: `[NWInterface.InterfaceType]` used to configured the `NWPathMonitor` each time one is needed.
  89. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before
  90. /// - isOfflineError: Predicate closure used to determine whether a particular `any Error` indicates connectivity
  91. /// is offline. Returning `false` moves to the next retrier, if any.
  92. ///
  93. @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *)
  94. public convenience init(prohibitedInterfaceTypes: [NWInterface.InterfaceType],
  95. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  96. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
  97. self.init(monitor: NWPathMonitor(prohibitedInterfaceTypes: prohibitedInterfaceTypes), maximumWait: maximumWait, isOfflineError: isOfflineError)
  98. }
  99. init(monitor: @autoclosure @escaping () -> PathMonitor,
  100. maximumWait: DispatchTimeInterval,
  101. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError) {
  102. state = Protected(State(maximumWait: maximumWait, isOfflineError: isOfflineError, monitorCreator: monitor))
  103. }
  104. deinit {
  105. state.write { state in
  106. state.cleanupMonitor()
  107. }
  108. }
  109. public func retry(_ request: Request,
  110. for session: Session,
  111. dueTo error: any Error,
  112. completion: @escaping @Sendable (RetryResult) -> Void) {
  113. state.write { state in
  114. guard state.isOfflineError(error) else { completion(.doNotRetry); return }
  115. state.pendingCompletions.append(completion)
  116. guard state.currentMonitor == nil else { return }
  117. state.startListening { [unowned self] result in
  118. let retryResult: RetryResult = switch result {
  119. case .pathAvailable:
  120. .retry
  121. case .timeout:
  122. // Do not retry, keep original error.
  123. .doNotRetry
  124. }
  125. performResult(retryResult)
  126. }
  127. }
  128. }
  129. private func performResult(_ result: RetryResult) {
  130. state.write { state in
  131. let completions = state.pendingCompletions
  132. state.cleanupMonitor()
  133. for completion in completions {
  134. Self.monitorQueue.async {
  135. completion(result)
  136. }
  137. }
  138. }
  139. }
  140. }
  141. @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
  142. extension OfflineRetrier.State {
  143. fileprivate mutating func startListening(onResult: @escaping @Sendable (_ result: PathMonitor.Result) -> Void) {
  144. let timeout = DispatchWorkItem {
  145. onResult(.timeout)
  146. }
  147. timeoutWorkItem = timeout
  148. OfflineRetrier.monitorQueue.asyncAfter(deadline: .now() + maximumWait, execute: timeout)
  149. currentMonitor = monitorCreator()
  150. currentMonitor?.startListening(on: OfflineRetrier.monitorQueue, onResult: onResult)
  151. }
  152. fileprivate mutating func cleanupMonitor() {
  153. pendingCompletions.removeAll()
  154. timeoutWorkItem?.cancel()
  155. timeoutWorkItem = nil
  156. currentMonitor?.stopListening()
  157. currentMonitor = nil
  158. }
  159. }
  160. @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
  161. extension RequestInterceptor where Self == OfflineRetrier {
  162. /// Creates an instance from the provided `NWPathMonitor`, maximum wait for connectivity, and offline error predicate.
  163. ///
  164. /// - Parameters:
  165. /// - monitor: `NWPathMonitor()` to use to detect connectivity. A new instance is created each time a
  166. /// request fails and retry may be needed.
  167. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
  168. /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
  169. /// is offline. Returning `false` moves to the next retrier, if any.
  170. ///
  171. public static func offlineRetrier(
  172. monitor: @autoclosure @escaping () -> NWPathMonitor = NWPathMonitor(),
  173. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  174. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
  175. ) -> OfflineRetrier {
  176. OfflineRetrier(monitor: monitor(), maximumWait: maximumWait, isOfflineError: isOfflineError)
  177. }
  178. /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`, maximum wait for
  179. /// connectivity, and offline error predicate.
  180. ///
  181. /// - Parameters:
  182. /// - monitor: `NWInterface.InterfaceType` used to configured the `NWPathMonitor` each time one is needed.
  183. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
  184. /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
  185. /// is offline. Returning `false` moves to the next retrier, if any.
  186. ///
  187. public static func offlineRetrier(
  188. requiredInterfaceType: NWInterface.InterfaceType,
  189. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  190. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
  191. ) -> OfflineRetrier {
  192. OfflineRetrier(requiredInterfaceType: requiredInterfaceType, maximumWait: maximumWait, isOfflineError: isOfflineError)
  193. }
  194. /// Creates an instance using an `NWPathMonitor` configured with the provided `InterfaceType`s, maximum wait for
  195. /// connectivity, and offline error predicate.
  196. ///
  197. /// - Parameters:
  198. /// - monitor: `[NWInterface.InterfaceType]` used to configured the `NWPathMonitor` each time one is needed.
  199. /// - maximumWait: `DispatchTimeInterval` to wait for connectivity before timeout.
  200. /// - isOfflineError: Predicate closure used to determine whether a paricular `any Error` indicates connectivity
  201. /// is offline. Returning `false` moves to the next retrier, if any.
  202. ///
  203. @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *)
  204. public static func offlineRetrier(
  205. prohibitedInterfaceTypes: [NWInterface.InterfaceType],
  206. maximumWait: DispatchTimeInterval = OfflineRetrier.defaultWait,
  207. isOfflineError: @escaping @Sendable (_ error: any Error) -> Bool = OfflineRetrier.defaultIsOfflineError
  208. ) -> OfflineRetrier {
  209. OfflineRetrier(prohibitedInterfaceTypes: prohibitedInterfaceTypes, maximumWait: maximumWait, isOfflineError: isOfflineError)
  210. }
  211. static func offlineRetrier(
  212. monitor: @autoclosure @escaping () -> PathMonitor,
  213. maximumWait: DispatchTimeInterval
  214. ) -> OfflineRetrier {
  215. OfflineRetrier(monitor: monitor(), maximumWait: maximumWait)
  216. }
  217. }
  218. /// Internal abstraction for starting and stopping a path monitor. Used for testing.
  219. @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
  220. struct PathMonitor {
  221. enum Result {
  222. case pathAvailable, timeout
  223. }
  224. /// Starts the listener's work. Ensure work is properly cancellable in `stop()` in case of cancellation
  225. var start: (_ queue: DispatchQueue, _ onResult: @escaping @Sendable (_ result: Result) -> Void) -> Void
  226. /// Stops the listener. Ensure ongoing work is cancelled.
  227. var stop: () -> Void
  228. func startListening(on queue: DispatchQueue, onResult: @escaping @Sendable (_ result: Result) -> Void) {
  229. start(queue, onResult)
  230. }
  231. func stopListening() {
  232. stop()
  233. }
  234. }
  235. @available(macOS 10.14, iOS 12, tvOS 12, watchOS 5, visionOS 1, *)
  236. extension PathMonitor {
  237. init(_ pathMonitor: NWPathMonitor) {
  238. start = { queue, onResult in
  239. pathMonitor.pathUpdateHandler = { path in
  240. if path.status != .unsatisfied {
  241. onResult(.pathAvailable)
  242. }
  243. }
  244. pathMonitor.start(queue: queue)
  245. }
  246. stop = {
  247. pathMonitor.cancel()
  248. pathMonitor.pathUpdateHandler = nil
  249. }
  250. }
  251. }
  252. #endif