diff --git a/README.md b/README.md index 0cec1f8..7065ff3 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ let coldRetrier = withExponentialBackoff() .retryOnErrors { $0 is MyTmpError } - // All giveUp / retry modifiers are evaluated in reversed order. ``` +**All giveUp / retry modifiers are evaluated in reversed order.** + [Exponential backoff](https://aws.amazon.com/fr/blogs/architecture/exponential-backoff-and-jitter/) with full jitter is the default and recommended algorithm to fetch from a backend. @@ -98,48 +99,6 @@ the completion - Retriers expose `successPublisher()`, `failurePublisher()` and `completionPublisher()` shortcuts. - You can use `publisher(propagateCancellation: true)` to cancel the retrier when you're done listening to it. -## Without the main DSL - -### `withRetries()` functions - -You may prefer to use a function to more directly access either the `async` value of your job, or the success publisher -of your repeating job. - -In this case you can use the `withRetries()` functions. - -Their first argument is the `policy`. It: -- handles delays and failure criteria -- defaults to `Policy.exponentialBackoff()` -- can be built using the `Policy` entry point - -```swift -let policy = Policy.exponentialBackoff().giveUpAfter(maxAttempts: 12) -let value = try await withRetries(policy: policy) { try await fetchSomething() } -// You can add an extra `attemptFailureHandler` block to log attempt errors. -// If the task executing the concurrency context is cancelled, the underlying retrier will be canceled. - -withRetries(policy: Policy.exponentialBackoff(), repeatDelay: 10) { try await fetchSomething() } - .success() // If you're not interested in all events, just use .success() - .sink { - print("Got a value: \($0), let's rest 10s now") - } -// You can set `propagateCancellation` to `true` to cancel the underlying retrier when you're done listening to the -// success publisher. -``` - -Note that `conditionPublisher` is an optional argument to make the execution conditional. - -### `retrier()` functions - -Use the shortcut `retrier()` functions to build a hot retrier in one line and keep full control on it. They have -the almost same arguments as `withRetries()` and they return an executing retrier. - -### Actual retrier classes - -Finally, you can also use the classes initializers directly, namely `SimpleRetrier`, -`ConditionalRetrier` and `SimpleRepeater`. - - ## Retriers contract - All retriers are cancellable. @@ -169,26 +128,17 @@ When repeating, the policy is reused from start after each success. ### Built-in retry policies -```swift -Policy.exponentialBackoff() -Policy.constantDelay() -Policy.noDelay() -``` - -**Exponential backoff** policy is implemented according to state of the art algorithms. -Have a look to the available arguments and you'll recognize the standard parameters and options. +**ExponentialBackoffRetryPolicy** is implemented according to state-of-the-art algorithms. +Have a look to the available arguments, and you'll recognize the standard parameters and options. You can especially choose the jitter type between `none`, `full` (default) and `decorrelated`. -**Constant delay** policy does what you expect, just waiting for a fixed amount of time. +**ConstantDelayRetryPolicy** does what you expect, just waiting for a fixed amount of time. -**No delay** policy is a constant delay policy with a `0` delay. - -In a fallible context, you can add failure conditions using -`giveUp*()` functions, and bypass these conditions using `retry*()` functions. +You can add failure conditions using `giveUp*()` functions, and bypass these conditions using `retry*()` functions. All giveUp / retry modifiers are evaluated in reversed order. -### Home made policy +### Homemade policy You can create your own policies that conform `RetryPolicy` and they will benefit from the same modifiers. Have a look at `ConstantDelayRetryPolicy.swift` for a basic example. @@ -196,15 +146,20 @@ Have a look at `ConstantDelayRetryPolicy.swift` for a basic example. To create a DSL entry point using your policy: ```swift -public func withMyOwnPolicy() -> ColdInfallibleRetrier { +public func withMyOwnPolicy() -> ColdRetrier { let policy = MyOwnPolicy() - return ColdInfallibleRetrier(policy: policy, conditionPublisher: nil) + return ColdRetrier(policy: policy, conditionPublisher: nil) } ``` +## Actual retrier classes + +You can use the classes initializers directly, namely `SimpleRetrier`, +`ConditionalRetrier` and `Repeater`. + ## Contribute -Feel free to make any comment, criticism, bug report or feature request using Github issues. +Feel free to make any comment, criticism, bug report or feature request using GitHub issues. You can also directly send me an email at `pierre` *strange "a" with a long round tail* `pittscraft.com`. ## License diff --git a/Sources/SwiftRetrier/Core/Model/Retriers/Repeater.swift b/Sources/SwiftRetrier/Core/Model/Retriers/Repeater.swift deleted file mode 100644 index 7cb2c89..0000000 --- a/Sources/SwiftRetrier/Core/Model/Retriers/Repeater.swift +++ /dev/null @@ -1 +0,0 @@ -protocol Repeater: Retrier {} diff --git a/Sources/SwiftRetrier/Core/Model/Retriers/SingleOutputConditionalRetrier.swift b/Sources/SwiftRetrier/Core/Model/Retriers/SingleOutputConditionalRetrier.swift deleted file mode 100644 index 57c9738..0000000 --- a/Sources/SwiftRetrier/Core/Model/Retriers/SingleOutputConditionalRetrier.swift +++ /dev/null @@ -1 +0,0 @@ -protocol SingleOutputConditionalRetrier: SingleOutputRetrier {} diff --git a/Sources/SwiftRetrier/Core/Retriers/ConditionalRetrier.swift b/Sources/SwiftRetrier/Core/Retriers/ConditionalRetrier.swift index 0bd48cd..a08aeaa 100644 --- a/Sources/SwiftRetrier/Core/Retriers/ConditionalRetrier.swift +++ b/Sources/SwiftRetrier/Core/Retriers/ConditionalRetrier.swift @@ -20,7 +20,7 @@ import Combine /// /// If the condition publisher completes and it had not emitted any value or the last value it emitted was `false` /// then the retrier emits a completion embedding `RetryError.conditionPublisherCompleted` and finishes. -public class ConditionalRetrier: SingleOutputRetrier, SingleOutputConditionalRetrier { +public class ConditionalRetrier: SingleOutputRetrier { private let policy: RetryPolicy private let job: Job diff --git a/Sources/SwiftRetrier/Core/Retriers/SimpleRepeater.swift b/Sources/SwiftRetrier/Core/Retriers/Repeater.swift similarity index 98% rename from Sources/SwiftRetrier/Core/Retriers/SimpleRepeater.swift rename to Sources/SwiftRetrier/Core/Retriers/Repeater.swift index f8d8a52..65ef724 100644 --- a/Sources/SwiftRetrier/Core/Retriers/SimpleRepeater.swift +++ b/Sources/SwiftRetrier/Core/Retriers/Repeater.swift @@ -22,7 +22,7 @@ import Combine /// ``` /// /// On cancellation, the publisher emits a completion embedding a `CancellationError`then finishes. -public class SimpleRepeater: Repeater, Retrier { +public class Repeater: Retrier { private let retrierBuilder: () -> AnySingleOutputRetrier private var retrier: AnySingleOutputRetrier? diff --git a/Sources/SwiftRetrier/DSL/ColdRepeater.swift b/Sources/SwiftRetrier/DSL/ColdRepeater.swift index f548d49..312566a 100644 --- a/Sources/SwiftRetrier/DSL/ColdRepeater.swift +++ b/Sources/SwiftRetrier/DSL/ColdRepeater.swift @@ -42,12 +42,12 @@ public extension ColdRepeater { } @discardableResult - func execute(_ job: @escaping Job) -> SimpleRepeater { - SimpleRepeater(policy: policy, repeatDelay: repeatDelay, job: job) + func execute(_ job: @escaping Job) -> Repeater { + Repeater(policy: policy, repeatDelay: repeatDelay, job: job) } @discardableResult - func callAsFunction(_ job: @escaping Job) -> SimpleRepeater { + func callAsFunction(_ job: @escaping Job) -> Repeater { execute(job) } } diff --git a/Sources/SwiftRetrier/Policy.swift b/Sources/SwiftRetrier/Policy.swift deleted file mode 100644 index 3768819..0000000 --- a/Sources/SwiftRetrier/Policy.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public struct Policy { - public static func exponentialBackoff( - timeSlot: TimeInterval = ExponentialBackoffConstants.defaultTimeSlot, - maxDelay: TimeInterval = ExponentialBackoffConstants.defaultMaxDelay, - jitter: ExponentialBackoffRetryPolicy.Jitter = ExponentialBackoffConstants.defaultJitter - ) -> ExponentialBackoffRetryPolicy { - ExponentialBackoffRetryPolicy(timeSlot: timeSlot, - maxDelay: maxDelay, - jitter: jitter) - } - - public static func constantDelay( - _ delay: TimeInterval = ConstantDelayConstants.defaultDelay - ) -> ConstantDelayRetryPolicy { - ConstantDelayRetryPolicy(delay: delay) - } - - public static func noDelay() -> ConstantDelayRetryPolicy { - constantDelay(0) - } -} diff --git a/Sources/SwiftRetrier/RetrierFunctions.swift b/Sources/SwiftRetrier/RetrierFunctions.swift deleted file mode 100644 index 0a28965..0000000 --- a/Sources/SwiftRetrier/RetrierFunctions.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import Combine - -public func retrier>( - policy: RetryPolicy = Policy.exponentialBackoff(), - conditionPublisher: P? = nil as AnyPublisher?, - job: @escaping Job -) -> AnySingleOutputRetrier { - if let conditionPublisher { - return ConditionalRetrier(policy: policy, conditionPublisher: conditionPublisher, job: job) - .eraseToAnySingleOutputRetrier() - } - return SimpleRetrier(policy: policy, job: job) - .eraseToAnySingleOutputRetrier() -} - -public func retrier>( - policy: RetryPolicy = Policy.exponentialBackoff(), - conditionPublisher: P? = nil as AnyPublisher?, - repeatDelay: TimeInterval, - job: @escaping Job -) -> SimpleRepeater { - SimpleRepeater(policy: policy, conditionPublisher: conditionPublisher, repeatDelay: repeatDelay, job: job) -} diff --git a/Sources/SwiftRetrier/WithRetriesFunctions.swift b/Sources/SwiftRetrier/WithRetriesFunctions.swift deleted file mode 100644 index 335fcb6..0000000 --- a/Sources/SwiftRetrier/WithRetriesFunctions.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import Combine - -private func subscribeAndAwait( - retrier: R, - attemptFailureHandler: ((AttemptFailure) -> Void)? -) async throws -> R.Output where R: SingleOutputRetrier { - var cancellables = Set() - if let attemptFailureHandler { - retrier.failurePublisher() - .sink(receiveCompletion: { _ in }, - receiveValue: attemptFailureHandler) - .store(in: &cancellables) - } - do { - let result = try await retrier.cancellableValue - cancellables.removeAll() - return result - } catch { - cancellables.removeAll() - throw error - } -} - -public func withRetries>( - policy: RetryPolicy = Policy.exponentialBackoff(), - onlyWhen conditionPublisher: P? = nil as AnyPublisher?, - attemptFailureHandler: ((AttemptFailure) -> Void)? = nil, - job: @escaping Job -) async throws -> Value { - try await subscribeAndAwait(retrier: retrier(policy: policy, - conditionPublisher: conditionPublisher, - job: job), - attemptFailureHandler: attemptFailureHandler) -} - -public func withRetries>( - policy: RetryPolicy = Policy.exponentialBackoff(), - repeatEvery repeatDelay: TimeInterval, - propagateCancellation: Bool = false, - onlyWhen conditionPublisher: P? = nil as AnyPublisher?, - job: @escaping Job -) -> AnyPublisher, Never> { - retrier(policy: policy, - conditionPublisher: conditionPublisher, - repeatDelay: repeatDelay, - job: job) - .publisher(propagateCancellation: propagateCancellation) -} diff --git a/Tests/SwiftRetrierTests/Common.swift b/Tests/SwiftRetrierTests/Common.swift index 37913de..0f97a80 100644 --- a/Tests/SwiftRetrierTests/Common.swift +++ b/Tests/SwiftRetrierTests/Common.swift @@ -29,12 +29,12 @@ func taskWait(_ time: TimeInterval = defaultWaitingTime) async throws { try await Task.sleep(nanoseconds: nanoseconds(time)) } -extension Policy { +enum Policy { static func testDefault(maxAttempts: UInt = UInt.max) -> RetryPolicy { - constantDelay(defaultRetryDelay).giveUpAfter(maxAttempts: maxAttempts) + ConstantDelayRetryPolicy(delay: defaultRetryDelay).giveUpAfter(maxAttempts: maxAttempts) } static func testDefault() -> RetryPolicy { - constantDelay(defaultRetryDelay) + ConstantDelayRetryPolicy(delay: defaultRetryDelay) } } diff --git a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputConditionalRetrierTests.swift b/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputConditionalRetrierTests.swift index 36316cc..f5dfefc 100644 --- a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputConditionalRetrierTests.swift +++ b/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputConditionalRetrierTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import SwiftRetrier import Combine -class SingleOutputConditionalRetrierTests: XCTestCase { +class SingleOutputConditionalRetrierTests: XCTestCase { var retrier: ((AnyPublisher, Job) -> R)! diff --git a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputFallibleRetrierTests.swift b/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputFallibleRetrierTests.swift index 193af99..6b8fde9 100644 --- a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputFallibleRetrierTests.swift +++ b/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/SingleOutputFallibleRetrierTests.swift @@ -21,7 +21,7 @@ class SingleOutputFallibleRetrierTests: XCTestCase { @MainActor func test_async_value_throws_on_trial_failure() async { - let retrier = buildRetrier(Policy.constantDelay().giveUpAfter(maxAttempts: 1), immediateFailureJob) + let retrier = buildRetrier(Policy.testDefault().giveUpAfter(maxAttempts: 1), immediateFailureJob) do { _ = try await retrier.value XCTFail("Unexpected success") diff --git a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/RepeaterTests.swift b/Tests/SwiftRetrierTests/Retriers/RepeaterTests.swift similarity index 57% rename from Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/RepeaterTests.swift rename to Tests/SwiftRetrierTests/Retriers/RepeaterTests.swift index 98a3648..4c77e45 100644 --- a/Tests/SwiftRetrierTests/Retriers/ProtocolBasedTests/RepeaterTests.swift +++ b/Tests/SwiftRetrierTests/Retriers/RepeaterTests.swift @@ -2,12 +2,29 @@ import Foundation import XCTest @testable import SwiftRetrier -class RepeaterTests: XCTestCase { - var retrier: ((TimeInterval, @escaping Job) -> R)! +// swiftlint:disable type_name +class Repeater_RetrierTests: RetrierTests> { + override func setUp() { + self.retrier = { + Repeater(policy: Policy.testDefault(), repeatDelay: 100, job: $0) + } + } +} + +class Repeater_FallibleRetrierTests: FallibleRetrierTests> { + override func setUp() { + self.retrier = { + Repeater(policy: $0, repeatDelay: 100, job: $1) + } + } +} + +class RepeaterTests: XCTestCase { + var retrier: ((TimeInterval, @escaping Job) -> Repeater)! - private var instance: R? + private var instance: Repeater? - func buildRetrier(_ repeatDelay: TimeInterval, _ job: @escaping Job) -> R { + func buildRetrier(_ repeatDelay: TimeInterval, _ job: @escaping Job) -> Repeater { let retrier = retrier(repeatDelay, job) instance = retrier return retrier @@ -39,3 +56,4 @@ class RepeaterTests: XCTestCase { } } } +// swiftlint:enable type_name diff --git a/Tests/SwiftRetrierTests/Retriers/SimpleRepeaterTests.swift b/Tests/SwiftRetrierTests/Retriers/SimpleRepeaterTests.swift deleted file mode 100644 index fbd8aa0..0000000 --- a/Tests/SwiftRetrierTests/Retriers/SimpleRepeaterTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import XCTest -@testable import SwiftRetrier - -// swiftlint:disable type_name -class Repeater_RetrierTests: RetrierTests> { - override func setUp() { - self.retrier = { - SimpleRepeater(policy: Policy.testDefault(), repeatDelay: 100, job: $0) - } - } -} - -class Repeater_FallibleRetrierTests: FallibleRetrierTests> { - override func setUp() { - self.retrier = { - SimpleRepeater(policy: $0, repeatDelay: 100, job: $1) - } - } -} - -class Repeater_RepeaterTests: RepeaterTests> { - override func setUp() { - self.retrier = { - SimpleRepeater(policy: Policy.testDefault(), repeatDelay: $0, job: $1) - } - } -} -// swiftlint:enable type_name diff --git a/Tests/SwiftRetrierTests/Retriers/WithRetriesFunctionsTests.swift b/Tests/SwiftRetrierTests/Retriers/WithRetriesFunctionsTests.swift deleted file mode 100644 index 03219a3..0000000 --- a/Tests/SwiftRetrierTests/Retriers/WithRetriesFunctionsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import XCTest -@testable import SwiftRetrier - -class WithRetriesFunctionsTests: XCTestCase { - - func testAttemptFailureHandlerCalled() { - let expectation = expectation(description: "Attempt failure handler called") - Task { - let policy = Policy - .testDefault() - .giveUpAfter(maxAttempts: 1) - try await withRetries(policy: policy, - attemptFailureHandler: { _ in - expectation.fulfill() - }, - job: immediateFailureJob) - } - wait(for: [expectation], timeout: defaultSequenceWaitingTime) - } - - func testRepeatSubscriptionCancellationPropagated() { - _ = withRetries(policy: Policy.testDefault(), - repeatEvery: 0.1, - propagateCancellation: true, - job: { - DispatchQueue.main.sync { - XCTFail("Job should not be called") - } - }) - .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) - _ = XCTWaiter.wait(for: [expectation(description: "Wait for some time")], timeout: defaultSequenceWaitingTime) - } -}