Forráskód Böngészése

OfflineRetrier (#3948)

Issue Link :link:
No specific issue was raised for this feature; this PR introduces a
network-aware request retrier that automatically reattempts failed
requests when the network becomes available.

Goals :soccer:
Implement an automatic retry mechanism that waits for network
availability.

Improve request handling by reducing manual retry efforts for
network-related failures.

Implementation Details :construction:
Introduced NetworkAwareRetrier, a custom RequestRetrier that integrates
with Alamofire sessions.

Utilized NWPathMonitor to detect network status changes and store
pending retries.

Ensured thread safety using a concurrent queue with barrier flags for
state management.

Maintained a queue of pending retry completions, executing them once the
network is restored.

Usage :rocket:
1️⃣ Adding NetworkAwareRetrier to an Alamofire Session
To enable automatic request retries for all requests within a session:

```
let retrier = NetworkAwareRetrier()
let session = Session(interceptor: retrier)

session.request("https://example.com/api/").response { response in
    print("Request completed")
}
```

2️⃣ Using NetworkAwareRetrier for a Specific Request
If you want to apply the retrier only to a specific request without
affecting the entire session, you can use

request(_:method:interceptor:):

```
let retrier = NetworkAwareRetrier()
let session = Session()

session.request("https://example.com/api/", interceptor: retrier).response { response in
    print("Request completed")
}
```

This ensures that only the specific request will retry when network
connectivity is restored, while other requests in the session remain
unaffected.

Testing Details :mag:
Unit Tests: Verified that failed requests are queued and retried once
the network becomes available.

Manual Testing: Simulated network loss and recovery scenarios to confirm
expected retry behavior.

Concurrency Testing: Ensured thread-safe access to shared properties.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Gourav Kumar <gourav.kumar@saavn.com>
Co-authored-by: 김영빈 (Rei) <89764127+kybeen@users.noreply.github.com>
Co-authored-by: Jon Shier <jon@jonshier.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
gourav kumar 1 hónapja
szülő
commit
d81e8ee41a

+ 24 - 0
Alamofire.xcodeproj/project.pbxproj

@@ -438,6 +438,11 @@
 		31DADDFC224811ED0051390F /* AlamofireExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DADDFA224811ED0051390F /* AlamofireExtended.swift */; };
 		31DADDFD224811ED0051390F /* AlamofireExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DADDFA224811ED0051390F /* AlamofireExtended.swift */; };
 		31DADDFE224811ED0051390F /* AlamofireExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DADDFA224811ED0051390F /* AlamofireExtended.swift */; };
+		31E021532E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */; };
+		31E021542E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */; };
+		31E021552E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */; };
+		31E021562E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */; };
+		31E021572E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */; };
 		31E382E126477307004533B3 /* WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31FEC68B26225A54009D17DB /* WebSocketTests.swift */; };
 		31EBD9C120D1D89C00D1FF34 /* ValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AE910119D28DCC0078C7B2 /* ValidationTests.swift */; };
 		31EBD9C220D1D89C00D1FF34 /* ValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AE910119D28DCC0078C7B2 /* ValidationTests.swift */; };
@@ -569,6 +574,11 @@
 		4CFB030E1D7D2FA20056F249 /* utf8_string.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4CFB02F41D7D2FA20056F249 /* utf8_string.txt */; };
 		4CFB030F1D7D2FA20056F249 /* utf8_string.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4CFB02F41D7D2FA20056F249 /* utf8_string.txt */; };
 		4DD67C251A5C590000ED2280 /* Alamofire.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897FF4019AA800700AB5182 /* Alamofire.swift */; };
+		5FC199BD2D92145E0002EF38 /* OfflineRetrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */; };
+		5FC199BE2D92145E0002EF38 /* OfflineRetrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */; };
+		5FC199BF2D92145E0002EF38 /* OfflineRetrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */; };
+		5FC199C02D92145E0002EF38 /* OfflineRetrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */; };
+		5FC199C12D92145E0002EF38 /* OfflineRetrier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */; };
 		8035DB621BAB492500466CB3 /* Alamofire.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8111E3319A95C8B0040E7D1 /* Alamofire.framework */; };
 		E4202FD01B667AA100C997FB /* ParameterEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE2724E1AF88FB500F1D59A /* ParameterEncoding.swift */; };
 		E4202FD21B667AA100C997FB /* ResponseSerialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDE2C451AF89FF300BABAE5 /* ResponseSerialization.swift */; };
@@ -705,6 +715,7 @@
 		31BC5E792B7E75770069BDEF /* Package@swift-5.9.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Package@swift-5.9.swift"; sourceTree = "<group>"; };
 		31D83FCD20D5C29300D93E47 /* URLConvertible+URLRequestConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLConvertible+URLRequestConvertible.swift"; sourceTree = "<group>"; };
 		31DADDFA224811ED0051390F /* AlamofireExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireExtended.swift; sourceTree = "<group>"; };
+		31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineRetrierTests.swift; sourceTree = "<group>"; };
 		31ED52E61D73889D00199085 /* AFError+AlamofireTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AFError+AlamofireTests.swift"; sourceTree = "<group>"; };
 		31EF4BF5279646450048A19D /* iOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = iOS.xctestplan; sourceTree = "<group>"; };
 		31EF4BF627964B520048A19D /* iOS-Old.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "iOS-Old.xctestplan"; sourceTree = "<group>"; };
@@ -763,6 +774,7 @@
 		4CFB02F41D7D2FA20056F249 /* utf8_string.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = utf8_string.txt; sourceTree = "<group>"; };
 		4CFD6B132201338E00FFB5E3 /* CachedResponseHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedResponseHandlerTests.swift; sourceTree = "<group>"; };
 		4DD67C0B1A5C55C900ED2280 /* Alamofire.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineRetrier.swift; sourceTree = "<group>"; };
 		E4202FE01B667AA100C997FB /* Alamofire.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		F8111E3319A95C8B0040E7D1 /* Alamofire.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		F8111E3719A95C8B0040E7D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -983,6 +995,7 @@
 				4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */,
 				311B198F20B0D3B40036823B /* MultipartUpload.swift */,
 				4C3D00531C66A63000D1F709 /* NetworkReachabilityManager.swift */,
+				5FC199BC2D9214580002EF38 /* OfflineRetrier.swift */,
 				4C0CB645220CA8A400604EDC /* RedirectHandler.swift */,
 				3165407229AEBC0400C9BE08 /* RequestCompression.swift */,
 				4C256A0521EEB69000AD5D87 /* RequestInterceptor.swift */,
@@ -1052,6 +1065,7 @@
 				3113D46A21878227001CCD21 /* HTTPHeadersTests.swift */,
 				4C3238E61B3604DB00FE04AE /* MultipartFormDataTests.swift */,
 				4C3D00571C66A8B900D1F709 /* NetworkReachabilityManagerTests.swift */,
+				31E021522E5BCF1900EEB257 /* OfflineRetrierTests.swift */,
 				3106FB6023F8C53A007FAB43 /* ProtectedTests.swift */,
 				4C0CB64A220CA8D600604EDC /* RedirectHandlerTests.swift */,
 				4C0CB630220BC70300604EDC /* RequestInterceptorTests.swift */,
@@ -1770,6 +1784,7 @@
 				31293086263E184500473CEA /* CombineTests.swift in Sources */,
 				3129307E263E183C00473CEA /* RequestModifierTests.swift in Sources */,
 				3129307B263E183C00473CEA /* AuthenticationTests.swift in Sources */,
+				31E021562E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */,
 				31293083263E184500473CEA /* NetworkReachabilityManagerTests.swift in Sources */,
 				3129308E263E184500473CEA /* CachedResponseHandlerTests.swift in Sources */,
 				31293087263E184500473CEA /* RedirectHandlerTests.swift in Sources */,
@@ -1795,6 +1810,7 @@
 			files = (
 				317338CB2A43A51100D4EA0A /* Alamofire.swift in Sources */,
 				317338CC2A43A51100D4EA0A /* AFError.swift in Sources */,
+				5FC199BF2D92145E0002EF38 /* OfflineRetrier.swift in Sources */,
 				317338CD2A43A51100D4EA0A /* HTTPHeaders.swift in Sources */,
 				317338CE2A43A51100D4EA0A /* HTTPMethod.swift in Sources */,
 				318702FC2B0AEEE400C10A8C /* UploadRequest.swift in Sources */,
@@ -1866,6 +1882,7 @@
 				317339112A43BE9000D4EA0A /* Request+AlamofireTests.swift in Sources */,
 				317339122A43BE9000D4EA0A /* Result+AlamofireTests.swift in Sources */,
 				317339132A43BE9000D4EA0A /* AuthenticationInterceptorTests.swift in Sources */,
+				31E021542E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */,
 				317339142A43BE9000D4EA0A /* CachedResponseHandlerTests.swift in Sources */,
 				317339152A43BE9000D4EA0A /* CacheTests.swift in Sources */,
 				317339162A43BE9000D4EA0A /* CombineTests.swift in Sources */,
@@ -1891,6 +1908,7 @@
 			files = (
 				4CF627081BA7CBF60011A099 /* AFError.swift in Sources */,
 				3191B5771F5F53A6003960A8 /* Protected.swift in Sources */,
+				5FC199C12D92145E0002EF38 /* OfflineRetrier.swift in Sources */,
 				3199179A209CDA7F00103A19 /* Response.swift in Sources */,
 				31D83FD020D5C29300D93E47 /* URLConvertible+URLRequestConvertible.swift in Sources */,
 				318702FA2B0AEEE400C10A8C /* UploadRequest.swift in Sources */,
@@ -1962,6 +1980,7 @@
 				3111CE8620A76370008315E2 /* SessionTests.swift in Sources */,
 				31C2B0F220B271380089BA7C /* TLSEvaluationTests.swift in Sources */,
 				3106FB6723F8D9E0007FAB43 /* DataStreamTests.swift in Sources */,
+				31E021532E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */,
 				3111CE9D20A7EC58008315E2 /* URLProtocolTests.swift in Sources */,
 				4CBD2182220B48AE008F1C59 /* RetryPolicyTests.swift in Sources */,
 				317A6A7820B2208000A9FEC5 /* DownloadTests.swift in Sources */,
@@ -1987,6 +2006,7 @@
 			files = (
 				4CE272501AF88FB500F1D59A /* ParameterEncoding.swift in Sources */,
 				3191B5761F5F53A6003960A8 /* Protected.swift in Sources */,
+				5FC199BE2D92145E0002EF38 /* OfflineRetrier.swift in Sources */,
 				4CDE2C471AF89FF300BABAE5 /* ResponseSerialization.swift in Sources */,
 				31991799209CDA7F00103A19 /* Response.swift in Sources */,
 				318702F92B0AEEE400C10A8C /* UploadRequest.swift in Sources */,
@@ -2036,6 +2056,7 @@
 			files = (
 				4CEE82AD1C6813CF00E9C9F0 /* NetworkReachabilityManager.swift in Sources */,
 				E4202FD01B667AA100C997FB /* ParameterEncoding.swift in Sources */,
+				5FC199BD2D92145E0002EF38 /* OfflineRetrier.swift in Sources */,
 				3191B5781F5F53A6003960A8 /* Protected.swift in Sources */,
 				3199179B209CDA7F00103A19 /* Response.swift in Sources */,
 				318702FB2B0AEEE400C10A8C /* UploadRequest.swift in Sources */,
@@ -2085,6 +2106,7 @@
 			files = (
 				4CE2724F1AF88FB500F1D59A /* ParameterEncoding.swift in Sources */,
 				3191B5751F5F53A6003960A8 /* Protected.swift in Sources */,
+				5FC199C02D92145E0002EF38 /* OfflineRetrier.swift in Sources */,
 				4CDE2C461AF89FF300BABAE5 /* ResponseSerialization.swift in Sources */,
 				31991798209CDA7F00103A19 /* Response.swift in Sources */,
 				318702F82B0AEEE400C10A8C /* UploadRequest.swift in Sources */,
@@ -2156,6 +2178,7 @@
 				3111CE8420A7636E008315E2 /* SessionTests.swift in Sources */,
 				31C2B0F020B271370089BA7C /* TLSEvaluationTests.swift in Sources */,
 				3106FB6523F8D9E0007FAB43 /* DataStreamTests.swift in Sources */,
+				31E021552E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */,
 				3111CE9B20A7EC57008315E2 /* URLProtocolTests.swift in Sources */,
 				4CBD2180220B48AE008F1C59 /* RetryPolicyTests.swift in Sources */,
 				317A6A7620B2207F00A9FEC5 /* DownloadTests.swift in Sources */,
@@ -2203,6 +2226,7 @@
 				3111CE8520A7636F008315E2 /* SessionTests.swift in Sources */,
 				31C2B0F120B271370089BA7C /* TLSEvaluationTests.swift in Sources */,
 				3106FB6623F8D9E0007FAB43 /* DataStreamTests.swift in Sources */,
+				31E021572E5BCF2000EEB257 /* OfflineRetrierTests.swift in Sources */,
 				3111CE9C20A7EC58008315E2 /* URLProtocolTests.swift in Sources */,
 				4CBD2181220B48AE008F1C59 /* RetryPolicyTests.swift in Sources */,
 				317A6A7720B2208000A9FEC5 /* DownloadTests.swift in Sources */,

+ 4 - 4
Source/Core/WebSocketRequest.swift

@@ -377,7 +377,7 @@ import Foundation
         on queue: DispatchQueue = .main,
         handler: @escaping @Sendable (_ event: Event<Serializer.Output, Serializer.Failure>) -> Void
     ) -> Self where Serializer: WebSocketMessageSerializer, Serializer.Failure == any Error {
-        forIncomingEvent(on: queue) { incomingEvent in
+        forIncomingEvent(on: queue) { [unowned self] incomingEvent in
             let event: Event<Serializer.Output, Serializer.Failure>
             switch incomingEvent {
             case let .connected(`protocol`):
@@ -429,7 +429,7 @@ import Foundation
         on queue: DispatchQueue = .main,
         handler: @escaping @Sendable (_ event: Event<URLSessionWebSocketTask.Message, Never>) -> Void
     ) -> Self {
-        forIncomingEvent(on: queue) { incomingEvent in
+        forIncomingEvent(on: queue) { [unowned self] incomingEvent in
             let event: Event<URLSessionWebSocketTask.Message, Never> = switch incomingEvent {
             case let .connected(`protocol`):
                 .init(socket: self, kind: .connected(protocol: `protocol`))
@@ -458,8 +458,8 @@ import Foundation
 
     func forIncomingEvent(on queue: DispatchQueue, handler: @escaping @Sendable (IncomingEvent) -> Void) -> Self {
         socketMutableState.write { state in
-            state.handlers.append((queue: queue, handler: { incomingEvent in
-                self.serializationQueue.async {
+            state.handlers.append((queue: queue, handler: { [unowned self] incomingEvent in
+                serializationQueue.async {
                     handler(incomingEvent)
                 }
             }))

+ 280 - 0
Source/Features/OfflineRetrier.swift

@@ -0,0 +1,280 @@
+//
+//  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.
+//
+
+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: 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.
+    public static let defaultURLErrorOfflineCodes: Set<URLError.Code> = [
+        .notConnectedToInternet
+    ]
+    /// Default method of detecting whether a paricular `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 paricular `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 paricular `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 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 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
+        }
+    }
+}

+ 228 - 0
Tests/OfflineRetrierTests.swift

@@ -0,0 +1,228 @@
+import Dispatch
+import Testing
+
+@testable import Alamofire
+
+@Suite("OfflineRetrierTests")
+struct OfflineRetrierTests {
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func requestIsRetriedWhenConnectivityIsRestored() async throws {
+        // Given
+        let didStop = Protected(false)
+        let monitor = PathMonitor { queue, onResult in
+            queue.async {
+                onResult(.pathAvailable)
+            }
+        } stop: {
+            didStop.write(true)
+        }
+
+        // When: retrier considers error to be offline error.
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(100)) { _ in true }
+        // When: request fails due to error (type doesn't matter).
+        let request = AF.request(.endpoints(.status(404), .get), interceptor: retrier).validate()
+        let result = await request.serializingData().result
+
+        // Then: request is retried successfully.
+        #expect(result.isSuccess == true)
+        // Then: two tasks are created.
+        #expect(request.tasks.count == 2)
+        // Then: monitor is stopped.
+        #expect(didStop.value == true)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func requestIsNotRetriedWhenTheErrorIsNotOfflineError() async throws {
+        // Given
+        let didStop = Protected(false)
+        let monitor = PathMonitor { queue, onResult in
+            queue.async {
+                onResult(.pathAvailable)
+            }
+        } stop: {
+            didStop.write(true)
+        }
+
+        // When
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(100))
+        // When: request fails due to validation.
+        let request = AF.request(.endpoints(.status(404), .get), interceptor: retrier).validate()
+        let result = await request.serializingData().result
+
+        // Then: request fails since validation failures aren't retried.
+        #expect(result.isSuccess == false)
+        // Then: only one task is created.
+        #expect(request.tasks.count == 1)
+        // Then: stop not called, as retrier isn't immediately deinit'd.
+        #expect(didStop.value == false)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func requestIsNotRetriedWhenPathTimesOut() async throws {
+        // Given
+        let didStop = Protected(false)
+        let pathAvailable: Protected<DispatchWorkItem?> = .init(nil)
+        let monitor = PathMonitor { queue, onResult in
+            let work = DispatchWorkItem { onResult(.pathAvailable) }
+            pathAvailable.write(work)
+            // Given: path available after one second.
+            queue.asyncAfter(deadline: .now() + .seconds(1), execute: work)
+        } stop: {
+            pathAvailable.write { $0?.cancel() }
+            didStop.write(true)
+        }
+
+        // When: retrier times out after one millisecond.
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(1)) { _ in true }
+        // When: request fails due to validation but would succeed on retry.
+        let request = AF.request(.endpoints(.status(404), .get), interceptor: retrier).validate()
+        let result = await request.serializingData().result
+
+        // Then: request fails since it's not retried.
+        #expect(result.isSuccess == false)
+        // Then: only one task is created.
+        #expect(request.tasks.count == 1)
+        // Then: stop is called since timeout resets retrier.
+        #expect(didStop.value == true)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func sessionWideRetrierCanRetryMultipleRequests() async throws {
+        // Given
+        let didStop = Protected(false)
+        let pathAvailable: Protected<DispatchWorkItem?> = .init(nil)
+        let monitor = PathMonitor { queue, onResult in
+            let work = DispatchWorkItem { onResult(.pathAvailable) }
+            pathAvailable.write(work)
+            // Given: path available after ten milliseconds.
+            queue.asyncAfter(deadline: .now() + .milliseconds(10), execute: work)
+        } stop: {
+            pathAvailable.write { $0?.cancel() }
+            didStop.write(true)
+        }
+
+        // When
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(500)) { _ in true }
+        let session = Session(interceptor: retrier)
+        // When: multiple requests are started which initially fail due to validation.
+        async let first = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let second = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let third = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let fourth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+
+        // Then: all requests succeed after retry.
+        await #expect(first.isSuccess == true)
+        await #expect(second.isSuccess == true)
+        await #expect(third.isSuccess == true)
+        await #expect(fourth.isSuccess == true)
+        // Then: monitor has stopped due to `Session` deinit.
+        #expect(didStop.value == true)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func sessionWideRetrierCanRetryMultipleRequestsTwice() async throws {
+        // Given
+        let didStop = Protected(false)
+        let pathAvailable: Protected<DispatchWorkItem?> = .init(nil)
+        let monitor = PathMonitor { queue, onResult in
+            let work = DispatchWorkItem { onResult(.pathAvailable) }
+            pathAvailable.write(work)
+            // Given: path available after ten milliseconds.
+            queue.asyncAfter(deadline: .now() + .milliseconds(10), execute: work)
+        } stop: {
+            pathAvailable.write { $0?.cancel() }
+            didStop.write(true)
+        }
+
+        // When
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(500)) { _ in true }
+        let session = Session(interceptor: retrier)
+        // When: multiple requests are started which initially fail due to validation.
+        async let first = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let second = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let third = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let fourth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+
+        // Then: all requests succeed after retry.
+        await #expect(first.isSuccess == true)
+        await #expect(second.isSuccess == true)
+        await #expect(third.isSuccess == true)
+        await #expect(fourth.isSuccess == true)
+
+        // When: another set of requests are started which initially fail due to validation.
+        async let fifth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let sixth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let seventh = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let eighth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+
+        // Then: second set of requests succeed after retry.
+        await #expect(fifth.isSuccess == true)
+        await #expect(sixth.isSuccess == true)
+        await #expect(seventh.isSuccess == true)
+        await #expect(eighth.isSuccess == true)
+        // Then: monitor has stopped due to `Session` deinit.
+        #expect(didStop.value == true)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func sessionWideRetrierCanTimeOutMultipleRequests() async throws {
+        // Given
+        let didStop = Protected(false)
+        let pathAvailable: Protected<DispatchWorkItem?> = .init(nil)
+        let monitor = PathMonitor { queue, onResult in
+            let work = DispatchWorkItem { onResult(.pathAvailable) }
+            pathAvailable.write(work)
+            // Given: path available after one second.
+            queue.asyncAfter(deadline: .now() + .seconds(1), execute: work)
+        } stop: {
+            pathAvailable.write { $0?.cancel() }
+            didStop.write(true)
+        }
+
+        // When
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .milliseconds(10)) { _ in true }
+        let session = Session(interceptor: retrier)
+        // When: multiple requests are started which initially fail due to validation.
+        async let first = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let second = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let third = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+        async let fourth = session.request(.endpoints(.status(404), .get)).validate().serializingData().result
+
+        // Then: all requests succeed after retry.
+        await #expect(first.isSuccess == false)
+        await #expect(second.isSuccess == false)
+        await #expect(third.isSuccess == false)
+        await #expect(fourth.isSuccess == false)
+        // Then: monitor has stopped due to `Session` deinit.
+        #expect(didStop.value == true)
+    }
+
+    @Test
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, visionOS 1, *)
+    func offlineRetrierNeverStartsOrStopsWhenImmediatelyDeinited() async throws {
+        // Given
+        let didStart = Protected(false)
+        let didStop = Protected(false)
+        let monitor = PathMonitor { _, _ in
+            didStart.write(true)
+        } stop: {
+            didStop.write(true)
+        }
+
+        // When: retrier created with no start and long timeout.
+        let retrier = OfflineRetrier(monitor: monitor, maximumWait: .seconds(100))
+        // When: retrier is deinit'd.
+        _ = consume retrier
+
+        // Then: didStart is false.
+        #expect(didStart.value == false)
+        // Then: didStop is false.
+        #expect(didStop.value == false)
+    }
+}

+ 5 - 5
Tests/TestHelpers.swift

@@ -22,7 +22,7 @@
 //  THE SOFTWARE.
 //
 
-import Alamofire
+@testable import Alamofire
 import Foundation
 
 extension String {
@@ -326,16 +326,16 @@ extension Endpoint: URLConvertible {
 final class EndpointSequence: URLRequestConvertible {
     enum Error: Swift.Error { case noRemainingEndpoints }
 
-    private var remainingEndpoints: [Endpoint]
+    private let remainingEndpoints: Protected<[Endpoint]>
 
     init(endpoints: [Endpoint]) {
-        remainingEndpoints = endpoints
+        remainingEndpoints = .init(endpoints)
     }
 
     func asURLRequest() throws -> URLRequest {
-        guard !remainingEndpoints.isEmpty else { throw Error.noRemainingEndpoints }
+        guard !remainingEndpoints.value.isEmpty else { throw Error.noRemainingEndpoints }
 
-        return try remainingEndpoints.removeFirst().asURLRequest()
+        return try remainingEndpoints.write { $0.removeFirst() }.asURLRequest()
     }
 }
 

+ 4 - 4
Tests/WebSocketTests.swift

@@ -309,7 +309,7 @@ final class WebSocketTests: BaseTestCase {
 
         // When
         let request = session.webSocketRequest(.websocketEcho)
-        request.streamMessageEvents { event in
+        request.streamMessageEvents { [unowned request] event in
             switch event.kind {
             case let .connected(`protocol`):
                 connectedProtocol = `protocol`
@@ -353,7 +353,7 @@ final class WebSocketTests: BaseTestCase {
 
         // When
         let request = session.webSocketRequest(.websocketEcho)
-        request.streamMessageEvents { event in
+        request.streamMessageEvents { [unowned request] event in
             switch event.kind {
             case let .connected(`protocol`):
                 connectedProtocol = `protocol`
@@ -398,7 +398,7 @@ final class WebSocketTests: BaseTestCase {
 
         // When
         let request = session.webSocketRequest(.websocketEcho)
-        request.streamMessageEvents { event in
+        request.streamMessageEvents { [unowned request] event in
             switch event.kind {
             case let .connected(`protocol`):
                 connectedProtocol = `protocol`
@@ -460,7 +460,7 @@ final class WebSocketTests: BaseTestCase {
 
         // When
         let request = session.webSocketRequest(.websocketPings(), configuration: .pingInterval(0.01))
-        request.streamMessageEvents { event in
+        request.streamMessageEvents { [unowned request] event in
             switch event.kind {
             case let .connected(`protocol`):
                 connectedProtocol = `protocol`