Просмотр исходного кода

Merge pull request #2439 from komkovla/feature/network-retry-strategy

Feature - Network retry strategy
Wei Wang 4 месяцев назад
Родитель
Сommit
5db037ead5

+ 4 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -7,6 +7,7 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		00A8E26E2E81B89600ABB84F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */; };
 		07292245263B02F00089E810 /* KFAnimatedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07292244263B02F00089E810 /* KFAnimatedImage.swift */; };
 		078DCB4F2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078DCB4E2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift */; };
 		22FDCE0E2700078B0044D11E /* CPListItem+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FDCE0D2700078B0044D11E /* CPListItem+Kingfisher.swift */; };
@@ -153,6 +154,7 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
 		07292244263B02F00089E810 /* KFAnimatedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFAnimatedImage.swift; sourceTree = "<group>"; };
 		078DCB4E2BCFEB7D0008114E /* PHPickerResultImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultImageDataProvider.swift; sourceTree = "<group>"; };
 		185218B51CC07F8300BD58DE /* NSButtonExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSButtonExtensionTests.swift; sourceTree = "<group>"; };
@@ -370,6 +372,7 @@
 		D12AB69C215D2BB50013BA68 /* Networking */ = {
 			isa = PBXGroup;
 			children = (
+				00A8E26D2E81B89600ABB84F /* NetworkMonitor.swift */,
 				D12AB69D215D2BB50013BA68 /* RequestModifier.swift */,
 				D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */,
 				D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */,
@@ -856,6 +859,7 @@
 				D1132C9725919F69003E528D /* KFOptionsSetter.swift in Sources */,
 				D18B3222251852E100662F63 /* KF.swift in Sources */,
 				D12AB704215D2BB50013BA68 /* Kingfisher.swift in Sources */,
+				00A8E26E2E81B89600ABB84F /* NetworkMonitor.swift in Sources */,
 				D1AEB09725890DEA008556DF /* KFImage.swift in Sources */,
 				F39B68C82E33AC2A00404B02 /* NetworkMetrics.swift in Sources */,
 				D1BA781D2174D07800C69D7B /* CallbackQueue.swift in Sources */,

+ 32 - 11
Sources/Documentation.docc/Topics/Topic_Retry.md

@@ -4,34 +4,55 @@ Managing the retry mechanism when an error happens during loading.
 
 ## Overview
 
-Use ``KingfisherOptionsInfoItem/retryStrategy(_:)`` along with a `RetryStrategy` implementation to easily set up a 
-retry mechanism for image setting operations when an error occurs. 
+Use ``KingfisherOptionsInfoItem/retryStrategy(_:)`` along with a `RetryStrategy` implementation to easily set up a
+retry mechanism for image setting operations when an error occurs.
 
-This combination allows you to define retry logic, including the number of retries and the conditions under which a 
+This combination allows you to define retry logic, including the number of retries and the conditions under which a
 retry should be attempted, ensuring a more resilient image loading process.
 
 
-## Basic Retry Strategy
+## Built-in Retry Strategies
 
-``DelayRetryStrategy`` is a predefined retry strategy in Kingfisher. It allows you to specify the `maxRetryCount` and 
-the `retryInterval` to easily configure retry behavior. This setup enables quick implementation of a retry mechanism: 
+Kingfisher provides two built-in retry strategies to handle different scenarios:
+
+### DelayRetryStrategy
+
+``DelayRetryStrategy`` is a time-based retry strategy that allows you to specify the `maxRetryCount` and
+the `retryInterval` to easily configure retry behavior. This setup enables quick implementation of a retry mechanism:
 
 ```swift
 let retry = DelayRetryStrategy(
-  maxRetryCount: 5, 
+  maxRetryCount: 5,
   retryInterval: .seconds(3)
 )
 imageView.kf.setImage(with: url, options: [.retryStrategy(retry)])
 ```
 
 This implements a retry mechanism that attempts to reload the target URL up to 5 times, with a fixed 3-second interval
-between each try. 
+between each try.
 
 #### Other retry interval
 
-For a more dynamic approach, you can also select `.accumulated(3)` as the retry interval results in progressively 
-increasing delays between attempts, specifically `3 -> 6 -> 9 -> 12 -> 15` seconds for each subsequent retry. 
-Additionally, for ultimate flexibility, `.custom` allows you to define a unique pattern for retry intervals, tailoring 
+For a more dynamic approach, you can also select `.accumulated(3)` as the retry interval results in progressively
+increasing delays between attempts, specifically `3 -> 6 -> 9 -> 12 -> 15` seconds for each subsequent retry.
+Additionally, for ultimate flexibility, `.custom` allows you to define a unique pattern for retry intervals, tailoring
 the retry logic to your specific requirements.
 
+### NetworkRetryStrategy
+
+``NetworkRetryStrategy`` is a network-aware retry strategy that handles network connectivity issues.
+It only retries when the network becomes available after a disconnection, this is suitable to handle unstable user connection.
+
+```swift
+// Basic usage - retries immediately when network becomes available
+let networkRetry = NetworkRetryStrategy()
+imageView.kf.setImage(with: url, options: [.retryStrategy(networkRetry)])
+
+// With timeout - stops waiting after specified duration
+let networkRetryWithTimeout = NetworkRetryStrategy(timeoutInterval: 30.0)
+imageView.kf.setImage(with: url, options: [.retryStrategy(networkRetryWithTimeout)])
+```
+
+## Custom Retry Strategies
+
 If you need more control for the retry strategy, implement your own type that conforms to ``RetryStrategy``.

+ 188 - 0
Sources/Networking/NetworkMonitor.swift

@@ -0,0 +1,188 @@
+//
+//  NetworkMonitor.swift
+//  Kingfisher
+//
+//  Created by Vladislav Komkov on 2025/09/22.
+//
+//  Copyright (c) 2020 Wei Wang <onevcat@gmail.com>
+//
+//  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 Network
+import Foundation
+
+/// A protocol for network connectivity monitoring that allows for dependency injection and testing.
+internal protocol NetworkMonitoring: Sendable {
+    /// Whether the network is currently connected.
+    var isConnected: Bool { get }
+
+    /// Observes network connectivity changes with an optional timeout.
+    /// - Parameters:
+    ///   - timeoutInterval: The timeout for waiting for network reconnection. If nil, no timeout is applied.
+    ///   - callback: The callback to be called when network state changes or timeout occurs.
+    /// - Returns: A cancellable observer that can be used to cancel the observation.
+    func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver
+}
+
+/// A protocol for network observers that can be cancelled.
+internal protocol NetworkObserver: Sendable {
+    /// Cancels the network observation.
+    func cancel()
+}
+
+/// A shared singleton that manages network connectivity monitoring.
+/// This prevents creating multiple NWPathMonitor instances when many NetworkRetryStrategy instances are used.
+/// The monitor is created lazily only when first accessed.
+internal final class NetworkMonitor: @unchecked Sendable, NetworkMonitoring {
+    static let `default` = NetworkMonitor()
+
+    /// Whether the network is currently connected.
+    var isConnected: Bool {
+        return monitor.currentPath.status == .satisfied
+    }
+
+    /// The network path monitor for observing connectivity changes.
+    private let monitor = NWPathMonitor()
+
+    /// The queue for monitoring network changes.
+    private let monitorQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor", qos: .utility)
+
+    /// Observers waiting for network reconnection.
+    private var observers: [NetworkObserverImpl] = []
+    private let observersQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor.Observers", attributes: .concurrent)
+
+    /// Whether the monitor has been started.
+    private var isStarted = false
+    private let startQueue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkMonitor.Start")
+
+    private init() {
+        // Set up path monitoring
+        monitor.pathUpdateHandler = { [weak self] path in
+            self?.handlePathUpdate(path)
+        }
+    }
+
+    /// Starts monitoring if not already started.
+    private func startMonitoring() {
+        startQueue.sync {
+            guard !isStarted else { return }
+            monitor.start(queue: monitorQueue)
+            isStarted = true
+        }
+    }
+
+    /// Handles network path updates and notifies observers.
+    private func handlePathUpdate(_ path: NWPath) {
+        let connected = path.status == .satisfied
+        guard connected else { return }
+
+        // Notify all observers that network is available
+        observersQueue.async(flags: .barrier) {
+            let activeObservers = self.observers
+            self.observers.removeAll()
+
+            DispatchQueue.main.async {
+                activeObservers.forEach { $0.notify(isConnected: true) }
+            }
+        }
+    }
+
+    /// Adds an observer for network reconnection.
+    private func addObserver(_ observer: NetworkObserverImpl) {
+        startMonitoring()
+
+        observersQueue.async(flags: .barrier) {
+            self.observers.append(observer)
+        }
+    }
+
+    /// Removes an observer.
+    internal func removeObserver(_ observer: NetworkObserverImpl) {
+        observersQueue.async(flags: .barrier) {
+            self.observers.removeAll { $0 === observer }
+        }
+    }
+
+    // MARK: - NetworkMonitoring
+
+    public func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver {
+        let observer = NetworkObserverImpl(
+            timeoutInterval: timeoutInterval,
+            callback: callback,
+            monitor: self
+        )
+        addObserver(observer)
+        return observer
+    }
+}
+
+/// Internal implementation of network observer that manages timeout and callbacks.
+internal final class NetworkObserverImpl: @unchecked Sendable, NetworkObserver {
+    let timeoutInterval: TimeInterval?
+    let callback: @Sendable (Bool) -> Void
+    private weak var monitor: NetworkMonitor?
+    private var timeoutWorkItem: DispatchWorkItem?
+    private let queue = DispatchQueue(label: "com.onevcat.Kingfisher.NetworkObserver", qos: .utility)
+
+    init(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void, monitor: NetworkMonitor) {
+        self.timeoutInterval = timeoutInterval
+        self.callback = callback
+        self.monitor = monitor
+
+        // Set up timeout if specified
+        if let timeoutInterval = timeoutInterval {
+            let workItem = DispatchWorkItem { [weak self] in
+                self?.notify(isConnected: false)
+            }
+            timeoutWorkItem = workItem
+            queue.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
+        }
+    }
+
+    func notify(isConnected: Bool) {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout if we're notifying
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+
+            // Call the callback
+            DispatchQueue.main.async {
+                self.callback(isConnected)
+            }
+        }
+    }
+
+    func cancel() {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+        }
+    }
+}

+ 92 - 9
Sources/Networking/RetryStrategy.swift

@@ -30,7 +30,7 @@ import Foundation
 ///
 /// The instance of this type can be shared between different retry attempts.
 public class RetryContext: @unchecked Sendable {
-    
+
     private let propertyQueue = DispatchQueue(label: "com.onevcat.Kingfisher.RetryContextPropertyQueue")
 
     /// The source from which the target image should be retrieved.
@@ -40,7 +40,7 @@ public class RetryContext: @unchecked Sendable {
     public let error: KingfisherError
 
     private var _retriedCount: Int
-    
+
     /// The number of retries attempted before the current retry happens.
     ///
     /// This value is `0` if the current retry is for the first time.
@@ -48,10 +48,10 @@ public class RetryContext: @unchecked Sendable {
         get { propertyQueue.sync { _retriedCount } }
         set { propertyQueue.sync { _retriedCount = newValue } }
     }
-    
+
     private var _userInfo: Any? = nil
 
-    /// A user-set value for passing any other information during the retry. 
+    /// A user-set value for passing any other information during the retry.
     ///
     /// If you choose to use ``RetryDecision/retry(userInfo:)`` as the retry decision for
     /// ``RetryStrategy/retry(context:retryHandler:)``, the associated value of ``RetryDecision/retry(userInfo:)`` will
@@ -105,18 +105,18 @@ public struct DelayRetryStrategy: RetryStrategy {
 
     /// Represents the interval mechanism used in a ``DelayRetryStrategy``.
     public enum Interval : Sendable{
-        
-        /// The next retry attempt should happen in a fixed number of seconds. 
+
+        /// The next retry attempt should happen in a fixed number of seconds.
         ///
         /// For example, if the associated value is 3, the attempt happens 3 seconds after the previous decision is
         /// made.
         case seconds(TimeInterval)
-        
-        /// The next retry attempt should happen in an accumulated duration. 
+
+        /// The next retry attempt should happen in an accumulated duration.
         ///
         /// For example, if the associated value is 3, the attempts happen with intervals of 3, 6, 9, 12, ... seconds.
         case accumulated(TimeInterval)
-        
+
         /// Uses a block to determine the next interval.
         ///
         /// The current retry count is given as a parameter.
@@ -184,3 +184,86 @@ public struct DelayRetryStrategy: RetryStrategy {
         }
     }
 }
+
+/// A retry strategy that observes network state and retries on reconnect.
+///
+/// This strategy only retries when network becomes available after a disconnection.
+/// It does not use any delay mechanisms - it retries immediately when network is restored.
+///
+/// The network monitor is created lazily only when this strategy is first used,
+/// ensuring no unnecessary resource usage when the strategy is not in use.
+public struct NetworkRetryStrategy: RetryStrategy {
+
+    /// The timeout for waiting for network reconnection (in seconds).
+    private let timeoutInterval: TimeInterval?
+
+    /// The network monitoring service used to observe connectivity changes.
+    private let networkMonitor: NetworkMonitoring
+
+    /// Creates a network-aware retry strategy.
+    ///
+    /// - Parameters:
+    ///   - timeoutInterval: The timeout for waiting for network reconnection. If nil, no timeout is applied. Defaults to 30 seconds.
+    public init(timeoutInterval: TimeInterval? = 30) {
+        self.init(
+            timeoutInterval: timeoutInterval,
+            networkMonitor: NetworkMonitor.default
+        )
+    }
+
+    internal init(
+        timeoutInterval: TimeInterval?,
+        networkMonitor: NetworkMonitoring
+    ) {
+        self.timeoutInterval = timeoutInterval
+        self.networkMonitor = networkMonitor
+    }
+
+    public func retry(context: RetryContext, retryHandler: @escaping @Sendable (RetryDecision) -> Void) {
+        // Dispose of any previous disposable from userInfo
+        if let previousObserver = context.userInfo as? NetworkObserver {
+            previousObserver.cancel()
+        }
+
+        // User cancel the task. No retry.
+        guard !context.error.isTaskCancelled else {
+            retryHandler(.stop)
+            return
+        }
+
+        // Only retry for a response error.
+        guard case KingfisherError.responseError = context.error else {
+            retryHandler(.stop)
+            return
+        }
+
+        // Check if we have network connectivity
+        if networkMonitor.isConnected {
+            // Network is available, retry immediately
+            retryHandler(.retry(userInfo: nil))
+        } else {
+            // Network is not available, wait for reconnection
+            waitForReconnection(context: context, retryHandler: retryHandler)
+        }
+    }
+
+    // MARK: - Private helpers
+
+    private func waitForReconnection(
+        context: RetryContext,
+        retryHandler: @escaping @Sendable (RetryDecision) -> Void
+    ) {
+        let observer = networkMonitor.observeConnectivity(timeoutInterval: timeoutInterval) { [weak context] isConnected in
+            if isConnected {
+                // Connection is restored, retry immediately
+                retryHandler(.retry(userInfo: context?.userInfo))
+            } else {
+                // Timeout reached or cancelled
+                retryHandler(.stop)
+            }
+        }
+
+        // Store the observer in userInfo so it can be cancelled if needed
+        context.userInfo = observer
+    }
+}

+ 285 - 4
Tests/KingfisherTests/RetryStrategyTests.swift

@@ -102,6 +102,8 @@ class RetryStrategyTests: XCTestCase {
         waitForExpectations(timeout: 3, handler: nil)
     }
 
+    // MARK: - DelayRetryStrategy Tests
+
     func testDelayRetryStrategyExceededCount() {
         let exp = expectation(description: #function)
         let blockCalled: ActorArray<Bool> = ActorArray([])
@@ -110,7 +112,7 @@ class RetryStrategyTests: XCTestCase {
         let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))
 
         let group = DispatchGroup()
-        
+
         group.enter()
         let context1 = RetryContext(
             source: source,
@@ -146,7 +148,7 @@ class RetryStrategyTests: XCTestCase {
                 group.leave()
             }
         }
-        
+
         group.notify(queue: .main) {
             Task {
                 let result = await blockCalled.value
@@ -199,7 +201,7 @@ class RetryStrategyTests: XCTestCase {
                 group.leave()
             }
         }
-        
+
         group.notify(queue: .main) {
             Task {
                 let result = await blockCalled.value
@@ -232,9 +234,181 @@ class RetryStrategyTests: XCTestCase {
                 exp.fulfill()
             }
         }
-        
+
         waitForExpectations(timeout: 3, handler: nil)
     }
+
+    // MARK: - NetworkRetryStrategy Tests
+
+    func testNetworkRetryStrategyRetriesImmediatelyWhenConnected() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: true)
+        let retry = NetworkRetryStrategy(
+            timeoutInterval: 30,
+            networkMonitor: networkMonitor
+        )
+
+        let context = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.retry(let userInfo) = decision else {
+                XCTFail("The decision should be `retry` when network is connected")
+                return
+            }
+            XCTAssertNil(userInfo)
+            exp.fulfill()
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testNetworkRetryStrategyStopsForTaskCancelled() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: true)
+        let retry = NetworkRetryStrategy(
+            timeoutInterval: 30,
+            networkMonitor: networkMonitor
+        )
+
+        let task = URLSession.shared.dataTask(with: URL(string: "url")!)
+        let context = RetryContext(
+            source: source,
+            error: .requestError(reason: .taskCancelled(task: .init(task: task), token: .init()))
+        )
+
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The decision should be `stop` if user cancelled the task")
+                return
+            }
+            exp.fulfill()
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testNetworkRetryStrategyStopsForNonResponseError() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: true)
+        let retry = NetworkRetryStrategy(
+            timeoutInterval: 30,
+            networkMonitor: networkMonitor
+        )
+
+        let context = RetryContext(
+            source: source,
+            error: .cacheError(reason: .imageNotExisting(key: "any_key"))
+        )
+
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The decision should be `stop` if the error type is not response error")
+                return
+            }
+            exp.fulfill()
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testNetworkRetryStrategyWithTimeout() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: false)
+        let retry = NetworkRetryStrategy(timeoutInterval: 0.1, networkMonitor: networkMonitor)
+
+        let context = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+
+        // Test timeout behavior when network is disconnected
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The decision should be `stop` after timeout")
+                return
+            }
+            exp.fulfill()
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testNetworkRetryStrategyWaitsForReconnection() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: false)
+        let retry = NetworkRetryStrategy(
+            timeoutInterval: 30,
+            networkMonitor: networkMonitor
+        )
+
+        let context = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+
+        // Start retry when network is disconnected - should wait for reconnection
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.retry(let userInfo) = decision else {
+                XCTFail("The decision should be `retry` when network reconnects")
+                return
+            }
+            XCTAssertNotNil(userInfo) // Should contain the observer
+            exp.fulfill()
+        }
+
+        // Simulate network reconnection after a short delay
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+            networkMonitor.simulateNetworkChange(isConnected: true)
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testNetworkRetryStrategyCancelsPreviousObserver() {
+        let exp = expectation(description: #function)
+        let source = Source.network(URL(string: "url")!)
+        let networkMonitor = TestNetworkMonitor(isConnected: false)
+        let retry = NetworkRetryStrategy(
+            timeoutInterval: 30,
+            networkMonitor: networkMonitor
+        )
+
+        let context = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+
+        // First retry attempt - should create an observer
+        retry.retry(context: context) { decision in
+            // This should not be called since network is disconnected initially
+            XCTFail("First callback should not be called immediately when network is disconnected")
+        }
+
+        // Second retry attempt - should cancel previous observer
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.retry(let userInfo) = decision else {
+                XCTFail("The second decision should be `retry`")
+                return
+            }
+            XCTAssertNotNil(userInfo)
+            exp.fulfill()
+        }
+
+        // Simulate network reconnection
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+            networkMonitor.simulateNetworkChange(isConnected: true)
+        }
+
+        waitForExpectations(timeout: 1, handler: nil)
+    }
 }
 
 private struct E: Error {}
@@ -266,3 +440,110 @@ final class StubRetryStrategy: RetryStrategy, @unchecked Sendable {
         }
     }
 }
+
+// MARK: - Test Network Monitoring Implementations
+
+/// A test implementation of NetworkMonitoring that allows controlling network state for testing.
+final class TestNetworkMonitor: @unchecked Sendable, NetworkMonitoring {
+    private let queue = DispatchQueue(label: "com.onevcat.KingfisherTests.TestNetworkMonitor", attributes: .concurrent)
+    private var _isConnected: Bool
+    private var observers: [TestNetworkObserver] = []
+
+    var isConnected: Bool {
+        get { queue.sync { _isConnected } }
+        set { queue.sync(flags: .barrier) { _isConnected = newValue } }
+    }
+
+    init(isConnected: Bool = true) {
+        self._isConnected = isConnected
+    }
+
+    func observeConnectivity(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void) -> NetworkObserver {
+        let observer = TestNetworkObserver(
+            timeoutInterval: timeoutInterval,
+            callback: callback,
+            monitor: self
+        )
+
+        queue.sync(flags: .barrier) {
+            observers.append(observer)
+        }
+
+        return observer
+    }
+
+    /// Simulates network state change and notifies all observers.
+    func simulateNetworkChange(isConnected: Bool) {
+        queue.sync(flags: .barrier) {
+            _isConnected = isConnected
+            let activeObservers = observers
+            observers.removeAll()
+
+            DispatchQueue.main.async {
+                activeObservers.forEach { $0.notify(isConnected: isConnected) }
+            }
+        }
+    }
+
+    /// Removes an observer from the list.
+    func removeObserver(_ observer: TestNetworkObserver) {
+        queue.sync(flags: .barrier) {
+            observers.removeAll { $0 === observer }
+        }
+    }
+}
+
+/// Test implementation of NetworkObserver for testing purposes.
+final class TestNetworkObserver: @unchecked Sendable, NetworkObserver {
+    let timeoutInterval: TimeInterval?
+    let callback: @Sendable (Bool) -> Void
+    private weak var monitor: TestNetworkMonitor?
+    private var timeoutWorkItem: DispatchWorkItem?
+    private let queue = DispatchQueue(label: "com.onevcat.KingfisherTests.TestNetworkObserver", qos: .utility)
+
+    init(timeoutInterval: TimeInterval?, callback: @escaping @Sendable (Bool) -> Void, monitor: TestNetworkMonitor) {
+        self.timeoutInterval = timeoutInterval
+        self.callback = callback
+        self.monitor = monitor
+
+        // Set up timeout if specified
+        if let timeoutInterval = timeoutInterval {
+            let workItem = DispatchWorkItem { [weak self] in
+                self?.notify(isConnected: false)
+            }
+            timeoutWorkItem = workItem
+            queue.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
+        }
+    }
+
+    func notify(isConnected: Bool) {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout if we're notifying
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+
+            // Call the callback
+            DispatchQueue.main.async {
+                self.callback(isConnected)
+            }
+        }
+    }
+
+    func cancel() {
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            // Cancel timeout
+            timeoutWorkItem?.cancel()
+            timeoutWorkItem = nil
+
+            // Remove from monitor
+            monitor?.removeObserver(self)
+        }
+    }
+}