Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8f379b3
Implement 100% cold retriers
PierreMardon Aug 28, 2024
daeef09
Cleanup main code
PierreMardon Aug 28, 2024
f2c4e7b
Add handleRetrierEvents modifier on JobRepeater
PierreMardon Aug 28, 2024
6303d16
Delete all tests, minor adjustments
PierreMardon Aug 28, 2024
11c8e4e
Some adjustements, first tests, fixes
PierreMardon Aug 29, 2024
1a8f9d3
Enhance DSL, add some protocols
PierreMardon Aug 29, 2024
28b5505
Still tests
PierreMardon Aug 29, 2024
3483db8
Some more tests and fixes
PierreMardon Aug 29, 2024
a0840ec
Some more tests
PierreMardon Aug 29, 2024
3b1cb1a
Edit README, add event handler on publishers
PierreMardon Aug 29, 2024
c81c498
README edit
PierreMardon Aug 29, 2024
0351cff
Linting fix, README edit
PierreMardon Aug 29, 2024
5b11a57
Fix async value
PierreMardon Aug 29, 2024
27ec393
Minor fixes
PierreMardon Oct 11, 2024
bb243f6
Fix condition publisher cancelling the retrier
PierreMardon Dec 5, 2024
6100bd5
Implement actual TrialPublisher and ConditionalRetrierPublisher
PierreMardon Feb 5, 2025
539af8c
Renaming, move files, fix handleRetrierEvents and add test
PierreMardon Feb 5, 2025
f9bbea5
Create RepeatingTrialPublisher, first cleanup
PierreMardon Feb 5, 2025
e36c931
Lint fixes, some comments
PierreMardon Feb 5, 2025
e9cc245
Cleanup TrialPublisher code and comment, add attempt failure index test
PierreMardon Feb 6, 2025
aecda95
Add backpressure in README, optimize TrialPublisher end
PierreMardon Feb 6, 2025
6de66bd
Minor renaming, remove useless call in ConditionalTrialPublisher
PierreMardon Feb 6, 2025
b75c8db
Fix lint + CI
PierreMardon Feb 6, 2025
21ca450
Some comments and aethetical changes
PierreMardon Feb 6, 2025
790b0df
Fix lint
PierreMardon Feb 6, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: List available Xcode versions
run: ls /Applications | grep Xcode
- name: Set up Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
- name: Show current version of Xcode
run: xcodebuild -version
- name: Build
Expand Down
4 changes: 1 addition & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let lint = false

var extraDependencies: [Package.Dependency] = []
var extraPlugins: [Target.PluginUsage] = []
if lint {
extraDependencies = [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
extraDependencies = [.package(url: "https://github.com/realm/SwiftLint.git", from: "0.56.2")]
extraPlugins = [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")]
}

Expand Down
150 changes: 82 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# SwiftRetrier
# SwiftRetrier v2

🪨 Rock-solid, concise and thorough library to retry and repeat `async throws` jobs.

## A cold retrier with all options
*Swift 6 mode and `MainActor` friendly* 🥳

[Migrating from v0 or v1?](#migration-from-v0-or-v1)

## A retrier with all options ❄️

```swift
var conditionPublisher: AnyPublisher<Bool, Never>

// Fully configurable policy with good defaults. Also available: withConstantDelay(), withNoDelay()
let coldRetrier = withExponentialBackoff()
let retrier = withExponentialBackoff()
// Fetch only when you've got network and your user is authenticated for example
.onlyWhen(conditionPublisher)
// Ensure your retrier gives up on some conditions
Expand All @@ -22,58 +26,28 @@ let coldRetrier = withExponentialBackoff()
[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.

## Execute and repeat
## Job retrier and repeater ❄️

You can chain a call to `execute { try await job() }`, but you can also reuse any cold retrier to execute multiple
jobs independently.
You can directly chain a call to `job { try await job() }` to create a cold job retrier,
but you can also reuse any retrier to create multiple job retriers.

```swift
let fetcher = coldRetrier.execute {
```swift
let fetcher = retrier.job {
try await fetchSomething()
}

let poller = coldRetrier
let poller = retrier
// If you want to poll, well you can
.repeating(withDelay: 30)
.execute {
.job {
try await fetchSomethingElse()
}

// you can omit `execute` and call the retrier as a function:
let otherFetcher = coldRetrier { try await fetchSomethingElse() }

// You can always cancel hot retriers
fetcher.cancel()
```

## Await value in concurrency context

If you don't repeat, you can wait for a single value in a concurrency context

```swift
// This will throw if you cancel the retrier or if any `giveUp*()` function matches
let value = try await withExponentialBackoff()
.onlyWhen(conditionPublisher)
.giveUpAfter(maxAttempts: 10)
.giveUpAfter(timeout: 30)
.giveUpOnErrors {
$0 is MyFatalError
}
.execute {
try await api.fetchValue()
}
.value
```

Note that you can use `cancellableValue` instead of `value`. In this case, if the task wrapping the concurrency context
is cancelled, the underlying retrier will be cancelled.

## Simple events handling

Retrier events can be handled simply.
Once the job is set, you can add event handlers to your (still cold ❄️) retrier.

```swift
fetcher.onEach {
let fetcherWithEventHandler = fetcher.handleRetrierEvents {
switch $0 {
case .attemptSuccess(let value):
print("Fetched something: \(value)")
Expand All @@ -82,19 +56,22 @@ fetcher.onEach {
case .completion(let error):
print("Fetcher completed with \(error?.localizedDescription ?? "no error")")
}
}.handleRetrierEvents {
// Do something fun 🤡
}
```

Keep in mind that the event handler will be retained until the retrier finishes (succeeding, failing or being
cancelled).
## Collect 🔥

## Combine publishers
All job retriers are cold publishers and:
- **each subscription will create a new independent retrying stream** 🔥
- **cancelling the subscription cancels the retrier**

All retriers (including repeaters) expose Combine publishers that publish relevant events.
Once in the Combine world, you'll know what to do (else check next paragraph).

```swift
let cancellable = poller.publisher()
.sink {
let cancellable = fetcher
.sink { event in
switch $0 {
case .attemptSuccess(let value):
print("Fetched something: \(value)")
Expand All @@ -103,37 +80,58 @@ let cancellable = poller.publisher()
case .completion(let error):
print("Poller completed with \(error?.localizedDescription ?? "no error")")
}
}
}

let cancellable = fetcher
// Retrieve success values
.success()
.sink { fetchedValue in
// Do something with values
}
```

- `failure()` and `completion()` filters are also available
- The publishers never fail, meaning their completion is always `.finished` and you can `sink {}` without handling
the completion
- Instead, `attemptFailure`, `attemptSuccess` and `completion` events are materialized and sent as values.
- Retriers expose `successPublisher()`, `failurePublisher()` and `completionPublisher()` shortcuts.
- You can use `publisher(propagateCancellation: true)` to cancel the retrier when you're done listening to it.
- You can use `success()`, `failure()` and `completion()` shortcuts.

## Await value in concurrency context 🔥

If you don't repeat, you can wait for a single value in a concurrency context and:
- **each awaiting will create a new independent retrying stream**
- **cancelling the task that is awaiting the value cancels the retrier**

```swift
// This will throw if you cancel the retrier or if any `giveUp*()` function matches
let value = try await withExponentialBackoff()
.onlyWhen(conditionPublisher)
.giveUpAfter(maxAttempts: 10)
.giveUpAfter(timeout: 30)
.giveUpOnErrors {
$0 is MyFatalError
}
.job {
try await api.fetchValue()
}
.value
```

## Retriers contract

- All retriers are cancellable.
- Retriers retry until either:
- their policy gives up
- the job succeeds (except for repeaters that will delay another trial)
- the retrier is cancelled
- the retrier is cancelled (via its subscription or its awaiting task cancellation)
- their conditionPublisher ends after having published no value or `false` as its last value
- When a policy gives up, the last job error is thrown on any `try await retrier.value`, and also embedded into
a `RetrierEvent.completion`.
- Retriers publishers emit only on `DispatchQueue.main`.
- When cancelled, the retrier publishers emit a `RetrierEvent.completion(CancellationError())` value then a `.finished`
completion and no intermediary attempt result.
- All retriers start their tasks immediately on initialization, and just wait for the current main queue cycle to end
before executing jobs. This way, if a retrier is created on main queue and cancelled in the same cycle, it's guaranteed
to not execute the job even once.
- You can create and cancel retriers on a different `DispatchQueue` or even in an asynchronous context. But in this
case, guarantees such as the previous one are no longer valid.
- Condition publishers events will be processed on `DispatchQueue.main`, but won't be delayed if they're already
emitted on it.
- Publishers emit only on `DispatchQueue.main`
- Everything here is `MainActor` friendly
- After a retrier is interrupted then resumed by its `conditionPublisher`, its policy is reused from start.
Consequently `giveUpAfter(maxAttempts:)` and `giveUpAfter(timeout:)` checks are applied to the current trial, ignoring previous ones.
Consequently `giveUpAfter(maxAttempts:)` and `giveUpAfter(timeout:)` checks are applied to the current trial,
ignoring previous ones.

## Retry Policies

Expand Down Expand Up @@ -163,16 +161,32 @@ If a policy needs to know about attempts history, ensure you propagate what's ne
To create a DSL entry point using your policy:

```swift
public func withMyOwnPolicy() -> ColdRetrier {
public func withMyOwnPolicy() -> Retrier {
let policy = MyOwnPolicy()
return ColdRetrier(policy: policy, conditionPublisher: nil)
return Retrier(policy: policy, conditionPublisher: nil)
}
```

## Actual retrier classes
## Backpressure

- most (if not all) usecases imply unlimited demand using `sink(receiveCompletion:)` or `assign(to:)` operators
- given the asynchronous nature of jobs, there's a very good chance backpressure won't be a problem

Still backpressure is properly managed:
No event will be sent to the subscriber if there's no demand, and there will be no attempt to execute a job until the
subscriber provides a positive demand.

**In practice, you shouldn't care about that except if you implement your own `Subscriber`.**

## Migration from v0 or v1

- Now, retriers are cold until consumed *because* the old behavior was mostly useless and complicated - even dangerous
- All cancellations are propagated by default *because* I never found any use to preventing that but observed mistakes
frequently. Up to you to not cancel if you don't want to.
- `execute()` -> `job()` *because* this modifier doesn't trigger execution anymore
- forget about `publisher()`, the job retrier/repeater IS the publisher *because* there's no reason to add an extra step

You can use the classes initializers directly, namely `SimpleRetrier`,
`ConditionalRetrier` and `Repeater`.
I'm convinced these changes are for the best, and the API should not change much in the future.

## Contribute

Expand Down
48 changes: 48 additions & 0 deletions Sources/SwiftRetrier/Core/JobRepeater.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
@preconcurrency import Combine

public struct JobRepeater<Value: Sendable>: Sendable {
public typealias Failure = Never
public typealias Output = RetrierEvent<Value>

let policy: RetryPolicy
let repeatDelay: TimeInterval
let conditionPublisher: AnyPublisher<Bool, Never>?
let receiveEvent: @Sendable @MainActor (RetrierEvent<Value>) -> Void
let job: Job<Value>

private let publisher: RepeatingTrialPublisher<Value>

init(
policy: RetryPolicy,
repeatDelay: TimeInterval,
conditionPublisher: AnyPublisher<Bool, Never>?,
receiveEvent: @escaping @Sendable @MainActor (RetrierEvent<Value>) -> Void = { _ in },
job: @escaping Job<Value>
) {
self.policy = policy
self.repeatDelay = repeatDelay
self.conditionPublisher = conditionPublisher
self.receiveEvent = receiveEvent
self.job = job
self.publisher = RepeatingTrialPublisher<Value>(
policy: policy,
repeatDelay: repeatDelay,
job: job,
conditionPublisher: conditionPublisher ?? Just(true).eraseToAnyPublisher()
)
}
}

extension JobRepeater: Publisher {

public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
publisher
.handleEvents(receiveOutput: { output in
MainActor.assumeIsolated {
receiveEvent(output)
}
})
.receive(subscriber: subscriber)
}
}
68 changes: 68 additions & 0 deletions Sources/SwiftRetrier/Core/JobRetrier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation
@preconcurrency import Combine

public struct JobRetrier<Value: Sendable>: @unchecked Sendable {
public typealias Failure = Never
public typealias Output = RetrierEvent<Value>

let policy: RetryPolicy
let job: Job<Value>
let conditionPublisher: AnyPublisher<Bool, Never>?
let receiveEvent: @Sendable @MainActor (RetrierEvent<Value>) -> Void

private let publisher: ConditionalTrialPublisher<Value>

init(
policy: RetryPolicy,
conditionPublisher: AnyPublisher<Bool, Never>?,
receiveEvent: @escaping @Sendable @MainActor (RetrierEvent<Value>) -> Void = { _ in },
job: @escaping Job<Value>
) {
self.policy = policy
self.conditionPublisher = conditionPublisher
self.receiveEvent = receiveEvent
self.job = job
self.publisher = ConditionalTrialPublisher(
policy: policy,
job: job,
conditionPublisher: conditionPublisher ?? Just(true).eraseToAnyPublisher()
)
}
}

extension JobRetrier: Publisher {

public func receive<S>(subscriber: S) where S: Subscriber, Never == S.Failure, RetrierEvent<Value> == S.Input {
publisher
.handleEvents(receiveOutput: { output in
MainActor.assumeIsolated {
receiveEvent(output)
}
})
.receive(subscriber: subscriber)
}
}

public extension JobRetrier {

var value: Value {
get async throws {
try await publisher
.tryCompactMap {
switch $0 {
case .attemptSuccess(let value):
value
case .attemptFailure:
nil
case .completion(let error):
if let error {
throw error
} else {
nil
}
}
}
.cancellableFirst
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public enum RetrierEvent<Output> {
public enum RetrierEvent<Output: Sendable>: Sendable {
case attemptSuccess(Output)
case attemptFailure(AttemptFailure)
case completion(Error?)
Expand Down
Loading