Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,24 @@ on:

jobs:
build:
runs-on: macos-13
runs-on: macos-14

steps:
- uses: maxim-lobanov/setup-xcode@v1
- uses: swift-actions/setup-swift@v2
with:
xcode-version: latest-stable
- name: Check XCode Version
run: xcodebuild -version
- uses: actions/checkout@v3
swift-version: '5.10'
- name: Get swift version
run: swift --version
- uses: actions/checkout@v4
- name: Build
run: xcodebuild -scheme SwiftRetrier build -destination "platform=OS X"
run: swift build -v
- name: Run tests
run: xcodebuild -scheme SwiftRetrier test -destination "platform=OS X"
run: swift test -v
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: SwiftLint
uses: raphaelbussa/swiftlint-action@main
with:
subcommand: lint --strict
subcommand: lint --strict
13 changes: 10 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ let lint = false
var extraDependencies: [Package.Dependency] = []
var extraPlugins: [Target.PluginUsage] = []
if lint {
extraDependencies = [.package(url: "https://github.com/realm/SwiftLint", exact: "0.52.4")]
extraPlugins = [.plugin(name: "SwiftLintPlugin", package: "SwiftLint")]
extraDependencies = [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
extraPlugins = [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")]
}

let package = Package(
Expand All @@ -28,12 +28,19 @@ let package = Package(
.target(
name: "SwiftRetrier",
dependencies: [],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=complete")
],
plugins: [] + extraPlugins
),
.testTarget(
name: "SwiftRetrierTests",
dependencies: ["SwiftRetrier"],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency=complete")
],
plugins: [] + extraPlugins
)
]
],
swiftLanguageVersions: [.version("5")]
)
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ You can add failure conditions using `giveUp*()` functions.
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.

⚠️ Policies should be stateless. To ensure that, I recommend implementing them with `struct` types.

If a policy needs to know about attempts history, ensure you propagate what's needed when implementing
`policyAfter(attemptFailure:, delay:) -> any RetryPolicy`.

To create a DSL entry point using your policy:

```swift
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

open class ConstantDelayRetryPolicy: RetryPolicy {
public struct ConstantDelayRetryPolicy: RetryPolicy {

public let delay: TimeInterval

Expand All @@ -16,7 +16,7 @@ open class ConstantDelayRetryPolicy: RetryPolicy {
.retry(delay: retryDelay(for: attemptFailure))
}

public func freshCopy() -> RetryPolicy {
public func policyAfter(attemptFailure: AttemptFailure, delay: TimeInterval) -> any RetryPolicy {
self
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Foundation

open class ExponentialBackoffRetryPolicy: RetryPolicy {
public struct ExponentialBackoffRetryPolicy: RetryPolicy {

public enum Jitter {
public enum Jitter: Sendable {
case none
case full
case decorrelated(growthFactor: Double = ExponentialBackoffConstants.defaultDecorrelatedJitterGrowthFactor)
Expand All @@ -11,14 +11,16 @@ open class ExponentialBackoffRetryPolicy: RetryPolicy {
public let timeSlot: TimeInterval
public let maxDelay: TimeInterval
public let jitter: Jitter
private var previousDelay: TimeInterval?
private let previousDelay: TimeInterval?

public init(timeSlot: TimeInterval = ExponentialBackoffConstants.defaultTimeSlot,
maxDelay: TimeInterval = ExponentialBackoffConstants.defaultMaxDelay,
jitter: Jitter = ExponentialBackoffConstants.defaultJitter) {
jitter: Jitter = ExponentialBackoffConstants.defaultJitter,
previousDelay: TimeInterval? = nil) {
self.timeSlot = timeSlot
self.maxDelay = maxDelay
self.jitter = jitter
self.previousDelay = previousDelay
}

public func exponentiationBySquaring<T: BinaryInteger>(_ base: T, _ multiplier: T, _ exponent: T) -> T {
Expand Down Expand Up @@ -57,7 +59,6 @@ open class ExponentialBackoffRetryPolicy: RetryPolicy {
} else {
delay = fullJitterDelay(attemptIndex: attemptIndex)
}
previousDelay = delay
return delay
}

Expand All @@ -72,15 +73,15 @@ open class ExponentialBackoffRetryPolicy: RetryPolicy {
}
}

open func retryDelay(for attemptFailure: AttemptFailure) -> TimeInterval {
public func retryDelay(for attemptFailure: AttemptFailure) -> TimeInterval {
min(maxDelay, uncappedDelay(attemptIndex: attemptFailure.index))
}

public func shouldRetry(on attemptFailure: AttemptFailure) -> RetryDecision {
.retry(delay: retryDelay(for: attemptFailure))
}

public func freshCopy() -> RetryPolicy {
ExponentialBackoffRetryPolicy(timeSlot: timeSlot, maxDelay: maxDelay, jitter: jitter)
public func policyAfter(attemptFailure: AttemptFailure, delay: TimeInterval) -> any RetryPolicy {
ExponentialBackoffRetryPolicy(timeSlot: timeSlot, maxDelay: maxDelay, jitter: jitter, previousDelay: delay)
}
}
2 changes: 1 addition & 1 deletion Sources/SwiftRetrier/Core/Model/Job.swift
Original file line number Diff line number Diff line change
@@ -1 +1 @@
public typealias Job<Value> = () async throws -> Value
public typealias Job<Value> = @Sendable () async throws -> Value
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public struct AttemptFailure {
public struct AttemptFailure: Sendable {
public let trialStart: Date
public let index: UInt
public let error: Error
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public enum RetryDecision {
public enum RetryDecision: Sendable {
case giveUp
case retry(delay: TimeInterval)
}
5 changes: 2 additions & 3 deletions Sources/SwiftRetrier/Core/Model/Policies/RetryPolicy.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Foundation

public protocol RetryPolicy {
public protocol RetryPolicy: Sendable {
func shouldRetry(on attemptFailure: AttemptFailure) -> RetryDecision
func retryDelay(for attemptFailure: AttemptFailure) -> TimeInterval
func freshCopy() -> RetryPolicy
func policyAfter(attemptFailure: AttemptFailure, delay: TimeInterval) -> any RetryPolicy
}
4 changes: 2 additions & 2 deletions Sources/SwiftRetrier/Core/Model/Retriers/Retrier.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Foundation
import Combine

public protocol Retrier: Cancellable, AnyObject {
associatedtype Output
public protocol Retrier: Cancellable, AnyObject, Sendable {
associatedtype Output: Sendable

func publisher() -> AnyPublisher<RetrierEvent<Output>, Never>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

public typealias GiveUpCriteria = @Sendable (
_ attemptFailure: AttemptFailure,
_ nestedPolicyDelay: TimeInterval
) -> Bool

public struct GiveUpCriteriaPolicyWrapper: RetryPolicy {

private let wrapped: RetryPolicy
private let giveUpCriteria: GiveUpCriteria

public init(wrapped: RetryPolicy, giveUpCriteria: @escaping GiveUpCriteria) {
self.wrapped = wrapped
self.giveUpCriteria = giveUpCriteria
}

public func shouldRetry(on attemptFailure: AttemptFailure) -> RetryDecision {
return switch wrapped.shouldRetry(on: attemptFailure) {
case .giveUp:
.giveUp
case .retry(let delay):
if giveUpCriteria(attemptFailure, delay) {
.giveUp
} else {
.retry(delay: delay)
}
}
}

public func policyAfter(attemptFailure: AttemptFailure, delay: TimeInterval) -> any RetryPolicy {
GiveUpCriteriaPolicyWrapper(
wrapped: wrapped.policyAfter(attemptFailure: attemptFailure, delay: delay),
giveUpCriteria: giveUpCriteria
)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ import Foundation

public extension RetryPolicy {

func giveUp(on giveUpCriterium: @escaping (AttemptFailure) -> Bool) -> RetryPolicy {
GiveUpOnPolicyWrapper(wrapped: self, giveUpCriterium: giveUpCriterium)
func giveUp(on giveUpCriteria: @escaping GiveUpCriteria) -> RetryPolicy {
GiveUpCriteriaPolicyWrapper(wrapped: self, giveUpCriteria: giveUpCriteria)
}

func giveUpAfter(maxAttempts: UInt) -> RetryPolicy {
GiveUpOnPolicyWrapper(wrapped: self, giveUpCriterium: { $0.index >= maxAttempts - 1})
GiveUpCriteriaPolicyWrapper(wrapped: self) { attempt, _ in
attempt.index >= maxAttempts - 1
}
}

func giveUpAfter(timeout: TimeInterval) -> RetryPolicy {
GiveUpOnPolicyWrapper(wrapped: self, giveUpCriterium: {
let nextAttemptStart = Date().addingTimeInterval(retryDelay(for: $0))
return nextAttemptStart >= $0.trialStart.addingTimeInterval(timeout)
})
GiveUpCriteriaPolicyWrapper(wrapped: self) { attempt, wrappedDelay in
let nextAttemptStart = Date().addingTimeInterval(wrappedDelay)
return nextAttemptStart >= attempt.trialStart.addingTimeInterval(timeout)
}
}

func giveUpOnErrors(matching finalErrorCriterium: @escaping (Error) -> Bool) -> RetryPolicy {
GiveUpOnPolicyWrapper(wrapped: self, giveUpCriterium: { finalErrorCriterium($0.error) })
func giveUpOnErrors(matching finalErrorCriteria: @escaping @Sendable (Error) -> Bool) -> RetryPolicy {
GiveUpCriteriaPolicyWrapper(wrapped: self) { attempt, _ in
finalErrorCriteria(attempt.error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output>: SingleOutputRetrier {
public class ConditionalRetrier<Output: Sendable>: SingleOutputRetrier, @unchecked Sendable {

private let policy: RetryPolicy
private let job: Job<Output>
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftRetrier/Core/Retriers/Repeater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import Combine
/// ```
///
/// On cancellation, the publisher emits a completion embedding a `CancellationError`then finishes.
public class Repeater<Output>: Retrier {
public class Repeater<Output: Sendable>: Retrier, @unchecked Sendable {

private let retrierBuilder: () -> AnySingleOutputRetrier<Output>
private var retrier: AnySingleOutputRetrier<Output>?
Expand Down
8 changes: 5 additions & 3 deletions Sources/SwiftRetrier/Core/Retriers/SimpleRetrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import Combine
/// the last attempt error, the publisher emits the attempt failure then a completion embedding the attempt error.
/// - **the retrier is canceled:** any awaiting on the `value` property will throw a `CancellationError`, the publisher
/// emits a completion embedding the same error then finishes.
public class SimpleRetrier<Output>: SingleOutputRetrier {
public class SimpleRetrier<Output: Sendable>: SingleOutputRetrier, @unchecked Sendable {

public let trialStart = Date()
private let subject = PassthroughSubject<RetrierEvent<Output>, Never>()
private var task: Task<Output, Error>!

public init(policy: RetryPolicy, job: @escaping Job<Output>) {
self.task = createTask(policy: policy.freshCopy(), job: job)
self.task = createTask(policy: policy, job: job)
}

@MainActor
Expand Down Expand Up @@ -49,6 +49,7 @@ public class SimpleRetrier<Output>: SingleOutputRetrier {
// Ensure we don't start before any ongoing business on main actor is finished
await MainActor.run {}
do {
var policy = policy
var attemptIndex: UInt = 0
while true {
try Task.checkCancellation()
Expand All @@ -60,7 +61,7 @@ public class SimpleRetrier<Output>: SingleOutputRetrier {
let attemptFailure = AttemptFailure(trialStart: trialStart, index: attemptIndex, error: error)
await sendAttemptFailure(attemptFailure)
try Task.checkCancellation()
let retryDecision = await MainActor.run { [attemptIndex] in
let retryDecision = await MainActor.run { [policy, attemptIndex] in
policy.shouldRetry(on: AttemptFailure(trialStart: trialStart,
index: attemptIndex,
error: error))
Expand All @@ -70,6 +71,7 @@ public class SimpleRetrier<Output>: SingleOutputRetrier {
throw error
case .retry(delay: let delay):
try await Task.sleep(nanoseconds: nanoseconds(delay))
policy = policy.policyAfter(attemptFailure: attemptFailure, delay: delay)
attemptIndex += 1
}
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/SwiftRetrier/DSL/ColdRepeater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public struct ColdRepeater {

public extension ColdRepeater {

func giveUp(on giveUpCriterium: @escaping (AttemptFailure) -> Bool) -> ColdRepeater {
let policy = policy.giveUp(on: giveUpCriterium)
func giveUp(on giveUpCriteria: @escaping GiveUpCriteria) -> ColdRepeater {
let policy = policy.giveUp(on: giveUpCriteria)
return ColdRepeater(policy: policy, repeatDelay: repeatDelay, conditionPublisher: conditionPublisher)
}

Expand All @@ -24,8 +24,8 @@ public extension ColdRepeater {
return ColdRepeater(policy: policy, repeatDelay: repeatDelay, conditionPublisher: conditionPublisher)
}

func giveUpOnErrors(matching finalErrorCriterium: @escaping (Error) -> Bool) -> ColdRepeater {
let policy = policy.giveUpOnErrors(matching: finalErrorCriterium)
func giveUpOnErrors(matching finalErrorCriteria: @escaping @Sendable (Error) -> Bool) -> ColdRepeater {
let policy = policy.giveUpOnErrors(matching: finalErrorCriteria)
return ColdRepeater(policy: policy, repeatDelay: repeatDelay, conditionPublisher: conditionPublisher)
}

Expand Down
Loading