Prechádzať zdrojové kódy

Merge pull request #1447 from onevcat/feature/retry

Feature retry
Wei Wang 5 rokov pred
rodič
commit
4b10dec77a

+ 8 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -24,6 +24,7 @@
 		4BE688F722FD513100B11168 /* NSButton+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12AB6AD215D2BB50013BA68 /* NSButton+Kingfisher.swift */; };
 		4BE688F822FD513700B11168 /* WKInterfaceImage+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12AB6AF215D2BB50013BA68 /* WKInterfaceImage+Kingfisher.swift */; };
 		C9286407228584EB00257182 /* ImageProgressive.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9286406228584EB00257182 /* ImageProgressive.swift */; };
+		D11D9B72245FA6F700C5A0AE /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11D9B71245FA6F700C5A0AE /* RetryStrategy.swift */; };
 		D12AB6C0215D2BB50013BA68 /* RequestModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12AB69D215D2BB50013BA68 /* RequestModifier.swift */; };
 		D12AB6C4215D2BB50013BA68 /* Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12AB69E215D2BB50013BA68 /* Resource.swift */; };
 		D12AB6C8215D2BB50013BA68 /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */; };
@@ -104,6 +105,7 @@
 		D1E564412199C21E0057AAE3 /* StorageExpirationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E564402199C21E0057AAE3 /* StorageExpirationTests.swift */; };
 		D1E56445219B16330057AAE3 /* ImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E56444219B16330057AAE3 /* ImageDataProvider.swift */; };
 		D1ED2D401AD2D09F00CFC3EB /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */; };
+		D1F1F6FF24625EC600910725 /* RetryStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */; };
 		D1F7606E230974DE000C5269 /* Kingfisher.h in Headers */ = {isa = PBXBuildFile; fileRef = D12AB6AA215D2BB50013BA68 /* Kingfisher.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		D1F7607723097533000C5269 /* ImageBinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F7607523097532000C5269 /* ImageBinder.swift */; };
 		D1F7607823097533000C5269 /* KFImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F7607623097532000C5269 /* KFImage.swift */; };
@@ -142,6 +144,7 @@
 		4BD821662189FD330084CC21 /* SessionDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDataTask.swift; sourceTree = "<group>"; };
 		C9286406228584EB00257182 /* ImageProgressive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProgressive.swift; sourceTree = "<group>"; };
 		C959EEE7228940FE00467A10 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
+		D11D9B71245FA6F700C5A0AE /* RetryStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategy.swift; sourceTree = "<group>"; };
 		D12AB69D215D2BB50013BA68 /* RequestModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestModifier.swift; sourceTree = "<group>"; };
 		D12AB69E215D2BB50013BA68 /* Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = "<group>"; };
 		D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = "<group>"; };
@@ -256,6 +259,7 @@
 		D1E56444219B16330057AAE3 /* ImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ImageDataProvider.swift; path = Sources/General/ImageSource/ImageDataProvider.swift; sourceTree = SOURCE_ROOT; };
 		D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Kingfisher.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		D1ED2D3F1AD2D09F00CFC3EB /* KingfisherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KingfisherTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategyTests.swift; sourceTree = "<group>"; };
 		D1F76072230974DE000C5269 /* KingfisherSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KingfisherSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		D1F7607523097532000C5269 /* ImageBinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageBinder.swift; sourceTree = "<group>"; };
 		D1F7607623097532000C5269 /* KFImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KFImage.swift; sourceTree = "<group>"; };
@@ -338,6 +342,7 @@
 				4B10480C216F157000300C61 /* ImageDataProcessor.swift */,
 				D12AB6A0215D2BB50013BA68 /* ImageModifier.swift */,
 				D12AB6A1215D2BB50013BA68 /* ImagePrefetcher.swift */,
+				D11D9B71245FA6F700C5A0AE /* RetryStrategy.swift */,
 			);
 			path = Networking;
 			sourceTree = "<group>";
@@ -446,6 +451,7 @@
 				D1A1CC9E21A0F98600263AD8 /* ImageDataProviderTests.swift */,
 				D1BFED94222ACC6B009330C8 /* ImageProcessorTests.swift */,
 				4BA3BF1D228BCDD100909201 /* DataReceivingSideEffectTests.swift */,
+				D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */,
 			);
 			name = KingfisherTests;
 			path = Tests/KingfisherTests;
@@ -822,6 +828,7 @@
 				4B10480D216F157000300C61 /* ImageDataProcessor.swift in Sources */,
 				D12AB72C215D2BB50013BA68 /* Indicator.swift in Sources */,
 				D12AB6C8215D2BB50013BA68 /* ImageDownloader.swift in Sources */,
+				D11D9B72245FA6F700C5A0AE /* RetryStrategy.swift in Sources */,
 				D1A37BE3215D359F009B39B7 /* ImageFormat.swift in Sources */,
 				D12AB714215D2BB50013BA68 /* ImageCache.swift in Sources */,
 				D12AB6D0215D2BB50013BA68 /* ImagePrefetcher.swift in Sources */,
@@ -887,6 +894,7 @@
 				D16FEA3E23078C63006E67D5 /* LSNocilla.m in Sources */,
 				D16FEA4D23078C63006E67D5 /* LSMatcher.m in Sources */,
 				4B8351C8217066580081EED8 /* StubHelpers.swift in Sources */,
+				D1F1F6FF24625EC600910725 /* RetryStrategyTests.swift in Sources */,
 				D1DC4B411D60996D00DFDFAA /* StringExtensionTests.swift in Sources */,
 				D1BFED95222ACC6B009330C8 /* ImageProcessorTests.swift in Sources */,
 				D16FEA5223078C63006E67D5 /* LSHTTPRequestDSLRepresentation.m in Sources */,

+ 68 - 22
Sources/General/KingfisherManager.swift

@@ -206,34 +206,80 @@ public class KingfisherManager {
         downloadTaskUpdated: DownloadTaskUpdatedBlock? = nil,
         completionHandler: ((Result<RetrieveImageResult, KingfisherError>) -> Void)?) -> DownloadTask?
     {
-        var context = RetrievingContext(options: options, originalSource: source)
+        var retrievingContext = RetrievingContext(options: options, originalSource: source)
+        var retryContext: RetryContext?
+
+        func startNewRetrieveTask(
+            with source: Source,
+            downloadTaskUpdated: DownloadTaskUpdatedBlock?
+        ) {
+            let newTask = self.retrieveImage(with: source, context: retrievingContext) { result in
+                handler(currentSource: source, result: result)
+            }
+            downloadTaskUpdated?(newTask)
+        }
+
+        func failCurrentSource(_ source: Source, with error: KingfisherError) {
+            // Skip alternative sources if the user cancelled it.
+            guard !error.isTaskCancelled else {
+                completionHandler?(.failure(error))
+                return
+            }
+            if let nextSource = retrievingContext.popAlternativeSource() {
+                startNewRetrieveTask(with: nextSource, downloadTaskUpdated: downloadTaskUpdated)
+            } else {
+                // No other alternative source. Finish with error.
+                if retrievingContext.propagationErrors.isEmpty {
+                    completionHandler?(.failure(error))
+                } else {
+                    retrievingContext.appendError(error, to: source)
+                    let finalError = KingfisherError.imageSettingError(
+                        reason: .alternativeSourcesExhausted(retrievingContext.propagationErrors)
+                    )
+                    completionHandler?(.failure(finalError))
+                }
+            }
+        }
 
         func handler(currentSource: Source, result: (Result<RetrieveImageResult, KingfisherError>)) -> Void {
             switch result {
             case .success:
                 completionHandler?(result)
             case .failure(let error):
-                // Skip alternative sources if the user cancelled it.
-                guard !error.isTaskCancelled else {
-                    completionHandler?(.failure(error))
-                    return
-                }
-                if let nextSource = context.popAlternativeSource() {
-                    context.appendError(error, to: currentSource)
-                    let newTask = self.retrieveImage(with: nextSource, context: context) { result in
-                        handler(currentSource: nextSource, result: result)
+                if let retryStrategy = options.retryStrategy {
+                    let context = retryContext?.increaseRetryCount() ?? RetryContext(source: source, error: error)
+                    retryContext = context
+
+                    retryStrategy.retry(context: context) { decision in
+                        switch decision {
+                        case .retry(let userInfo):
+                            retryContext?.userInfo = userInfo
+                            startNewRetrieveTask(with: source, downloadTaskUpdated: downloadTaskUpdated)
+                        case .stop:
+                            failCurrentSource(currentSource, with: error)
+                        }
                     }
-                    downloadTaskUpdated?(newTask)
                 } else {
-                    // No other alternative source. Finish with error.
-                    if context.propagationErrors.isEmpty {
+
+                    // Skip alternative sources if the user cancelled it.
+                    guard !error.isTaskCancelled else {
                         completionHandler?(.failure(error))
+                        return
+                    }
+                    if let nextSource = retrievingContext.popAlternativeSource() {
+                        retrievingContext.appendError(error, to: currentSource)
+                        startNewRetrieveTask(with: nextSource, downloadTaskUpdated: downloadTaskUpdated)
                     } else {
-                        context.appendError(error, to: currentSource)
-                        let finalError = KingfisherError.imageSettingError(
-                            reason: .alternativeSourcesExhausted(context.propagationErrors)
-                        )
-                        completionHandler?(.failure(finalError))
+                        // No other alternative source. Finish with error.
+                        if retrievingContext.propagationErrors.isEmpty {
+                            completionHandler?(.failure(error))
+                        } else {
+                            retrievingContext.appendError(error, to: currentSource)
+                            let finalError = KingfisherError.imageSettingError(
+                                reason: .alternativeSourcesExhausted(retrievingContext.propagationErrors)
+                            )
+                            completionHandler?(.failure(finalError))
+                        }
                     }
                 }
             }
@@ -241,7 +287,7 @@ public class KingfisherManager {
 
         return retrieveImage(
             with: source,
-            context: context)
+            context: retrievingContext)
         {
             result in
             handler(currentSource: source, result: result)
@@ -596,7 +642,7 @@ public class KingfisherManager {
     }
 }
 
-struct RetrievingContext {
+class RetrievingContext {
 
     var options: KingfisherParsedOptionsInfo
 
@@ -608,7 +654,7 @@ struct RetrievingContext {
         self.options = options
     }
 
-    mutating func popAlternativeSource() -> Source? {
+    func popAlternativeSource() -> Source? {
         guard var alternativeSources = options.alternativeSources, !alternativeSources.isEmpty else {
             return nil
         }
@@ -618,7 +664,7 @@ struct RetrievingContext {
     }
 
     @discardableResult
-    mutating func appendError(_ error: KingfisherError, to source: Source) -> [PropagationError] {
+    func appendError(_ error: KingfisherError, to source: Source) -> [PropagationError] {
         let item = PropagationError(source: source, error: error)
         propagationErrors.append(item)
         return propagationErrors

+ 4 - 0
Sources/General/KingfisherOptionsInfo.swift

@@ -240,6 +240,8 @@ public enum KingfisherOptionsInfoItem {
     ///
     /// User cancellation will not trigger the alternative source loading.
     case alternativeSources([Source])
+
+    case retryStrategy(RetryStrategy)
 }
 
 // Improve performance by parsing the input `KingfisherOptionsInfo` (self) first.
@@ -282,6 +284,7 @@ public struct KingfisherParsedOptionsInfo {
     public var processingQueue: CallbackQueue? = nil
     public var progressiveJPEG: ImageProgressive? = nil
     public var alternativeSources: [Source]? = nil
+    public var retryStrategy: RetryStrategy? = nil
 
     var onDataReceived: [DataReceivingSideEffect]? = nil
     
@@ -323,6 +326,7 @@ public struct KingfisherParsedOptionsInfo {
             case .processingQueue(let queue): processingQueue = queue
             case .progressiveJPEG(let value): progressiveJPEG = value
             case .alternativeSources(let sources): alternativeSources = sources
+            case .retryStrategy(let strategy): retryStrategy = strategy
             }
         }
 

+ 117 - 0
Sources/Networking/RetryStrategy.swift

@@ -0,0 +1,117 @@
+//
+//  RetryStrategy.swift
+//  Kingfisher
+//
+//  Created by onevcat on 2020/05/04.
+//
+//  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 Foundation
+
+public class RetryContext {
+
+    public let source: Source
+
+    public let error: KingfisherError
+    public var retriedCount: Int
+
+    public internal(set) var userInfo: Any? = nil
+
+    init(source: Source, error: KingfisherError) {
+        self.source = source
+        self.error = error
+        self.retriedCount = 0
+    }
+
+    @discardableResult
+    func increaseRetryCount() -> RetryContext {
+        retriedCount += 1
+        return self
+    }
+}
+
+public enum RetryDecision {
+    case retry(userInfo: Any?)
+    case stop
+}
+
+public protocol RetryStrategy {
+    func retry(context: RetryContext, retryHandler: @escaping (RetryDecision) -> Void)
+}
+
+public struct DelayRetryStrategy: RetryStrategy {
+
+    public enum Interval {
+        case seconds(TimeInterval)
+        case accumulated(TimeInterval)
+        case custom(block: (_ retriedCount: Int) -> TimeInterval)
+
+        func timeInterval(for retriedCount: Int) -> TimeInterval {
+            let retryAfter: TimeInterval
+            switch self {
+            case .seconds(let interval):
+                retryAfter = interval
+            case .accumulated(let interval):
+                retryAfter = Double(retriedCount + 1) * interval
+            case .custom(let block):
+                retryAfter = block(retriedCount)
+            }
+            return retryAfter
+        }
+    }
+
+    public let maxRetryCount: Int
+    public let retryInterval: Interval
+
+    public init(maxRetryCount: Int, retryInterval: Interval = .seconds(3)) {
+        self.maxRetryCount = maxRetryCount
+        self.retryInterval = retryInterval
+    }
+
+    public func retry(context: RetryContext, retryHandler: @escaping (RetryDecision) -> Void) {
+        // Retry count exceeded.
+        guard context.retriedCount < maxRetryCount else {
+            retryHandler(.stop)
+            return
+        }
+
+        // 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
+        }
+
+        let interval = retryInterval.timeInterval(for: context.retriedCount)
+        if interval == 0 {
+            retryHandler(.retry(userInfo: nil))
+        } else {
+            DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
+                retryHandler(.retry(userInfo: nil))
+            }
+        }
+    }
+}

+ 1 - 1
Tests/KingfisherTests/KingfisherManagerTests.swift

@@ -699,7 +699,7 @@ class KingfisherManagerTests: XCTestCase {
             .network(URL(string: "2")!)
         ]
         let info = KingfisherParsedOptionsInfo([.alternativeSources(allSources)])
-        var context = RetrievingContext(
+        let context = RetrievingContext(
             options: info, originalSource: .network(URL(string: "0")!))
 
         let source1 = context.popAlternativeSource()

+ 6 - 1
Tests/KingfisherTests/KingfisherOptionsInfoTests.swift

@@ -89,7 +89,8 @@ class KingfisherOptionsInfoTests: XCTestCase {
             .keepCurrentImageWhileLoading,
             .onlyLoadFirstFrame,
             .cacheOriginalImage,
-            .alternativeSources([alternativeSource])
+            .alternativeSources([alternativeSource]),
+            .retryStrategy(DelayRetryStrategy(maxRetryCount: 10))
         ])
         
         XCTAssertTrue(options.targetCache === cache)
@@ -127,6 +128,10 @@ class KingfisherOptionsInfoTests: XCTestCase {
         XCTAssertTrue(options.cacheOriginalImage)
         XCTAssertEqual(options.alternativeSources?.count, 1)
         XCTAssertEqual(options.alternativeSources?.first?.url, alternativeSource.url)
+
+        let retry = options.retryStrategy as? DelayRetryStrategy
+        XCTAssertNotNil(retry)
+        XCTAssertEqual(retry?.maxRetryCount, 10)
     }
     
     func testOptionCouldBeOverwritten() {

+ 221 - 0
Tests/KingfisherTests/RetryStrategyTests.swift

@@ -0,0 +1,221 @@
+//
+//  RetryStrategyTests.swift
+//  Kingfisher
+//
+//  Created by onevcat on 2020/05/06.
+//
+//  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 XCTest
+@testable import Kingfisher
+
+class RetryStrategyTests: XCTestCase {
+
+    var manager: KingfisherManager!
+
+    override class func setUp() {
+        super.setUp()
+        LSNocilla.sharedInstance().start()
+    }
+
+    override class func tearDown() {
+        LSNocilla.sharedInstance().stop()
+        super.tearDown()
+    }
+
+    override func setUpWithError() throws {
+        try super.setUpWithError()
+        let uuid = UUID()
+        let downloader = ImageDownloader(name: "test.manager.\(uuid.uuidString)")
+        let cache = ImageCache(name: "test.cache.\(uuid.uuidString)")
+
+        manager = KingfisherManager(downloader: downloader, cache: cache)
+        manager.defaultOptions = [.waitForCache]
+    }
+
+    override func tearDownWithError() throws {
+        LSNocilla.sharedInstance().clearStubs()
+        clearCaches([manager.cache])
+        cleanDefaultCache()
+        manager = nil
+        try super.tearDownWithError()
+    }
+
+    func testCanCreateRetryStrategy() {
+        let strategy = DelayRetryStrategy(maxRetryCount: 10, retryInterval: .seconds(5))
+        XCTAssertEqual(strategy.maxRetryCount, 10)
+        XCTAssertEqual(strategy.retryInterval.timeInterval(for: 0), 5)
+    }
+
+
+    func testDelayRetryIntervalCalculating() {
+        let secondInternal = DelayRetryStrategy.Interval.seconds(10)
+        XCTAssertEqual(secondInternal.timeInterval(for: 0), 10)
+
+        let accumulatedInternal = DelayRetryStrategy.Interval.accumulated(3)
+        XCTAssertEqual(accumulatedInternal.timeInterval(for: 0), 3)
+        XCTAssertEqual(accumulatedInternal.timeInterval(for: 1), 6)
+        XCTAssertEqual(accumulatedInternal.timeInterval(for: 2), 9)
+        XCTAssertEqual(accumulatedInternal.timeInterval(for: 3), 12)
+
+        let customInternal = DelayRetryStrategy.Interval.custom { TimeInterval($0 * 2) }
+        XCTAssertEqual(customInternal.timeInterval(for: 0), 0)
+        XCTAssertEqual(customInternal.timeInterval(for: 1), 2)
+        XCTAssertEqual(customInternal.timeInterval(for: 2), 4)
+        XCTAssertEqual(customInternal.timeInterval(for: 3), 6)
+    }
+
+    func testKingfisherManagerCanRetry() {
+        let exp = expectation(description: #function)
+
+        let brokenURL = URL(string: "brokenurl")!
+        stub(brokenURL, data: Data())
+
+        let retry = StubRetryStrategy()
+
+        _ = manager.retrieveImage(
+            with: .network(brokenURL),
+            options: [.retryStrategy(retry)],
+            completionHandler: { result in
+                XCTAssertEqual(retry.count, 3)
+                exp.fulfill()
+            }
+        )
+        waitForExpectations(timeout: 1, handler: nil)
+    }
+
+    func testDelayRetryStrategyExceededCount() {
+
+        var blockCalled: [Bool] = []
+
+        let source = Source.network(URL(string: "url")!)
+        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))
+
+        let context1 = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+        retry.retry(context: context1) { decision in
+            guard case RetryDecision.retry(let userInfo) = decision else {
+                XCTFail("The deicision should be `retry`")
+                return
+            }
+            XCTAssertNil(userInfo)
+            blockCalled.append(true)
+        }
+
+        let context2 = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+        context2.increaseRetryCount() // 1
+        context2.increaseRetryCount() // 2
+        context2.increaseRetryCount() // 3
+        retry.retry(context: context2) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The deicision should be `stop`")
+                return
+            }
+            blockCalled.append(true)
+        }
+
+        XCTAssertEqual(blockCalled.count, 2)
+        XCTAssertTrue(blockCalled.allSatisfy { $0 })
+    }
+
+    func testDelayRetryStrategyNotRetryForErrorReason() {
+        // Only non-user cancel error && response error should be retied.
+        var blockCalled: [Bool] = []
+        let source = Source.network(URL(string: "url")!)
+        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))
+        let context1 = RetryContext(
+            source: source,
+            error: .requestError(reason: .taskCancelled(task: .init(task: .init()), token: .init()))
+        )
+
+        retry.retry(context: context1) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The decision should be `stop` if user cancelled the task.")
+                return
+            }
+            blockCalled.append(true)
+        }
+
+        let context2 = RetryContext(
+            source: source,
+            error: .cacheError(reason: .imageNotExisting(key: "any_key"))
+        )
+        retry.retry(context: context2) { decision in
+            guard case RetryDecision.stop = decision else {
+                XCTFail("The decision should be `stop` if the error type is not response error.")
+                return
+            }
+            blockCalled.append(true)
+        }
+
+        XCTAssertEqual(blockCalled.count, 2)
+        XCTAssertTrue(blockCalled.allSatisfy { $0 })
+    }
+
+    func testDelayRetryStrategyDidRetried() {
+        var called = false
+        let source = Source.network(URL(string: "url")!)
+        let retry = DelayRetryStrategy(maxRetryCount: 3, retryInterval: .seconds(0))
+        let context = RetryContext(
+            source: source,
+            error: .responseError(reason: .URLSessionError(error: E()))
+        )
+        retry.retry(context: context) { decision in
+            guard case RetryDecision.retry = decision else {
+                XCTFail("The decision should be `retry`.")
+                return
+            }
+            called = true
+        }
+
+        XCTAssertTrue(called)
+    }
+}
+
+private struct E: Error {}
+
+class StubRetryStrategy: RetryStrategy {
+
+    var count = 0
+
+    func retry(context: RetryContext, retryHandler: @escaping (RetryDecision) -> Void) {
+
+        if count == 0 {
+            XCTAssertNil(context.userInfo)
+        } else {
+            XCTAssertEqual(context.userInfo as! Int, count)
+        }
+
+        XCTAssertEqual(context.retriedCount, count)
+
+        count += 1
+        if count == 3 {
+            retryHandler(.stop)
+        } else {
+            retryHandler(.retry(userInfo: count))
+        }
+    }
+}