├── Package.resolved ├── Package.swift ├── .gitignore ├── Sources └── RetryingOperation │ ├── RetryHelper.swift │ ├── RetryingOperationConfig.swift │ ├── WrappedRetryingOperation.swift │ └── RetryingOperation.swift ├── Tests └── RetryingOperationTests │ ├── Helpers │ ├── BasicSynchronousRetryingOperation.swift │ ├── BasicAsynchronousRetryingOperation.swift │ ├── BasicSynchronousRetryableOperation.swift │ └── CustomRetrySynchronousRetryingOperation.swift │ └── RetryingOperationTests.swift ├── Readme.md └── License.txt /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-log", 6 | "repositoryURL": "https://github.com/apple/swift-log.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "173f567a2dfec11d74588eea82cecea555bdc0bc", 10 | "version": "1.4.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | import PackageDescription 3 | 4 | 5 | let package = Package( 6 | name: "RetryingOperation", 7 | products: [ 8 | .library(name: "RetryingOperation", targets: ["RetryingOperation"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/apple/swift-log.git", from: "1.2.0") 12 | ], 13 | targets: [ 14 | .target(name: "RetryingOperation", dependencies: [ 15 | .product(name: "Logging", package: "swift-log") 16 | ]), 17 | .testTarget(name: "RetryingOperationTests", dependencies: ["RetryingOperation"]) 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Finder and Xcode 2 | .DS_Store 3 | xcuserdata/ 4 | 5 | # The SPM build folder 6 | /.build/ 7 | # This folder is used by SPM when a package is put in “edit” mode (`swift 8 | # package edit some_package`): the edited package is moved in this folder, and 9 | # removed when the package is “unedited” (`swift package unedit some_package`). 10 | /Packages/ 11 | 12 | # Contains a bunch of stuff… should usually be ignored I think. Currently, as 13 | # far as I’m aware, it is used by Xcode 11 to put the autogenerated Xcode 14 | # project created and maintained by Xcode when opening a Package.swift file, and 15 | # by the `swift package config` command to store its config (currently, the only 16 | # config I’m aware of are the mirrors for downloading packages). 17 | /.swiftpm/ 18 | -------------------------------------------------------------------------------- /Sources/RetryingOperation/RetryHelper.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | 18 | 19 | 20 | public protocol RetryHelper { 21 | 22 | mutating func setup() 23 | mutating func teardown() 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Tests/RetryingOperationTests/Helpers/BasicSynchronousRetryingOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | import RetryingOperation 18 | 19 | 20 | 21 | final class BasicSynchronousRetryingOperation : RetryingOperation, @unchecked /*Probably*/Sendable { 22 | 23 | let nRetries: Int 24 | var checkStr = "" 25 | 26 | private var nStart = 0 27 | 28 | init(nRetries r: Int) { 29 | nRetries = r 30 | } 31 | 32 | override func startBaseOperation(isRetry: Bool) { 33 | nStart += 1 34 | checkStr += "." 35 | Thread.sleep(forTimeInterval: 0.25) 36 | if nStart <= nRetries {baseOperationEnded(needsRetryIn: 0.1)} 37 | else {baseOperationEnded()} 38 | } 39 | 40 | override var isAsynchronous: Bool { 41 | return false 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Tests/RetryingOperationTests/Helpers/BasicAsynchronousRetryingOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | import RetryingOperation 18 | 19 | 20 | 21 | final class BasicAsynchronousRetryingOperation : RetryingOperation, @unchecked /*Probably*/Sendable { 22 | 23 | let nRetries: Int 24 | var checkStr = "" 25 | 26 | private var nStart = 0 27 | 28 | init(nRetries r: Int) { 29 | nRetries = r 30 | } 31 | 32 | override func startBaseOperation(isRetry: Bool) { 33 | nStart += 1 34 | checkStr += "." 35 | DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 0.25) { 36 | if self.nStart <= self.nRetries {self.baseOperationEnded(needsRetryIn: 0.1)} 37 | else {self.baseOperationEnded()} 38 | } 39 | } 40 | 41 | override var isAsynchronous: Bool { 42 | return true 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Tests/RetryingOperationTests/Helpers/BasicSynchronousRetryableOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | import RetryingOperation 18 | 19 | 20 | 21 | final class BasicSynchronousRetryableOperation : Operation, RetryableOperation, @unchecked /*Probably*/Sendable { 22 | 23 | let nRetries: Int 24 | var checkStr: String 25 | 26 | private let nStart: Int 27 | 28 | required init(nRetries r: Int, nStart n: Int, checkStr str: String) { 29 | nStart = n 30 | nRetries = r 31 | checkStr = str 32 | } 33 | 34 | override func main() { 35 | checkStr += "." 36 | Thread.sleep(forTimeInterval: 0.25) 37 | } 38 | 39 | func retryHelpers(from wrapper: RetryableOperationWrapper) -> [RetryHelper]? { 40 | return nStart <= nRetries ? [RetryingOperation.TimerRetryHelper(retryDelay: 0.1, retryingOperation: wrapper)] : nil 41 | } 42 | 43 | func operationForRetrying() -> Self { 44 | return type(of: self).init(nRetries: nRetries, nStart: nStart + 1, checkStr: checkStr) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Tests/RetryingOperationTests/Helpers/CustomRetrySynchronousRetryingOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | import RetryingOperation 18 | 19 | 20 | 21 | final class CustomRetrySynchronousRetryingOperation : RetryingOperation, @unchecked /*Probably*/Sendable { 22 | 23 | var checkStr = "" 24 | let immediateCancellation: Bool 25 | let retryHelper: CustomRetryHelper /* Only used to test whether the retry helper is properly setup */ 26 | 27 | var hasEndedBaseOperation: Bool { 28 | return _hasEndedBaseOperationQueue.sync{ _hasEndedBaseOperation } 29 | } 30 | 31 | private var _hasEndedBaseOperation = false 32 | private var _hasEndedBaseOperationQueue = DispatchQueue(label: "has ended base operation sync queue") 33 | 34 | init(immediateCancellation c: Bool = false) { 35 | retryHelper = CustomRetryHelper() 36 | immediateCancellation = c 37 | 38 | super.init() 39 | } 40 | 41 | override func startBaseOperation(isRetry: Bool) { 42 | if immediateCancellation {cancel()} 43 | 44 | checkStr += "." 45 | Thread.sleep(forTimeInterval: 0.25) 46 | if !isRetry {self.baseOperationEnded(retryHelpers: [retryHelper])} /* The retry helper won’t do anything; the operation will be retrying from the test directly. */ 47 | else {self.baseOperationEnded()} 48 | 49 | _hasEndedBaseOperationQueue.sync{ _hasEndedBaseOperation = true } 50 | } 51 | 52 | override var isAsynchronous: Bool { 53 | return false 54 | } 55 | 56 | } 57 | 58 | 59 | class CustomRetryHelper : RetryHelper { 60 | 61 | var setupCheckStr = "" 62 | var teardownCheckStr = "" 63 | 64 | func setup() { 65 | setupCheckStr += "." 66 | } 67 | 68 | func teardown() { 69 | teardownCheckStr += "." 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Sources/RetryingOperation/RetryingOperationConfig.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | #if canImport(os) 18 | import os.log 19 | #endif 20 | 21 | import Logging 22 | 23 | 24 | 25 | /** 26 | The global configuration for RetryingOperation. 27 | 28 | You can modify all of the variables in this struct to change the default behavior of ``RetryingOperation``. 29 | Be careful, none of these properties are thread-safe. 30 | It is a good practice to change the behaviors you want when you start your app, and then leave the config alone. 31 | 32 | - Note: We allow the configuration for a generic `Logger` (from Apple’s swift-log repository), **and** an `OSLog` logger. 33 | We do this because Apple recommends using `OSLog` directly whenever possible for performance and privacy reason 34 | (see [swift-log’s Readme](https://github.com/apple/swift-log/blob/4f876718737f2c2b2ecd6d4cb4b99e0367b257a4/README.md) for more informations). 35 | 36 | The recommended configuration for Logging is to use `OSLog` when you can (you are on an Apple platform that supports `OSLog`) and `Logger` otherwise. 37 | You can also configure both if you want, though I’m not sure why that would be needed. 38 | 39 | In the future, OSLog’s API should be modified to match the swift-log’s one, and we’ll then probably drop the support for OSLog 40 | (because you’ll be able to use OSLog through Logging without any performance or privacy hit). */ 41 | public enum RetryingOperationConfig { 42 | 43 | #if canImport(os) 44 | @available(macOS 10.12, tvOS 10.0, iOS 10.0, watchOS 3.0, *) 45 | public static var oslog: OSLog? = .default 46 | #endif 47 | public static var logger: Logging.Logger? = { 48 | #if canImport(os) 49 | if #available(macOS 10.12, tvOS 10.0, iOS 10.0, watchOS 3.0, *) { 50 | return nil 51 | } 52 | #endif 53 | return Logger(label: "com.happn.RetryingOperation") 54 | }() 55 | 56 | } 57 | 58 | typealias Conf = RetryingOperationConfig 59 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Retrying Operations 2 | ![Platforms](https://img.shields.io/badge/platform-macOS%20|%20iOS%20|%20tvOS%20|%20watchOS%20|%20Linux-lightgrey.svg?style=flat) [![SPM compatible](https://img.shields.io/badge/SPM-compatible-E05C43.svg?style=flat)](https://swift.org/package-manager/) [![License](https://img.shields.io/github/license/happn-app/RetryingOperation.svg)](License.txt) [![happn](https://img.shields.io/badge/from-happn-0087B4.svg?style=flat)](https://happn.com) 3 | 4 | ## What Is It? 5 | 6 | An abstract class for retrying operations. 7 | The idea is to provide a clean and easy way to create retrying operations. 8 | For instance, if you make an operation to fetch some network resources, 9 | the operation might fail because there is no Internet right now. 10 | However, Internet might be back soon! 11 | Instead of bothering your user by telling him there’s no net and he should retry, 12 | you might want to wait a few seconds and retry the request(s). 13 | 14 | The retrying operation class gives you a way to easily handle the retrying process. 15 | 16 | _Note_: Cancelled operations are not retried. 17 | 18 | ## How to Use It? 19 | 20 | `RetryingOperation` is an abstract class. 21 | In order to use it you must subclass it. 22 | 23 | Usually, when subclassing `Operation`, you either subclass `start()`, `executing`, `finished` and `asynchronous` 24 | if you want to write an asynchronous operation, or simply `main()` for synchronous operations. 25 | 26 | To subclass `RetryingOperation` correctly, you only have to subclass `startBaseOperation()` and `asynchronous`. 27 | In your implementation, you are responsible for starting your operation, 28 | but you do not have to worry about managing the `executing` and `finished` properties of the operation: they are managed for you. 29 | 30 | When your operation is finished, you must call `baseOperationEnded()`. 31 | The parameters you pass to this method will determine whether the operation should be retried and the retry delay. 32 | The method must be called even if the operation is finished because the operation was cancelled 33 | (even though the retry parameter is ignored if the operation is cancelled). 34 | Indeed, if your operation is synchronous, the method must be called before the `startBaseOperation()` returns… 35 | 36 | When your operation is in the process of waiting for a retry, you can call `retryNow()` or `retry(withHelpers:)` 37 | to bypass the current retry helpers and either retry now or setup new helpers. 38 | _Note_: If the base operation is already running, never started or is finished when these methods are called, 39 | nothing is done, but a warning is printed in the logs. 40 | 41 | `startBaseOperation()` and `cancelBaseOperation()` will be called from the same, private GCD queue. 42 | Do **not** make any other assumptions thread-wise about these methods when you’re called. 43 | Also note you might not have a working run-loop. 44 | If you’re writing an asynchronous operation, you **must** leave the method as soon as possible, 45 | exactly like you’d do when overwriting `start()`. 46 | 47 | ## What About Operations I Don’t Own? 48 | 49 | Use case: I’m using a framework which provide nice operations. 50 | I would want to make these operations retryable, but I cannot make them inherit from `RetryingOperation` as I do not own them. 51 | What can I do? 52 | 53 | A solution is to use `RetryableOperationWrapper`. 54 | See the doc of this class for more information. 55 | 56 | ## Credits 57 | This project was originally created by [François Lamboley](https://github.com/Frizlab) while working at [happn](https://happn.com). 58 | -------------------------------------------------------------------------------- /Sources/RetryingOperation/WrappedRetryingOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | 18 | 19 | 20 | public protocol RetryableOperation : Operation { 21 | 22 | /* I’d like to add “where T : Self” so that clients of the protocol know ther're given an object kind of class Self, 23 | * but I get an error (swift4.2): 24 | * Type ‘T’ constrainted to non-protocol, non-class type ‘Self’ 25 | * 26 | * I could also remove the T type and set wrapper’s type to RetryableOperationWrapper, 27 | * but this forces the clients of the protocol to be final, so it is not ideal either… */ 28 | func retryHelpers(from wrapper: RetryableOperationWrapper) -> [RetryHelper]? 29 | 30 | /** Must return a valid retryable operation. You cannot return self here. */ 31 | func operationForRetrying() throws -> Self 32 | 33 | } 34 | 35 | 36 | /* About Sendability conformance: 37 | * - AFAIK Operation is (implicitly) Sendable (doc says “The NSOperation class is itself multicore aware. It is therefore safe to call the methods of an NSOperation object from multiple threads without creating additional locks to synchronize access to the object.”); 38 | * - RetryableOperationWrapper inherits from RetryingOperation which is in the same state (not explicitly marked as Sendable because it’s not final and we cannot guarantee all subclasses will stay Sendable, but thread-safe like Operation); 39 | * - OperationQueue is also implicitly Sendable (doc says “You can safely use a single OperationQueue object from multiple threads without creating additional locks to synchronize access to that object.”); 40 | * - We take care of not mutating anything from the wrapper outside of locks (we only modify stuff in startBaseOperation which is called by RetryingOperation). 41 | * All of this should be enough to say RetryableOperationWrapper is indeed Sendable when T is Sendable. 42 | * 43 | * See also https://forums.swift.org/t/sendable-in-foundation/59577 which states that Operation and OperationQueue (espcially OperationQueue) should definitely be Sendable. */ 44 | extension RetryableOperationWrapper : @unchecked Sendable where T : Sendable {} 45 | 46 | /** 47 | An operation that can run an operation conforming to the ``RetryableOperation`` protocol 48 | and retry the operation depending on the protocol implementation. */ 49 | public final class RetryableOperationWrapper : RetryingOperation where T : RetryableOperation { 50 | 51 | public let originalBaseOperation: T 52 | public private(set) var currentBaseOperation: T 53 | 54 | /** 55 | The queue on which the base operation(s) will run. 56 | Do not set to the queue on which the retry operation wrapper runs unless you really know what you're doing. 57 | 58 | If `nil` (default), the base operation will not be launched in a queue. */ 59 | public let baseOperationQueue: OperationQueue? 60 | 61 | /** If `< 0`, the operation is retried indefinitely. */ 62 | public let maximumNumberOfRetries: Int 63 | 64 | public init(maximumNumberOfRetries maxRetry: Int = -1, baseOperation: T, baseOperationQueue queue: OperationQueue? = nil) { 65 | maximumNumberOfRetries = maxRetry 66 | 67 | originalBaseOperation = baseOperation 68 | currentBaseOperation = baseOperation 69 | 70 | baseOperationQueue = queue 71 | } 72 | 73 | public override func startBaseOperation(isRetry: Bool) { 74 | /* No need to call super. */ 75 | 76 | if isRetry { 77 | guard let op: T = try? currentBaseOperation.operationForRetrying() else {return baseOperationEnded()} 78 | assert(!op.isFinished && !op.isExecuting) /* Basic checks on operation to verify it is valid. */ 79 | currentBaseOperation = op 80 | } 81 | 82 | if let q = baseOperationQueue {q.addOperation(currentBaseOperation)} 83 | else {currentBaseOperation.start()} 84 | currentBaseOperation.waitUntilFinished() 85 | 86 | let canRetry = (maximumNumberOfRetries < 0 || numberOfRetries! < maximumNumberOfRetries) 87 | self.baseOperationEnded(retryHelpers: canRetry ? currentBaseOperation.retryHelpers(from: self) : nil) 88 | } 89 | 90 | public override func cancelBaseOperation() { 91 | currentBaseOperation.cancel() 92 | } 93 | 94 | public override var isAsynchronous: Bool { 95 | return false 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Tests/RetryingOperationTests/RetryingOperationTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import XCTest 17 | @testable import RetryingOperation 18 | 19 | 20 | 21 | class RetryingOperationTests : XCTestCase { 22 | 23 | let operationQueue = OperationQueue() 24 | 25 | override func setUp() { 26 | super.setUp() 27 | 28 | operationQueue.maxConcurrentOperationCount = 1 29 | } 30 | 31 | override func tearDown() { 32 | operationQueue.cancelAllOperations() 33 | operationQueue.waitUntilAllOperationsAreFinished() 34 | 35 | super.tearDown() 36 | } 37 | 38 | func testBasicSynchronousRetryingOperationNoRetries() { 39 | let op = BasicSynchronousRetryingOperation(nRetries: 0) 40 | operationQueue.addOperation(op) 41 | operationQueue.waitUntilAllOperationsAreFinished() 42 | XCTAssertEqual(op.checkStr, ".") 43 | } 44 | 45 | func testBasicAsynchronousRetryingOperationNoRetries() { 46 | let op = BasicAsynchronousRetryingOperation(nRetries: 0) 47 | operationQueue.addOperation(op) 48 | operationQueue.waitUntilAllOperationsAreFinished() 49 | XCTAssertEqual(op.checkStr, ".") 50 | } 51 | 52 | func testBasicSynchronousRetryableOperationInWrapperNoRetries() { 53 | let op = BasicSynchronousRetryableOperation(nRetries: 0, nStart: 1, checkStr: "") 54 | let rop = RetryableOperationWrapper(baseOperation: op, baseOperationQueue: nil) 55 | operationQueue.addOperation(rop) 56 | operationQueue.waitUntilAllOperationsAreFinished() 57 | XCTAssertEqual(rop.currentBaseOperation.checkStr, ".") 58 | } 59 | 60 | func testBasicSynchronousRetryingOperation1Retry() { 61 | let op = BasicSynchronousRetryingOperation(nRetries: 1) 62 | operationQueue.addOperation(op) 63 | operationQueue.waitUntilAllOperationsAreFinished() 64 | XCTAssertEqual(op.checkStr, "..") 65 | } 66 | 67 | func testBasicAsynchronousRetryingOperation1Retry() { 68 | let op = BasicAsynchronousRetryingOperation(nRetries: 1) 69 | operationQueue.addOperation(op) 70 | operationQueue.waitUntilAllOperationsAreFinished() 71 | XCTAssertEqual(op.checkStr, "..") 72 | } 73 | 74 | func testBasicSynchronousRetryableOperationInWrapper1Retry() { 75 | let op = BasicSynchronousRetryableOperation(nRetries: 1, nStart: 1, checkStr: "") 76 | let rop = RetryableOperationWrapper(baseOperation: op, baseOperationQueue: nil) 77 | operationQueue.addOperation(rop) 78 | operationQueue.waitUntilAllOperationsAreFinished() 79 | XCTAssertEqual(rop.currentBaseOperation.checkStr, "..") 80 | } 81 | 82 | func testCancelledBasicSynchronousRetryableOperationInWrapper1Retry() { 83 | let op = BasicSynchronousRetryableOperation(nRetries: 1, nStart: 1, checkStr: "") 84 | let rop = RetryableOperationWrapper(baseOperation: op, baseOperationQueue: nil) 85 | op.cancel() 86 | operationQueue.addOperation(rop) 87 | operationQueue.waitUntilAllOperationsAreFinished() 88 | XCTAssertEqual(rop.currentBaseOperation.checkStr, ".") 89 | } 90 | 91 | func testCustomRetrySynchronousRetryingOperation() { 92 | let op = CustomRetrySynchronousRetryingOperation() 93 | operationQueue.addOperation(op) 94 | /* There are probably cleverer ways to do this, but we don’t care about optimizing anything here; we’re in a test... */ 95 | DispatchQueue(label: "launch retry queue").async{ 96 | var hasRetried = false 97 | while !hasRetried { 98 | Thread.sleep(forTimeInterval: 0.1) 99 | if op.hasEndedBaseOperation { 100 | hasRetried = true 101 | op.retryNow() 102 | } 103 | } 104 | } 105 | operationQueue.waitUntilAllOperationsAreFinished() 106 | XCTAssertEqual(op.checkStr, "..") 107 | XCTAssertEqual(op.retryHelper.setupCheckStr, ".") 108 | XCTAssertEqual(op.retryHelper.teardownCheckStr, ".") 109 | } 110 | 111 | func testCustomRetryCancelledSynchronousRetryingOperation() { 112 | let op = CustomRetrySynchronousRetryingOperation(immediateCancellation: true) 113 | operationQueue.addOperation(op) 114 | operationQueue.waitUntilAllOperationsAreFinished() 115 | XCTAssertEqual(op.checkStr, ".") 116 | XCTAssertEqual(op.retryHelper.setupCheckStr, "") 117 | XCTAssertEqual(op.retryHelper.teardownCheckStr, "") 118 | } 119 | 120 | func testCustomRetryCancelledSynchronousRetryingOperationBis() { 121 | let op = CustomRetrySynchronousRetryingOperation() 122 | operationQueue.addOperation(op) 123 | /* There are probably cleverer ways to do this, but we don’t care about optimizing anything here; we’re in a test... */ 124 | DispatchQueue(label: "launch retry queue").async{ 125 | var hasCancelled = false 126 | while !hasCancelled { 127 | Thread.sleep(forTimeInterval: 0.1) 128 | if op.hasEndedBaseOperation { 129 | hasCancelled = true 130 | op.cancel() 131 | } 132 | } 133 | } 134 | operationQueue.waitUntilAllOperationsAreFinished() 135 | XCTAssertEqual(op.checkStr, ".") 136 | XCTAssertEqual(op.retryHelper.setupCheckStr, ".") 137 | XCTAssertEqual(op.retryHelper.teardownCheckStr, ".") 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Sources/RetryingOperation/RetryingOperation.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 happn 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. */ 15 | 16 | import Foundation 17 | #if canImport(os) 18 | import os.log 19 | #endif 20 | 21 | import Logging 22 | 23 | 24 | 25 | /** 26 | # Retrying Operations 27 | 28 | ## What Is It? 29 | 30 | An abstract class for retrying operations. 31 | The idea is to provide a clean and easy way to create retrying operations. 32 | For instance, if you make an operation to fetch some network resources, 33 | the operation might fail because there is no Internet right now. 34 | However, Internet might be back soon! 35 | Instead of bothering your user by telling him there’s no net and he should retry, 36 | you might want to wait a few seconds and retry the request(s). 37 | 38 | The retrying operation class gives you a way to easily handle the retrying process. 39 | 40 | _Note_: Cancelled operations are not retried. 41 | 42 | ## How to Use It? 43 | 44 | `RetryingOperation` is an abstract class. 45 | In order to use it you must subclass it. 46 | 47 | Usually, when subclassing `Operation`, you either subclass `start()`, `executing`, `finished` and `asynchronous` 48 | if you want to write an asynchronous operation, or simply `main()` for synchronous operations. 49 | 50 | To subclass `RetryingOperation` correctly, you only have to subclass `startBaseOperation()` and `asynchronous`. 51 | In your implementation, you are responsible for starting your operation, 52 | but you do not have to worry about managing the `executing` and `finished` properties of the operation: they are managed for you. 53 | 54 | When your operation is finished, you must call `baseOperationEnded()`. 55 | The parameters you pass to this method will determine whether the operation should be retried and the retry delay. 56 | The method must be called even if the operation is finished because the operation was cancelled 57 | (even though the retry parameter is ignored if the operation is cancelled). 58 | Indeed, if your operation is synchronous, the method must be called before the `startBaseOperation()` returns… 59 | 60 | When your operation is in the process of waiting for a retry, you can call `retryNow()` or `retry(withHelpers:)` 61 | to bypass the current retry helpers and either retry now or setup new helpers. 62 | _Note_: If the base operation is already running, never started or is finished when these methods are called, 63 | nothing is done, but a warning is printed in the logs. 64 | 65 | `startBaseOperation()` and `cancelBaseOperation()` will be called from the same, private GCD queue. 66 | Do **not** make any other assumptions thread-wise about these methods when you’re called. 67 | Also note you might not have a working run-loop. 68 | If you’re writing an asynchronous operation, you **must** leave the method as soon as possible, 69 | exactly like you’d do when overwriting `start()`. 70 | 71 | ## What About Operations I Don’t Own? 72 | 73 | Use case: I’m using a framework which provide nice operations. 74 | I would want to make these operations retryable, but I cannot make them inherit from `RetryingOperation` as I do not own them. 75 | What can I do? 76 | 77 | A solution is to use `RetryableOperationWrapper`. 78 | See the doc of this class for more information. */ 79 | open class RetryingOperation : Operation { 80 | 81 | public var numberOfRetries: Int? { 82 | retryStateSemaphore.wait(); defer {retryStateSemaphore.signal()} 83 | return retryingState.numberOfRetries 84 | } 85 | 86 | deinit { 87 | #if canImport(os) 88 | if #available(macOS 10.12, tvOS 10.0, iOS 10.0, watchOS 3.0, *) { 89 | Conf.oslog.flatMap{ os_log("Deiniting retrying operation %{public}@", log: $0, type: .debug, String(describing: Unmanaged.passUnretained(self).toOpaque())) }} 90 | #endif 91 | Conf.logger?.debug("Deiniting retrying operation \(String(describing: Unmanaged.passUnretained(self).toOpaque()))") 92 | } 93 | 94 | /** 95 | Is the operation synchronous or asynchronous. **Must** be overwritten by subclasses. 96 | 97 | Rationale for forcing overwrite: 98 | RetryingOperations are _much_ easier than standard Operations to create, in particular asynchronous operations. 99 | By default an `Operation` is synchronous, which, with no modifications on the `isAsynchronous` property, would make a RetryingOperation synchronous by default too. 100 | This is not such a good behavior; we prefer forcing subclassers to explicitely say whether they’re creating a synchronous or an asynchronous operation. */ 101 | open override var isAsynchronous: Bool { 102 | fatalError("isAsynchronous is abstract on a RetryingOperation") 103 | } 104 | 105 | public final override func start() { 106 | if !isAsynchronous {super.start()} 107 | else { 108 | /* We are in an asynchronous operation, we must start the operation ourselves. */ 109 | retryQueue.async{ self._startBaseOperationOnQueue(isRetry: false) } 110 | } 111 | } 112 | 113 | /* Note: The synchronous implementation deserves a little more tests. 114 | * Currently it is only very lightly tested with a few unit tests. */ 115 | public final override func main() { 116 | assert(!isAsynchronous) 117 | 118 | /* Sets the retryHelpers instance var to the given helpers minus the timer retry helpers. 119 | * Returns the time to wait, inferred from the removed timer retry helpers. 120 | * Must be called on retry queue. */ 121 | func setFilteredRetryHelpers(helpers: [RetryHelper]?) -> TimeInterval?? { 122 | guard let helpers = helpers else { 123 | retryHelpers = nil 124 | return nil 125 | } 126 | 127 | var timeToWait: TimeInterval? 128 | var filteredHelpers = [RetryHelper]() 129 | for helper in helpers { 130 | switch helper { 131 | case let timerHelper as TimerRetryHelper: 132 | /* The Timer Retry Helper is handled differently for optimization and wait time precision. */ 133 | if let t = timeToWait {timeToWait = min(t, timerHelper.delay)} 134 | else {timeToWait = timerHelper.delay} 135 | 136 | default: filteredHelpers.append(helper) 137 | } 138 | } 139 | retryHelpers = filteredHelpers 140 | return .some(timeToWait) 141 | } 142 | 143 | var isRetry = false 144 | var shouldRetry = true 145 | while shouldRetry { 146 | /* The variable below is: 147 | * - .none if there should not be any retry (op is finished); 148 | * - .some(.none) if there should be a retry, but no retry helper were a timer retry helper; 149 | * - .some(.some) if there should be a retry and there was a timer retry helper. 150 | * The value of the time interval will be the time to wait. */ 151 | var timeToWaitAndShouldRetry: TimeInterval?? = retryQueue.sync{ 152 | /* First we teardown any previous helper if any. */ 153 | _ = setFilteredRetryHelpers(helpers: nil) 154 | 155 | /* We start the base operation here. 156 | * Because the operation is sync, at the next line syncOperationRetryHelpers will have been set to the new retry helpers. */ 157 | _startBaseOperationOnQueue(isRetry: isRetry); assert(!isBaseOperationRunning) 158 | 159 | let ret = (!isCancelled ? syncOperationRetryHelpers : nil); syncOperationRetryHelpers = nil 160 | if ret != nil {retryingState = .waitingToRetry(nRetries)} 161 | return setFilteredRetryHelpers(helpers: ret) 162 | } 163 | 164 | shouldRetry = (timeToWaitAndShouldRetry != nil) 165 | while let timeToWait = timeToWaitAndShouldRetry { 166 | timeToWaitAndShouldRetry = nil 167 | 168 | let startWaitTime = Date() 169 | repeat { 170 | Thread.sleep(forTimeInterval: timeToWait.map{ max(0, min(0.5, $0 + startWaitTime.timeIntervalSinceNow)) } ?? 0.5) 171 | 172 | let shouldRefreshTimeToWait: Bool = retryQueue.sync{ 173 | let cancelled = isCancelled 174 | guard syncRefreshRetryHelpers || cancelled else {return false} 175 | 176 | timeToWaitAndShouldRetry = setFilteredRetryHelpers(helpers: !cancelled ? syncOperationRetryHelpers : nil) 177 | shouldRetry = shouldRetry && !cancelled 178 | syncOperationRetryHelpers = nil 179 | syncRefreshRetryHelpers = false 180 | return true 181 | } 182 | guard !shouldRefreshTimeToWait else {break} 183 | } while timeToWait.map{ -startWaitTime.timeIntervalSinceNow < $0 } ?? true 184 | } 185 | isRetry = true 186 | } 187 | retryingState = .finished 188 | } 189 | 190 | public final func retryNow() { 191 | retry(withHelpers: nil) 192 | } 193 | 194 | public final func retry(in delay: TimeInterval) { 195 | retry(withHelpers: [TimerRetryHelper(retryDelay: delay, retryingOperation: self)]) 196 | } 197 | 198 | /** 199 | - Warning: If you call this method with an empty array of retry helpers, the base operation will never be retrying (that is until retry is called again). 200 | But if you call this method with `nil`, the base operation is retried *now*. */ 201 | public final func retry(withHelpers helpers: [RetryHelper]?) { 202 | retryQueue.async{ self._unsafeRetry(withHelpers: helpers) } 203 | } 204 | 205 | /* ********************** 206 |   MARK: - For Subclasses 207 |   ********************** */ 208 | 209 | /** 210 | The entry point for subclasses. 211 | If your operation is not asynchronous, the operation must have finished by the time this method returns (`baseOperationEnded()` must have been called). 212 | 213 | It is valid to call `baseOperationEnded` from the start operation. 214 | (For sync operations it is actually required.) 215 | 216 | - Note: Do **NOT** call this manually, neither from a subclass or when using a retrying operation. 217 | I would have liked the method to be protected, but protected does not exist in Swift. */ 218 | open func startBaseOperation(isRetry: Bool) { 219 | } 220 | 221 | /** 222 | The cancellation point for subclasses. **Never** overwrite `cancel()` (actually you can’t). 223 | When this method is called, the `isCancelled` property of the operation is guaranteed to be `true`. 224 | 225 | Be sure to handle gracefully the cases where you’re called here even after you’ve called `baseOperationEnded`. 226 | This should not happen _in general_, but because of race condition, it is _possible_ that it does. 227 | 228 | In general you should not have to overwrite this for synchronous operations 229 | (you should instead check the isCancelled property regularly). 230 | The method will be called anyways; you can overwrite it even for synchronous operations. 231 | 232 | - Note: Do **NOT** call this manually, neither from a subclass or when using a retrying operation. 233 | I would have liked the method to be protected, but protected does not exist in Swift. */ 234 | open func cancelBaseOperation() { 235 | } 236 | 237 | public final func baseOperationEnded() { 238 | baseOperationEnded(retryHelpers: nil) 239 | } 240 | 241 | public final func baseOperationEnded(needsRetryIn delay: TimeInterval) { 242 | baseOperationEnded(retryHelpers: [TimerRetryHelper(retryDelay: delay, retryingOperation: self)]) 243 | } 244 | 245 | /** 246 | Subclasses **must** call this method when their base operation ends (or one of the derivative above). 247 | You can call them from any thread you want. 248 | 249 | - Note: Would have liked to be protected, but protected does not exist in Swift. */ 250 | public final func baseOperationEnded(retryHelpers: [RetryHelper]?) { 251 | /* For synchronous operations, the retrying is handled directly in main(). 252 | * We do NOT dispatch on the retry queue as we should already be on it! */ 253 | guard isAsynchronous else { 254 | assert(isExecuting && isBaseOperationRunning) 255 | syncOperationRetryHelpers = retryHelpers 256 | isBaseOperationRunning = false 257 | return 258 | } 259 | 260 | retryQueue.async{ 261 | assert(self.isExecuting && self.isBaseOperationRunning) 262 | self.isBaseOperationRunning = false 263 | 264 | guard !self.isCancelled, let retryHelpers = retryHelpers else { 265 | self.retryingState = .finished 266 | return 267 | } 268 | 269 | /* We need retrying the base operation. */ 270 | self.retryingState = .waitingToRetry(self.nRetries) 271 | self.retryHelpers = retryHelpers 272 | } 273 | } 274 | 275 | /* Since iOS 11, releasing a timer that has never been resumed crash. 276 | * So we need to set this entity as a class instead of a struct so we can have a “hasBeenResumed” var, 277 | * modified in `setup()` without a “mutating” modifier on the method… */ 278 | public class TimerRetryHelper : RetryHelper { 279 | 280 | public init(retryDelay d: TimeInterval, retryingOperation: RetryingOperation) { 281 | delay = d 282 | 283 | timer = DispatchSource.makeTimerSource(flags: [], queue: retryingOperation.retryQueue) 284 | timer.setEventHandler{ retryingOperation._unsafeRetry(withHelpers: nil) } 285 | /* We schedule the timer in setup. */ 286 | } 287 | 288 | deinit { 289 | timer.setEventHandler(handler: nil) 290 | /* On iOS 11, releasing a timer that has never been resumed will crash. */ 291 | if #available(iOS 11.0, *), !hasBeenResumed {timer.resume(); timer.cancel()} 292 | } 293 | 294 | public func setup() { 295 | timer.schedule(deadline: .now() + delay, leeway: .milliseconds(250)) 296 | timer.resume() 297 | hasBeenResumed = true 298 | } 299 | 300 | public func teardown() { 301 | timer.cancel() 302 | } 303 | 304 | private var hasBeenResumed = false 305 | 306 | private let timer: DispatchSourceTimer 307 | fileprivate let delay: TimeInterval /* For synchronous operations... */ 308 | 309 | } 310 | 311 | /* *************** 312 |   MARK: - Private 313 |   *************** */ 314 | 315 | private enum State : Sendable { 316 | 317 | case inited 318 | case running(Int) /* The value is the number of retries (0 for first try) */ 319 | case waitingToRetry(Int) /* The value is the number of retries already done (0 for first wait) */ 320 | case finished 321 | 322 | var isFinished: Bool { 323 | switch self { 324 | case .finished: return true 325 | default: return false 326 | } 327 | } 328 | 329 | var isWaitingToRetry: Bool { 330 | switch self { 331 | case .waitingToRetry: return true 332 | default: return false 333 | } 334 | } 335 | 336 | var isRunningOrWaitingToRetry: Bool { 337 | switch self { 338 | case .running: return true 339 | case .waitingToRetry: return true 340 | default: return false 341 | } 342 | } 343 | 344 | var numberOfRetries: Int? { 345 | switch self { 346 | case .running(let n): return n 347 | case .waitingToRetry(let n): return n 348 | default: return nil 349 | } 350 | } 351 | 352 | } 353 | 354 | private var nRetries = 0 355 | 356 | private var retryHelpers: [RetryHelper]? { 357 | willSet {retryHelpers?.forEach{ var mut = $0; mut.teardown() }} 358 | didSet {retryHelpers = retryHelpers?.map{ var ret = $0; ret.setup(); return ret }} 359 | } 360 | 361 | private let retryStateSemaphore = DispatchSemaphore(value: 1) 362 | private let retryQueue = DispatchQueue(label: "Queue for Syncing Retries (for One Retrying Operation)", qos: .utility) 363 | 364 | /* Only used for synchronous operations. */ 365 | private var syncRefreshRetryHelpers = false 366 | private var syncOperationRetryHelpers: [RetryHelper]? 367 | 368 | private var retryingState = State.inited { 369 | willSet(newState) { 370 | willChangeValue(forKey: "retryingState") 371 | 372 | if isAsynchronous { 373 | /* https://github.com/apple/swift-corelibs-foundation/blob/swift-4.1.2-RELEASE/Foundation/Operation.swift 374 | * On Linux, the Foundation implementation differs from the close-source one we got on Apple’s platforms. 375 | * Mainly, the original implementation expected changes to isExecuting and isFinished to be KVO-compliant 376 | * and relied heavily on these notifications to act on the state of the operation. 377 | * In pure Swift (no ObjC-runtime), KVO is not available (yet?). 378 | * So a workaround has been implemented to mimic the old behaviour. 379 | * However this (among other implementation details) results in a dispatch_leave_group method to be called twice 380 | * (which triggers an assert and makes the program crash) for synchronous operations when we manually managed these properties. 381 | * So for synchronous operations, the isExecuting and isFinished properties are managed by Operation itself. */ 382 | let newStateExecuting = newState.isRunningOrWaitingToRetry 383 | let oldStateExecuting = retryingState.isRunningOrWaitingToRetry 384 | let newStateFinished = newState.isFinished 385 | let oldStateFinished = retryingState.isFinished 386 | 387 | if newStateExecuting != oldStateExecuting {willChangeValue(forKey: "isExecuting")} 388 | if newStateFinished != oldStateFinished {willChangeValue(forKey: "isFinished")} 389 | } 390 | 391 | retryStateSemaphore.wait() 392 | } 393 | didSet(oldState) { 394 | retryStateSemaphore.signal() 395 | 396 | if isAsynchronous { 397 | let newStateExecuting = retryingState.isRunningOrWaitingToRetry 398 | let oldStateExecuting = oldState.isRunningOrWaitingToRetry 399 | let newStateFinished = retryingState.isFinished 400 | let oldStateFinished = oldState.isFinished 401 | 402 | if newStateFinished != oldStateFinished {didChangeValue(forKey: "isFinished")} 403 | if newStateExecuting != oldStateExecuting {didChangeValue(forKey: "isExecuting")} 404 | } 405 | 406 | didChangeValue(forKey: "retryingState") 407 | } 408 | } 409 | 410 | private func _startBaseOperationOnQueue(isRetry: Bool) { 411 | assert(!isBaseOperationRunning) 412 | 413 | guard !isCancelled else { 414 | retryingState = .finished 415 | return 416 | } 417 | 418 | if isRetry {nRetries += 1} 419 | retryingState = .running(nRetries) 420 | isBaseOperationRunning = true 421 | 422 | startBaseOperation(isRetry: isRetry) 423 | } 424 | 425 | private func _unsafeRetry(withHelpers helpers: [RetryHelper]?) { 426 | guard retryingState.isWaitingToRetry else { 427 | #if canImport(os) 428 | if #available(macOS 10.12, tvOS 10.0, iOS 10.0, watchOS 3.0, *) { 429 | Conf.oslog.flatMap{ os_log("Trying to force retry operation %{public}p which is not waiting to be retried...", log: $0, self) } 430 | Conf.oslog.flatMap{ os_log(" Don’t worry it might be normal (race in retry helpers). Doing nothing. FYI, current status is %{public}@.", log: $0, String(describing: retryingState)) }} 431 | #endif 432 | Conf.logger?.info("Trying to force retry operation \(String(describing: Unmanaged.passUnretained(self).toOpaque())) which is not waiting to be retried...") 433 | Conf.logger?.info(" Don’t worry it might be normal (race in retry helpers). Doing nothing. FYI, current status is \(String(describing: retryingState)).") 434 | return 435 | } 436 | 437 | if isAsynchronous { 438 | retryHelpers = helpers /* Tears-down previous helpers and setup new ones... */ 439 | if helpers == nil {_startBaseOperationOnQueue(isRetry: true)} /* ...but does not start the base operation if helpers is nil. */ 440 | } else { 441 | syncRefreshRetryHelpers = true 442 | syncOperationRetryHelpers = helpers 443 | } 444 | } 445 | 446 | public final override func cancel() { 447 | super.cancel() 448 | guard isAsynchronous else {cancelBaseOperation(); return} 449 | 450 | retryQueue.async{ 451 | if self.retryHelpers != nil { 452 | assert(self.retryingState.isWaitingToRetry) 453 | 454 | /* Tears-down the retry helpers. */ 455 | self.retryHelpers = nil 456 | 457 | /* If we’re waiting for a retry, the base operation will never notify us we’re over, 458 | * it’s up to us to say the operation has ended. */ 459 | self.retryingState = .finished 460 | } else { 461 | self.cancelBaseOperation() 462 | } 463 | } 464 | } 465 | 466 | private var isBaseOperationRunning = false 467 | 468 | public final override var isExecuting: Bool { 469 | guard isAsynchronous else {return super.isExecuting} 470 | 471 | retryStateSemaphore.wait(); defer {retryStateSemaphore.signal()} 472 | return retryingState.isRunningOrWaitingToRetry 473 | } 474 | 475 | public final override var isFinished: Bool { 476 | guard isAsynchronous else {return super.isFinished} 477 | 478 | retryStateSemaphore.wait(); defer {retryStateSemaphore.signal()} 479 | return retryingState.isFinished 480 | } 481 | 482 | } 483 | --------------------------------------------------------------------------------