diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md new file mode 100644 index 00000000..1b0d1eab --- /dev/null +++ b/Evolution/NNNN-retry-backoff.md @@ -0,0 +1,290 @@ +# Retry & Backoff + +* Proposal: [NNNN](NNNN-retry-backoff.md) +* Authors: [Philipp Gabriel](https://github.com/ph1ps) +* Review Manager: TBD +* Status: **Implemented** + +## Introduction + +This proposal introduces a `retry` function and a suite of backoff strategies for Swift Async Algorithms, enabling robust retries of failed asynchronous operations with customizable delays and error-driven decisions. + +Swift forums thread: [Discussion thread topic for that proposal](https://forums.swift.org/t/pitch-retry-backoff/82483) + +## Motivation + +Retry logic with backoff is a common requirement in asynchronous programming, especially for operations subject to transient failures such as network requests. Today, developers must reimplement retry loops manually, leading to fragmented and error-prone solutions across the ecosystem. + +Providing a standard `retry` function and reusable backoff strategies in Swift Async Algorithms ensures consistent, safe and well-tested patterns for handling transient failures. + +## Proposed solution + +This proposal includes a suite of backoff strategies that can be used to generate delays between retry attempts. `BackoffStrategy` is a protocol that defines an immutable configuration for generating delays, while `BackoffIterator` handles the stateful generation of successive delay durations. This design mirrors Swift's `Sequence`/`IteratorProtocol` pattern. + +```swift +@available(AsyncAlgorithms 1.1, *) +public protocol BackoffStrategy { + associatedtype Iterator: BackoffIterator + associatedtype Duration: DurationProtocol where Duration == Iterator.Duration + func makeIterator() -> Iterator +} + +@available(AsyncAlgorithms 1.1, *) +public protocol BackoffIterator { + associatedtype Duration: DurationProtocol + mutating func nextDuration() -> Duration + mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Duration +} +``` + +`BackoffIterator` provides a default implementation of `nextDuration(using:)` that ignores the generator and forwards to `nextDuration()`. Iterators that use randomization (such as the jitter strategies) override this to use the provided generator instead of the system default. + +The core strategies provide different patterns for calculating delays: constant intervals, linear growth, and exponential growth. + +```swift +@available(AsyncAlgorithms 1.1, *) +public enum Backoff { + public static func constant(_ constant: Duration) -> some BackoffStrategy + public static func constant(_ constant: Duration) -> some BackoffStrategy + public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy + public static func exponential(factor: Int128, initial: Duration) -> some BackoffStrategy +} +``` + +These strategies can be modified to enforce minimum or maximum delays, or to add jitter for preventing the thundering herd problem. + +```swift +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy { + public func minimum(_ minimum: Duration) -> some BackoffStrategy + public func maximum(_ maximum: Duration) -> some BackoffStrategy +} +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy where Self: Sendable { + public func minimum(_ minimum: Duration) -> some BackoffStrategy & Sendable + public func maximum(_ maximum: Duration) -> some BackoffStrategy & Sendable +} +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy where Duration == Swift.Duration { + public func fullJitter() -> some BackoffStrategy + public func equalJitter() -> some BackoffStrategy +} +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy where Duration == Swift.Duration, Self: Sendable { + public func fullJitter() -> some BackoffStrategy & Sendable + public func equalJitter() -> some BackoffStrategy & Sendable +} +``` + +Linear, exponential, and jitter backoff require the use of `Swift.Duration` rather than any type conforming to `DurationProtocol` due to limitations of `DurationProtocol` to do more complex mathematical operations, such as adding or multiplying with reporting overflows or generating random values. Constant, minimum and maximum are able to use `DurationProtocol`. + +This proposal also introduces a retry function that executes an asynchronous operation up to a specified number of attempts, with customizable delays and error-based retry decisions between attempts. + +```swift +@available(AsyncAlgorithms 1.1, *) +nonisolated(nonsending) public func retry( + maxAttempts: Int, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error +``` + +```swift +@available(AsyncAlgorithms 1.1, *) +public struct RetryAction { + public static var stop: Self + public static func backoff(_ duration: Duration) -> Self +} +``` + +For convenience, there are also overloads that accept a `BackoffStrategy` directly. These overloads automatically compute the next backoff duration from the strategy on each retry, and replace the `strategy` closure with a simpler `strategy` closure that returns `Bool` instead of `RetryAction`: + +```swift +@available(AsyncAlgorithms 1.1, *) +nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error, Strategy: BackoffStrategy +``` + +For each retry overload, there is also a convenience variant that omits the `clock` parameter and uses `ContinuousClock` by default. This provides ergonomic defaults for the common case: + +```swift +// Without explicit clock (uses ContinuousClock) +try await retry(maxAttempts: 5, backoff: backoff) { + try await operation() +} + +// With explicit clock +try await retry(maxAttempts: 5, backoff: backoff, clock: myClock) { + try await operation() +} +``` + +There are also overloads that accept an `inout RandomNumberGenerator` and forward it to `nextDuration(using:)` on each retry. This allows callers to inject a seeded generator for deterministic testing of jitter strategies: + +```swift +@available(AsyncAlgorithms 1.1, *) +nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + using generator: inout some RandomNumberGenerator, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error, Strategy: BackoffStrategy +``` + +## Detailed design + +### Retry + +The retry algorithm follows this sequence: +1. Execute the operation +2. If successful, return the result +3. If failed and this was not the final attempt: + - Call the `strategy` closure with the error + - If the strategy returns `.stop`, rethrow the error immediately + - If the strategy returns `.backoff`, suspend for the given duration + - Return to step 1 +4. If failed on the final attempt, rethrow the error without consulting the strategy + +Given this sequence, there are four termination conditions (when retrying will be stopped): +- The operation completes without throwing an error +- The operation has been attempted `maxAttempts` times +- The strategy closure returns `.stop` +- The clock throws + +#### Preconditions + +- `maxAttempts` must be greater than 0. Passing 0 or a negative value triggers a precondition failure. + +#### Cancellation + +`retry` does not introduce special cancellation handling. If your code cooperatively cancels by throwing, ensure your strategy returns `.stop` for that error. Otherwise, retries continue unless the clock throws on cancellation. + +### Backoff + +#### Modifier composition + +Backoff modifiers are applied in the order they are chained. This order affects the final computed duration: + +```swift +// Jitter applied to the capped value (0 to 5 seconds) +let a = Backoff.exponential(factor: 2, initial: .seconds(1)) + .maximum(.seconds(5)) + .fullJitter() + +// Jitter applied first, then capped (never exceeds 5 seconds) +let b = Backoff.exponential(factor: 2, initial: .seconds(1)) + .fullJitter() + .maximum(.seconds(5)) +``` + +In the first example, when the exponential reaches 8 seconds, it is capped to 5 seconds, then jitter produces a value between 0 and 5 seconds. In the second example, jitter is applied to the full 8 seconds first (producing 0 to 8 seconds), then the result is capped at 5 seconds. + +#### Custom backoff + +Adopters may create their own backoff logic. The base `retry` function accepts a `strategy` closure that returns `RetryAction`, allowing complete control over backoff durations without conforming to any protocol. To use the `retry` overloads that accept a `backoff` parameter, or to use the provided modifiers (`minimum`, `maximum`, `fullJitter`, `equalJitter`), a custom strategy must conform to `BackoffStrategy`. + +#### Standard backoff + +The strategies compute durations according to these formulas: + +- **Constant**: $f(n) = constant$ +- **Linear**: $f(n) = initial + increment * n$ +- **Exponential**: $f(n) = initial * factor ^ n$ +- **Minimum**: $f(n) = max(minimum, g(n))$ where $g(n)$ is the base strategy +- **Maximum**: $f(n) = min(maximum, g(n))$ where $g(n)$ is the base strategy +- **Full Jitter**: $f(n) = random(0, g(n))$ where $g(n)$ is the base strategy +- **Equal Jitter**: $f(n) = random(g(n)/2, g(n))$ where $g(n)$ is the base strategy + +##### Overflow Handling + +Linear and exponential backoff strategies perform overflow-checked arithmetic. If the computed duration would overflow, the strategy returns `Duration(attoseconds: .max)` for all subsequent calls. This ensures the program does not crash due to overflow. + +Note that wrapper strategies like `.maximum()` continue calling their base iterator even after the maximum is reached, so the underlying iterator can still reach overflow state. However, since the wrapper clamps the result, the effective duration remains bounded. + +##### Sendability + +The core backoff strategies returned by `Backoff.constant`, `Backoff.linear`, and `Backoff.exponential` are unconditionally `Sendable`. The modifier methods (`minimum`, `maximum`, `fullJitter`, `equalJitter`) use overloads to preserve `Sendable` conformance: when called on a `Sendable` strategy, they return `some BackoffStrategy & Sendable`; otherwise they return `some BackoffStrategy`. This allows strategies to be stored and shared across isolation domains. Iterators are stateful and should not be shared; create a fresh iterator via `makeIterator()` in each context. + +### Case studies + +The most common use cases encountered for recovering from transient failures are either: +- a system requiring its user to come up with a reasonable duration to let the system cool off +- a system providing its own duration which the user is supposed to honor to let the system cool off + +Both of these use cases can be implemented using the proposed algorithm, respectively: + +```swift +let backoff = Backoff + .exponential(factor: 2, initial: .milliseconds(100)) + .maximum(.seconds(10)) + .fullJitter() + +let response = try await retry(maxAttempts: 5, backoff: backoff) { + try await URLSession.shared.data(from: url) +} +``` + +```swift +let response = try await retry(maxAttempts: 5) { + let (data, response) = try await URLSession.shared.data(from: url) + if + let response = response as? HTTPURLResponse, + response.statusCode == 429, + let retryAfter = response.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(retryAfter) + { + throw TooManyRequestsError(retryAfter: seconds) + } + return (data, response) +} strategy: { error in + if let error = error as? TooManyRequestsError { + return .backoff(.seconds(error.retryAfter)) + } else { + return .stop + } +} +``` +(For demonstration purposes only, a network server is used as the remote system.) + +## Effect on API resilience + +This proposal introduces a purely additive API with no impact on existing functionality or API resilience. + +## Alternatives considered + +### Passing attempt number to `BackoffIterator` + +Another option considered was to pass the current attempt number into the `BackoffIterator`. + +Although this initially seems useful, it leads to an awkward semi-stateful design. Consider a Fibonacci backoff: if the attempt number is passed externally, the iterator would need to recompute the entire sequence up to that attempt on every call, or else maintain internal state anyway. True iterators track their own progression (e.g. storing the last duration), making each `nextDuration()` call O(1) rather than O(n). Passing the attempt number externally undermines this efficiency and creates confusion about where state belongs. + +If adopters require access to the attempt number, they are free to implement this themselves, since the strategy closure is invoked each time a failure occurs, making it straightforward to maintain an external attempt counter. + +### Retry on `AsyncSequence` + +An alternative considered was adding retry functionality directly to `AsyncSequence` types, similar to how Combine provides retry on `Publisher`. However, after careful consideration, this was not included in the current proposal due to the lack of compelling real-world use cases. + +If specific use cases emerge in the future that demonstrate clear value for async sequence retry functionality, this could be considered in a separate proposal or amended to this proposal. + +### Random Number Generator Injection + +Jitter strategies require a source of randomness. The options considered were: +1. Store the `RandomNumberGenerator` in the strategy or iterator +2. Pass the generator at each duration computation + +Storing `inout` values is not possible in Swift. The alternative would be to store a copy of the `RandomNumberGenerator`, but there is no precedent for copying random number generators in the standard library. Therefore, the design passes `inout some RandomNumberGenerator` to `nextDuration(using:)`. + +## Acknowledgments + +Thanks to [Philippe Hausler](https://github.com/phausler), [Franz Busch](https://github.com/FranzBusch) and [Honza Dvorsky](https://github.com/czechboy0) for their thoughtful feedback and suggestions that helped refine the API design and improve its clarity and usability. diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift new file mode 100644 index 00000000..3a0d96ad --- /dev/null +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -0,0 +1,517 @@ +#if compiler(>=6.2) +/// A protocol for defining backoff strategies that generate delays between retry attempts. +/// +/// A `BackoffStrategy` represents an immutable configuration for generating delay durations. +/// To produce actual delay values, call `makeIterator()` to create a `BackoffIterator`. +/// This separation allows strategies to be `Sendable` and reusable, while iterators manage +/// the mutable state for generating successive delays. +/// +/// ## Example +/// +/// ```swift +/// let strategy = Backoff.exponential(factor: 2, initial: .milliseconds(100)) +/// var iterator = strategy.makeIterator() +/// iterator.nextDuration() // 100ms +/// iterator.nextDuration() // 200ms +/// iterator.nextDuration() // 400ms +/// ``` +@available(AsyncAlgorithms 1.1, *) +public protocol BackoffStrategy { + associatedtype Iterator: BackoffIterator + associatedtype Duration: DurationProtocol where Duration == Iterator.Duration + func makeIterator() -> Iterator +} + +/// A protocol for stateful iteration over backoff delay durations. +/// +/// A `BackoffIterator` is created from a `BackoffStrategy` via `makeIterator()`. +/// Each call to `nextDuration()` returns the delay for the next retry attempt. +/// Iterators are stateful; they may track the number of invocations or the +/// previously returned duration to calculate the next delay. +@available(AsyncAlgorithms 1.1, *) +public protocol BackoffIterator { + associatedtype Duration: DurationProtocol + mutating func nextDuration() -> Duration + mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Duration +} + +@available(AsyncAlgorithms 1.1, *) +extension BackoffIterator { + /// Default implementation that ignores the random number generator and + /// calls ``nextDuration()``. + /// + /// Override this method in iterators that use randomization (such as jitter) + /// to use the provided generator instead of the system default. + public mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Duration { + nextDuration() + } +} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct ConstantBackoffStrategy: BackoffStrategy, Sendable { + @usableFromInline let constant: Duration + @usableFromInline init(constant: Duration) { + precondition(constant >= .zero, "Constant must be greater than or equal to 0") + self.constant = constant + } + @inlinable func makeIterator() -> Iterator { + return Iterator(constant: constant) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline let constant: Duration + @usableFromInline init(constant: Duration) { + self.constant = constant + } + @inlinable @inline(__always) func nextDuration() -> Duration { + return constant + } + } +} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct LinearBackoffStrategy: BackoffStrategy, Sendable { + @usableFromInline let initial: Duration + @usableFromInline let increment: Duration + @usableFromInline init(increment: Duration, initial: Duration) { + precondition(initial >= .zero, "Initial must be greater than or equal to 0") + precondition(increment >= .zero, "Increment must be greater than or equal to 0") + self.initial = initial + self.increment = increment + } + @inlinable func makeIterator() -> Iterator { + return Iterator(current: initial, increment: increment) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var current: Duration + @usableFromInline let increment: Duration + @usableFromInline var hasOverflown = false + @usableFromInline init(current: Duration, increment: Duration) { + self.current = current + self.increment = increment + } + @inlinable @inline(__always) mutating func nextDuration() -> Duration { + if hasOverflown { + return Duration(attoseconds: .max) + } else { + let (next, hasOverflown) = current.attoseconds.addingReportingOverflow(increment.attoseconds) + if hasOverflown { + self.hasOverflown = true + return Duration(attoseconds: .max) + } else { + defer { current = Duration(attoseconds: next) } + return current + } + } + } + } +} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct ExponentialBackoffStrategy: BackoffStrategy, Sendable { + @usableFromInline let initial: Duration + @usableFromInline let factor: Int128 + @usableFromInline init(factor: Int128, initial: Duration) { + precondition(initial >= .zero, "Initial must be greater than or equal to 0") + precondition(factor >= 1, "Factor must be greater than or equal to 1") + self.initial = initial + self.factor = factor + } + @inlinable func makeIterator() -> Iterator { + return Iterator(current: initial, factor: factor) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var current: Duration + @usableFromInline let factor: Int128 + @usableFromInline var hasOverflown = false + @usableFromInline init(current: Duration, factor: Int128) { + self.current = current + self.factor = factor + } + @inlinable @inline(__always) mutating func nextDuration() -> Duration { + if hasOverflown { + return Duration(attoseconds: .max) + } else { + let (next, hasOverflown) = current.attoseconds.multipliedReportingOverflow(by: factor) + if hasOverflown { + self.hasOverflown = true + return Duration(attoseconds: .max) + } else { + defer { current = Duration(attoseconds: next) } + return current + } + } + } + } +} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct MinimumBackoffStrategy: BackoffStrategy { + @usableFromInline let base: Base + @usableFromInline let minimum: Base.Duration + @usableFromInline init(base: Base, minimum: Base.Duration) { + self.base = base + self.minimum = minimum + } + @inlinable public func makeIterator() -> Iterator { + return Iterator(base: base.makeIterator(), minimum: minimum) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var base: Base.Iterator + @usableFromInline let minimum: Base.Duration + @usableFromInline init(base: Base.Iterator, minimum: Base.Duration) { + self.base = base + self.minimum = minimum + } + @inlinable @inline(__always) mutating func nextDuration() -> Base.Duration { + return max(minimum, base.nextDuration()) + } + @inlinable @inline(__always) mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Base.Duration { + return max(minimum, base.nextDuration(using: &generator)) + } + } +} + +@available(AsyncAlgorithms 1.1, *) +extension MinimumBackoffStrategy: Sendable where Base: Sendable {} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct MaximumBackoffStrategy: BackoffStrategy { + @usableFromInline let base: Base + @usableFromInline let maximum: Base.Duration + @usableFromInline init(base: Base, maximum: Base.Duration) { + self.base = base + self.maximum = maximum + } + @inlinable func makeIterator() -> Iterator { + return Iterator(base: base.makeIterator(), maximum: maximum) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var base: Base.Iterator + @usableFromInline let maximum: Base.Duration + @usableFromInline init(base: Base.Iterator, maximum: Base.Duration) { + self.base = base + self.maximum = maximum + } + @inlinable @inline(__always) mutating func nextDuration() -> Base.Duration { + return min(maximum, base.nextDuration()) + } + @inlinable @inline(__always) mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Base.Duration { + return min(maximum, base.nextDuration(using: &generator)) + } + } +} + +@available(AsyncAlgorithms 1.1, *) +extension MaximumBackoffStrategy: Sendable where Base: Sendable {} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { + @usableFromInline let base: Base + @usableFromInline init(base: Base) { + self.base = base + } + @inlinable func makeIterator() -> Iterator { + return Iterator(base: base.makeIterator()) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var base: Base.Iterator + @usableFromInline init(base: Base.Iterator) { + self.base = base + } + @inlinable @inline(__always) mutating func nextDuration() -> Base.Duration { + return .init(attoseconds: Int128.random(in: 0...base.nextDuration().attoseconds)) + } + @inlinable @inline(__always) mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Base.Duration { + return .init(attoseconds: Int128.random(in: 0...base.nextDuration(using: &generator).attoseconds, using: &generator)) + } + } +} + +@available(AsyncAlgorithms 1.1, *) +extension FullJitterBackoffStrategy: Sendable where Base: Sendable {} + +@available(AsyncAlgorithms 1.1, *) +@usableFromInline struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { + @usableFromInline let base: Base + @usableFromInline init(base: Base) { + self.base = base + } + @inlinable func makeIterator() -> Iterator { + return Iterator(base: base.makeIterator()) + } + @usableFromInline struct Iterator: BackoffIterator { + @usableFromInline var base: Base.Iterator + @usableFromInline init(base: Base.Iterator) { + self.base = base + } + @inlinable @inline(__always) mutating func nextDuration() -> Base.Duration { + let duration = base.nextDuration().attoseconds + return .init(attoseconds: Int128.random(in: (duration / 2)...duration)) + } + @inlinable @inline(__always) mutating func nextDuration(using generator: inout some RandomNumberGenerator) -> Duration { + let duration = base.nextDuration(using: &generator).attoseconds + return .init(attoseconds: Int128.random(in: (duration / 2)...duration, using: &generator)) + } + } +} + +@available(AsyncAlgorithms 1.1, *) +extension EqualJitterBackoffStrategy: Sendable where Base: Sendable {} + +@available(AsyncAlgorithms 1.1, *) +public enum Backoff { + /// Creates a constant backoff strategy that always returns the same delay. + /// + /// Formula: `f(n) = constant` + /// + /// - Precondition: `constant` must be greater than or equal to zero. + /// + /// - Parameter constant: The fixed duration to wait between retry attempts. + /// - Returns: A backoff strategy that always returns the constant duration. + @inlinable public static func constant( + _ constant: Duration + ) -> some BackoffStrategy & Sendable { + return ConstantBackoffStrategy(constant: constant) + } + + /// Creates a constant backoff strategy that always returns the same delay. + /// + /// Formula: `f(n) = constant` + /// + /// - Precondition: `constant` must be greater than or equal to zero. + /// + /// - Parameter constant: The fixed duration to wait between retry attempts. + /// - Returns: A backoff strategy that always returns the constant duration. + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff.constant(.milliseconds(100)) + /// var iterator = backoff.makeIterator() + /// iterator.nextDuration() // 100ms + /// iterator.nextDuration() // 100ms + /// ``` + @inlinable public static func constant( + _ constant: Duration + ) -> some BackoffStrategy & Sendable { + return ConstantBackoffStrategy(constant: constant) + } + + /// Creates a linear backoff strategy where delays increase by a fixed increment. + /// + /// Formula: `f(n) = initial + increment * n` + /// + /// - Precondition: `initial` and `increment` must be greater than or equal to zero. + /// + /// - Parameters: + /// - increment: The amount to increase the delay by on each attempt. + /// - initial: The initial delay for the first retry attempt. + /// - Returns: A backoff strategy with linearly increasing delays. + /// + /// - Note: If the computed duration overflows, subsequent calls return the maximum + /// representable duration (`Duration(attoseconds: .max)`). + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff.linear(increment: .milliseconds(100), initial: .milliseconds(100)) + /// var iterator = backoff.makeIterator() + /// iterator.nextDuration() // 100ms + /// iterator.nextDuration() // 200ms + /// iterator.nextDuration() // 300ms + /// ``` + @inlinable public static func linear( + increment: Duration, + initial: Duration + ) -> some BackoffStrategy & Sendable { + return LinearBackoffStrategy(increment: increment, initial: initial) + } + + /// Creates an exponential backoff strategy where delays grow exponentially. + /// + /// Formula: `f(n) = initial * factor^n` + /// + /// - Precondition: `initial` must be greater than or equal to zero. + /// - Precondition: `factor` must be greater than or equal to 1. + /// + /// - Parameters: + /// - factor: The multiplication factor for each retry attempt. + /// - initial: The initial delay for the first retry attempt. + /// - Returns: A backoff strategy with exponentially increasing delays. + /// + /// - Note: If the computed duration overflows, subsequent calls return the maximum + /// representable duration (`Duration(attoseconds: .max)`). + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff.exponential(factor: 2, initial: .milliseconds(100)) + /// var iterator = backoff.makeIterator() + /// iterator.nextDuration() // 100ms + /// iterator.nextDuration() // 200ms + /// iterator.nextDuration() // 400ms + /// ``` + @inlinable public static func exponential( + factor: Int128, + initial: Duration + ) -> some BackoffStrategy & Sendable { + return ExponentialBackoffStrategy(factor: factor, initial: initial) + } +} + +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy { + /// Applies a minimum duration constraint to this backoff strategy. + /// + /// Formula: `f(n) = max(minimum, g(n))` where `g(n)` is the base strategy + /// + /// This modifier ensures that no delay returned by the strategy is less than + /// the specified minimum duration. + /// + /// - Parameter minimum: The minimum duration to enforce. + /// - Returns: A backoff strategy that never returns delays shorter than the minimum. + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .minimum(.milliseconds(200)) + /// var iterator = backoff.makeIterator() + /// iterator.nextDuration() // 200ms (enforced minimum) + /// ``` + @inlinable public func minimum( + _ minimum: Duration + ) -> some BackoffStrategy { + return MinimumBackoffStrategy(base: self, minimum: minimum) + } + + /// Applies a maximum duration constraint to this backoff strategy. + /// + /// Formula: `f(n) = min(maximum, g(n))` where `g(n)` is the base strategy + /// + /// This modifier ensures that no delay returned by the strategy exceeds + /// the specified maximum duration, effectively capping exponential growth. + /// + /// - Parameter maximum: The maximum duration to enforce. + /// - Returns: A backoff strategy that never returns delays longer than the maximum. + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .maximum(.seconds(5)) + /// var iterator = backoff.makeIterator() + /// // Delays will cap at 5 seconds instead of growing indefinitely + /// ``` + @inlinable public func maximum( + _ maximum: Duration + ) -> some BackoffStrategy { + return MaximumBackoffStrategy(base: self, maximum: maximum) + } + + /// Applies full jitter to this backoff strategy. + /// + /// Formula: `f(n) = random(0, g(n))` where `g(n)` is the base strategy + /// + /// Jitter prevents the thundering herd problem where multiple clients retry + /// simultaneously, reducing server load spikes and improving system stability. + /// + /// - Returns: A backoff strategy with full jitter applied. + @inlinable public func fullJitter() -> some BackoffStrategy where Duration == Swift.Duration { + return FullJitterBackoffStrategy(base: self) + } + + /// Applies equal jitter to this backoff strategy. + /// + /// Formula: `f(n) = random(g(n)/2, g(n))` where `g(n)` is the base strategy + /// + /// Equal jitter provides a balance between full jitter and no jitter, ensuring + /// at least half of the computed delay is always applied while still providing + /// randomization to prevent thundering herd. + /// + /// - Returns: A backoff strategy with equal jitter applied. + @inlinable public func equalJitter() -> some BackoffStrategy where Duration == Swift.Duration { + return EqualJitterBackoffStrategy(base: self) + } +} + +@available(AsyncAlgorithms 1.1, *) +extension BackoffStrategy where Self: Sendable { + /// Applies a minimum duration constraint to this backoff strategy. + /// + /// Formula: `f(n) = max(minimum, g(n))` where `g(n)` is the base strategy + /// + /// This modifier ensures that no delay returned by the strategy is less than + /// the specified minimum duration. + /// + /// - Parameter minimum: The minimum duration to enforce. + /// - Returns: A backoff strategy that never returns delays shorter than the minimum. + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .minimum(.milliseconds(200)) + /// var iterator = backoff.makeIterator() + /// iterator.nextDuration() // 200ms (enforced minimum) + /// ``` + @inlinable public func minimum( + _ minimum: Duration + ) -> some BackoffStrategy & Sendable { + return MinimumBackoffStrategy(base: self, minimum: minimum) + } + + /// Applies a maximum duration constraint to this backoff strategy. + /// + /// Formula: `f(n) = min(maximum, g(n))` where `g(n)` is the base strategy + /// + /// This modifier ensures that no delay returned by the strategy exceeds + /// the specified maximum duration, effectively capping exponential growth. + /// + /// - Parameter maximum: The maximum duration to enforce. + /// - Returns: A backoff strategy that never returns delays longer than the maximum. + /// + /// ## Example + /// + /// ```swift + /// let backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .maximum(.seconds(5)) + /// var iterator = backoff.makeIterator() + /// // Delays will cap at 5 seconds instead of growing indefinitely + /// ``` + @inlinable public func maximum( + _ maximum: Duration + ) -> some BackoffStrategy & Sendable { + return MaximumBackoffStrategy(base: self, maximum: maximum) + } + + /// Applies full jitter to this backoff strategy. + /// + /// Formula: `f(n) = random(0, g(n))` where `g(n)` is the base strategy + /// + /// Jitter prevents the thundering herd problem where multiple clients retry + /// simultaneously, reducing server load spikes and improving system stability. + /// + /// - Returns: A backoff strategy with full jitter applied. + @inlinable public func fullJitter() -> some BackoffStrategy & Sendable where Duration == Swift.Duration { + return FullJitterBackoffStrategy(base: self) + } + + /// Applies equal jitter to this backoff strategy. + /// + /// Formula: `f(n) = random(g(n)/2, g(n))` where `g(n)` is the base strategy + /// + /// Equal jitter provides a balance between full jitter and no jitter, ensuring + /// at least half of the computed delay is always applied while still providing + /// randomization to prevent thundering herd. + /// + /// - Returns: A backoff strategy with equal jitter applied. + @inlinable public func equalJitter() -> some BackoffStrategy & Sendable where Duration == Swift.Duration { + return EqualJitterBackoffStrategy(base: self) + } +} +#endif diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift new file mode 100644 index 00000000..48514435 --- /dev/null +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -0,0 +1,413 @@ +#if compiler(>=6.2) +@available(AsyncAlgorithms 1.1, *) +public struct RetryAction { + @usableFromInline enum Action { + case backoff(Duration) + case stop + } + @usableFromInline let action: Action + @usableFromInline init(action: Action) { + self.action = action + } + + /// Indicates that retrying should stop immediately and the error should be rethrown. + @inlinable public static var stop: Self { + return .init(action: .stop) + } + + /// Indicates that retrying should continue after waiting for the specified duration. + /// + /// - Parameter duration: The duration to wait before the next retry attempt. + @inlinable public static func backoff(_ duration: Duration) -> Self { + return .init(action: .backoff(duration)) + } +} + +/// Executes an asynchronous operation with retry logic and customizable backoff strategies. +/// +/// This function executes an asynchronous operation up to a specified number of attempts, +/// with customizable delays and error-based retry decisions between attempts. +/// +/// The retry logic follows this sequence: +/// 1. Execute the operation +/// 2. If successful, return the result +/// 3. If failed and this was not the final attempt: +/// - Call the strategy closure with the error +/// - If the strategy returns `.stop`, rethrow the error immediately +/// - If the strategy returns `.backoff`, suspend for the given duration +/// - Return to step 1 +/// 4. If failed on the final attempt, rethrow the error without consulting the strategy +/// +/// Given this sequence, there are four termination conditions (when retrying will be stopped): +/// - The operation completes without throwing an error +/// - The operation has been attempted `maxAttempts` times +/// - The strategy closure returns `.stop` +/// - The clock throws +/// +/// ## Cancellation +/// +/// `retry` does not introduce special cancellation handling. If your code cooperatively +/// cancels by throwing, ensure your strategy returns `.stop` for that error. Otherwise, +/// retries continue unless the used clock throws on cancellation. +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. +/// - tolerance: The tolerance for the sleep operation between retries. This value is passed +/// to `clock.sleep(for:tolerance:)` and allows the system scheduling flexibility. +/// - clock: The clock to use for timing delays between retries. +/// - operation: The asynchronous operation to retry. +/// - strategy: A closure that determines the retry action based on the error. +/// Defaults to immediate retry with no delay. +/// - Returns: The result of the successful operation. +/// - Throws: The error from the operation if all retry attempts fail or if the strategy returns `.stop`. +/// +/// ## Example +/// +/// This example honors a server-provided backoff duration from the error: +/// +/// ```swift +/// let result = try await retry(maxAttempts: 5, clock: ContinuousClock()) { +/// try await someHTTPOperation() +/// } strategy: { error in +/// if let error = error as? StatusCodeError { +/// return .backoff(.seconds(error.retryAfter)) +/// } +/// return .stop +/// } +/// ``` +@available(AsyncAlgorithms 1.1, *) +@inlinable nonisolated(nonsending) public func retry( + maxAttempts: Int, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error { + precondition(maxAttempts > 0, "Must have at least one attempt") + for _ in 0..( + maxAttempts: Int, + tolerance: ContinuousClock.Duration? = nil, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } +) async throws -> Result where ErrorType: Error { + return try await retry( + maxAttempts: maxAttempts, + tolerance: tolerance, + clock: ContinuousClock(), + operation: operation, + strategy: strategy + ) +} + +/// Executes an asynchronous operation with retry logic using a backoff strategy. +/// +/// This function executes an asynchronous operation up to a specified number of attempts. +/// When the operation fails, the backoff strategy determines how long to wait before +/// the next attempt. Common strategies include exponential backoff with jitter to +/// prevent thundering herd problems. +/// +/// The retry logic follows this sequence: +/// 1. Execute the operation +/// 2. If successful, return the result +/// 3. If failed and this was not the final attempt: +/// - Call the `strategy` closure with the error +/// - If `strategy` returns `false`, rethrow the error immediately +/// - If `strategy` returns `true`, suspend for the next backoff duration +/// - Return to step 1 +/// 4. If failed on the final attempt, rethrow the error without consulting `strategy` +/// +/// Given this sequence, there are four termination conditions (when retrying will be stopped): +/// - The operation completes without throwing an error +/// - The operation has been attempted `maxAttempts` times +/// - The `strategy` closure returns `false` +/// - The clock throws +/// +/// ## Cancellation +/// +/// `retry` does not introduce special cancellation handling. If your code cooperatively +/// cancels by throwing, ensure `strategy` returns `false` for that error. Otherwise, +/// retries continue unless the used clock throws on cancellation. +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. +/// - backoff: The backoff strategy to use for delays between retries. +/// - tolerance: The tolerance for the sleep operation between retries. This value is passed +/// to `clock.sleep(for:tolerance:)` and allows the system scheduling flexibility. +/// - clock: The clock to use for timing delays between retries. +/// - operation: The asynchronous operation to retry. +/// - strategy: A closure that determines whether to retry based on the error. +/// Return `true` to retry with backoff, `false` to stop immediately. +/// Defaults to always retrying. +/// - Returns: The result of the successful operation. +/// - Throws: The error from the operation if all retry attempts fail or if `strategy` returns `false`. +/// +/// ## Example +/// +/// ```swift +/// let backoff = Backoff +/// .exponential(factor: 2, initial: .milliseconds(100)) +/// .maximum(.seconds(10)) +/// .fullJitter() +/// +/// let result = try await retry(maxAttempts: 5, backoff: backoff) { +/// try await someHTTPOperation() +/// } +/// ``` +@available(AsyncAlgorithms 1.1, *) +@inlinable nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error, Strategy: BackoffStrategy { + var iterator = backoff.makeIterator() + return try await retry( + maxAttempts: maxAttempts, + tolerance: tolerance, + clock: clock, + operation: operation, + strategy: { error in + if strategy(error) { + return .backoff(iterator.nextDuration()) + } else { + return .stop + } + } + ) +} + +/// Executes an asynchronous operation with retry logic using a backoff strategy. +/// +/// This function executes an asynchronous operation up to a specified number of attempts. +/// When the operation fails, the backoff strategy determines how long to wait before +/// the next attempt. Common strategies include exponential backoff with jitter to +/// prevent thundering herd problems. +/// +/// The retry logic follows this sequence: +/// 1. Execute the operation +/// 2. If successful, return the result +/// 3. If failed and this was not the final attempt: +/// - Call the `strategy` closure with the error +/// - If `strategy` returns `false`, rethrow the error immediately +/// - If `strategy` returns `true`, suspend for the next backoff duration +/// - Return to step 1 +/// 4. If failed on the final attempt, rethrow the error without consulting `strategy` +/// +/// Given this sequence, there are four termination conditions (when retrying will be stopped): +/// - The operation completes without throwing an error +/// - The operation has been attempted `maxAttempts` times +/// - The `strategy` closure returns `false` +/// - The clock throws +/// +/// ## Cancellation +/// +/// `retry` does not introduce special cancellation handling. If your code cooperatively +/// cancels by throwing, ensure `strategy` returns `false` for that error. Otherwise, +/// retries continue unless the used clock throws on cancellation. +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. +/// - backoff: The backoff strategy to use for delays between retries. +/// - tolerance: The tolerance for the sleep operation between retries. This value is passed +/// to `clock.sleep(for:tolerance:)` and allows the system scheduling flexibility. +/// - operation: The asynchronous operation to retry. +/// - strategy: A closure that determines whether to retry based on the error. +/// Return `true` to retry with backoff, `false` to stop immediately. +/// Defaults to always retrying. +/// - Returns: The result of the successful operation. +/// - Throws: The error from the operation if all retry attempts fail or if `strategy` returns `false`. +/// +/// ## Example +/// +/// ```swift +/// let backoff = Backoff +/// .exponential(factor: 2, initial: .milliseconds(100)) +/// .maximum(.seconds(10)) +/// .fullJitter() +/// +/// let result = try await retry(maxAttempts: 5, backoff: backoff) { +/// try await someHTTPOperation() +/// } +/// ``` +@available(AsyncAlgorithms 1.1, *) +@inlinable nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + tolerance: ContinuousClock.Duration? = nil, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where ErrorType: Error, Strategy: BackoffStrategy { + return try await retry( + maxAttempts: maxAttempts, + backoff: backoff, + tolerance: tolerance, + clock: ContinuousClock(), + operation: operation, + strategy: strategy + ) +} + +/// Executes an asynchronous operation with retry logic using a backoff strategy and a +/// custom random number generator. +/// +/// This overload forwards the provided random number generator to the backoff iterator +/// via ``BackoffIterator/nextDuration(using:)``, allowing callers to control the source +/// of randomness used by jitter strategies. +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. +/// - backoff: The backoff strategy to use for delays between retries. +/// - generator: The random number generator to use for jitter. +/// - tolerance: The tolerance for the sleep operation between retries. This value is passed +/// to `clock.sleep(for:tolerance:)` and allows the system scheduling flexibility. +/// - clock: The clock to use for timing delays between retries. +/// - operation: The asynchronous operation to retry. +/// - strategy: A closure that determines whether to retry based on the error. +/// Return `true` to retry with backoff, `false` to stop immediately. +/// Defaults to always retrying. +/// - Returns: The result of the successful operation. +/// - Throws: The error from the operation if all retry attempts fail or if `strategy` returns `false`. +@available(AsyncAlgorithms 1.1, *) +@inlinable nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + using generator: inout some RandomNumberGenerator, + tolerance: DurationType? = nil, + clock: any Clock, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where DurationType: DurationProtocol, ErrorType: Error, Strategy: BackoffStrategy { + var iterator = backoff.makeIterator() + return try await retry( + maxAttempts: maxAttempts, + tolerance: tolerance, + clock: clock, + operation: operation, + strategy: { error in + if strategy(error) { + return .backoff(iterator.nextDuration(using: &generator)) + } else { + return .stop + } + } + ) +} + +/// Executes an asynchronous operation with retry logic using a backoff strategy and a +/// custom random number generator, using `ContinuousClock` for timing. +/// +/// This is a convenience overload that uses `ContinuousClock` as the clock. +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. +/// - backoff: The backoff strategy to use for delays between retries. +/// - generator: The random number generator to use for jitter. +/// - tolerance: The tolerance for the sleep operation between retries. This value is passed +/// to `clock.sleep(for:tolerance:)` and allows the system scheduling flexibility. +/// - operation: The asynchronous operation to retry. +/// - strategy: A closure that determines whether to retry based on the error. +/// Return `true` to retry with backoff, `false` to stop immediately. +/// Defaults to always retrying. +/// - Returns: The result of the successful operation. +/// - Throws: The error from the operation if all retry attempts fail or if `strategy` returns `false`. +@available(AsyncAlgorithms 1.1, *) +@inlinable nonisolated(nonsending) public func retry( + maxAttempts: Int, + backoff: Strategy, + using generator: inout some RandomNumberGenerator, + tolerance: ContinuousClock.Duration? = nil, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> Bool = { _ in true } +) async throws -> Result where ErrorType: Error, Strategy: BackoffStrategy { + return try await retry( + maxAttempts: maxAttempts, + backoff: backoff, + using: &generator, + tolerance: tolerance, + clock: ContinuousClock(), + operation: operation, + strategy: strategy + ) +} +#endif diff --git a/Tests/AsyncAlgorithmsTests/Support/SplitMix64.swift b/Tests/AsyncAlgorithmsTests/Support/SplitMix64.swift new file mode 100644 index 00000000..4b675500 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/Support/SplitMix64.swift @@ -0,0 +1,16 @@ +// Taken from: https://github.com/swiftlang/swift/blob/main/benchmark/utils/TestsUtils.swift#L257-L271 +public struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + public init(seed: UInt64) { + self.state = seed + } + + public mutating func next() -> UInt64 { + self.state &+= 0x9e37_79b9_7f4a_7c15 + var z: UInt64 = self.state + z = (z ^ (z &>> 30)) &* 0xbf58_476d_1ce4_e5b9 + z = (z ^ (z &>> 27)) &* 0x94d0_49bb_1331_11eb + return z ^ (z &>> 31) + } +} diff --git a/Tests/AsyncAlgorithmsTests/TestBackoff.swift b/Tests/AsyncAlgorithmsTests/TestBackoff.swift new file mode 100644 index 00000000..ed64da6d --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestBackoff.swift @@ -0,0 +1,181 @@ +import AsyncAlgorithms +import Testing + +#if compiler(>=6.2) +@Suite struct BackoffTests { + + @available(AsyncAlgorithms 1.1, *) + @Test func constantBackoff() { + var iterator = + Backoff + .constant(.milliseconds(5)) + .makeIterator() + #expect(iterator.nextDuration() == .milliseconds(5)) + #expect(iterator.nextDuration() == .milliseconds(5)) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func linearBackoff() { + var iterator = + Backoff + .linear(increment: .milliseconds(2), initial: .milliseconds(1)) + .makeIterator() + #expect(iterator.nextDuration() == .milliseconds(1)) + #expect(iterator.nextDuration() == .milliseconds(3)) + #expect(iterator.nextDuration() == .milliseconds(5)) + #expect(iterator.nextDuration() == .milliseconds(7)) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func exponentialBackoff() { + var iterator = + Backoff + .exponential(factor: 2, initial: .milliseconds(1)) + .makeIterator() + #expect(iterator.nextDuration() == .milliseconds(1)) + #expect(iterator.nextDuration() == .milliseconds(2)) + #expect(iterator.nextDuration() == .milliseconds(4)) + #expect(iterator.nextDuration() == .milliseconds(8)) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func fullJitter() { + var rng = SplitMix64(seed: 42) + var iterator = + Backoff + .constant(.milliseconds(100)) + .fullJitter() + .makeIterator() + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 15_991_039_287_692_012)) // 15.99 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 34_419_071_652_363_758)) // 34.41 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 86_822_807_654_653_238)) // 86.82 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 80_063_187_671_350_344)) // 80.06 ms + } + + @available(AsyncAlgorithms 1.1, *) + @Test func equalJitter() { + var rng = SplitMix64(seed: 42) + var iterator = + Backoff + .constant(.milliseconds(100)) + .equalJitter() + .makeIterator() + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 57_995_519_643_846_006)) // 57.99 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 67_209_535_826_181_879)) // 67.20 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 93_411_403_827_326_619)) // 93.41 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 90_031_593_835_675_172)) // 90.03 ms + } + + @available(AsyncAlgorithms 1.1, *) + @Test func minimum() { + var iterator = + Backoff + .exponential(factor: 2, initial: .milliseconds(1)) + .minimum(.milliseconds(2)) + .makeIterator() + #expect(iterator.nextDuration() == .milliseconds(2)) // 1 clamped to min 2 + #expect(iterator.nextDuration() == .milliseconds(2)) // 2 unchanged + #expect(iterator.nextDuration() == .milliseconds(4)) // 4 unchanged + #expect(iterator.nextDuration() == .milliseconds(8)) // 8 unchanged + } + + @available(AsyncAlgorithms 1.1, *) + @Test func maximum() { + var iterator = + Backoff + .exponential(factor: 2, initial: .milliseconds(1)) + .maximum(.milliseconds(5)) + .makeIterator() + #expect(iterator.nextDuration() == .milliseconds(1)) // 1 unchanged + #expect(iterator.nextDuration() == .milliseconds(2)) // 2 unchanged + #expect(iterator.nextDuration() == .milliseconds(4)) // 4 unchanged + #expect(iterator.nextDuration() == .milliseconds(5)) // 8 clamped to max 5 + } + + @available(AsyncAlgorithms 1.1, *) + @Test func fullJitterAndMaximum() { + var rng = SplitMix64(seed: 42) + var iterator = + Backoff + .constant(.milliseconds(100)) + .fullJitter() + .maximum(.milliseconds(50)) + .makeIterator() + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 15_991_039_287_692_012)) // 15.99 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 34_419_071_652_363_758)) // 34.41 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 50_000_000_000_000_000)) // 50 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 50_000_000_000_000_000)) // 50 ms + } + + @available(AsyncAlgorithms 1.1, *) + @Test func equalJitterAndMaximum() { + var rng = SplitMix64(seed: 42) + var iterator = + Backoff + .constant(.milliseconds(100)) + .equalJitter() + .maximum(.milliseconds(60)) + .makeIterator() + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 57_995_519_643_846_006)) // 57.99 ms + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 60_000_000_000_000_000)) // 60 ms clamped + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 60_000_000_000_000_000)) // 60 ms clamped + #expect(iterator.nextDuration(using: &rng) == Duration(attoseconds: 60_000_000_000_000_000)) // 60 ms clamped + } + + @available(AsyncAlgorithms 1.1, *) + @Test func overflowSafety() async { + await #expect(processExitsWith: .success) { + var iterator = + Backoff + .exponential(factor: 2, initial: .seconds(5)) + .maximum(.seconds(120)) + .makeIterator() + for _ in 0..<1000 { + _ = iterator.nextDuration() + } + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func constantPrecondition() async { + await #expect(processExitsWith: .success) { + _ = Backoff.constant(.milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.constant(.milliseconds(-1)) + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func linearPrecondition() async { + await #expect(processExitsWith: .success) { + _ = Backoff.linear(increment: .milliseconds(1), initial: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.linear(increment: .milliseconds(1), initial: .milliseconds(-1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.linear(increment: .milliseconds(-1), initial: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.linear(increment: .milliseconds(-1), initial: .milliseconds(-1)) + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func exponentialPrecondition() async { + await #expect(processExitsWith: .success) { + _ = Backoff.exponential(factor: 1, initial: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.exponential(factor: 1, initial: .milliseconds(-1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.exponential(factor: 0, initial: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.exponential(factor: -1, initial: .milliseconds(1)) + } + } +} +#endif diff --git a/Tests/AsyncAlgorithmsTests/TestRetry.swift b/Tests/AsyncAlgorithmsTests/TestRetry.swift new file mode 100644 index 00000000..426e6075 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestRetry.swift @@ -0,0 +1,217 @@ +@testable import AsyncAlgorithms +import Testing + +#if compiler(>=6.2) +@Suite struct RetryTests { + + @available(AsyncAlgorithms 1.1, *) + @Test func singleAttempt() async throws { + var operationAttempts = 0 + var strategyAttempts = 0 + await #expect(throws: Failure.self) { + try await retry(maxAttempts: 1) { + operationAttempts += 1 + throw Failure() + } strategy: { _ in + strategyAttempts += 1 + return .backoff(.zero) + } + } + #expect(operationAttempts == 1) + #expect(strategyAttempts == 0) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func customCancellation() async throws { + struct CustomCancellationError: Error {} + let task = Task { + try await retry(maxAttempts: 3) { + if Task.isCancelled { + throw CustomCancellationError() + } + throw Failure() + } strategy: { error in + guard error is CustomCancellationError else { + return .backoff(.zero) + } + return .stop + } + } + task.cancel() + await #expect(throws: CustomCancellationError.self) { + try await task.value + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func defaultCancellation() async throws { + let task = Task { + try await retry(maxAttempts: 3) { + throw Failure() + } + } + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func successOnFirstAttempt() async throws { + func doesNotActuallyThrow() throws {} + var operationAttempts = 0 + var strategyAttempts = 0 + try await retry(maxAttempts: 3) { + operationAttempts += 1 + try doesNotActuallyThrow() + } strategy: { _ in + strategyAttempts += 1 + return .backoff(.zero) + } + #expect(operationAttempts == 1) + #expect(strategyAttempts == 0) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func successOnSecondAttempt() async throws { + var operationAttempts = 0 + var strategyAttempts = 0 + try await retry(maxAttempts: 3) { + operationAttempts += 1 + if operationAttempts == 1 { + throw Failure() + } + } strategy: { _ in + strategyAttempts += 1 + return .backoff(.zero) + } + #expect(operationAttempts == 2) + #expect(strategyAttempts == 1) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func maxAttemptsExceeded() async throws { + var operationAttempts = 0 + var strategyAttempts = 0 + await #expect(throws: Failure.self) { + try await retry(maxAttempts: 3) { + operationAttempts += 1 + throw Failure() + } strategy: { _ in + strategyAttempts += 1 + return .backoff(.zero) + } + } + #expect(operationAttempts == 3) + #expect(strategyAttempts == 2) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func nonRetryableError() async throws { + struct RetryableError: Error {} + struct NonRetryableError: Error {} + var operationAttempts = 0 + var strategyAttempts = 0 + await #expect(throws: NonRetryableError.self) { + try await retry(maxAttempts: 5) { + operationAttempts += 1 + if operationAttempts == 2 { + throw NonRetryableError() + } + throw RetryableError() + } strategy: { error in + strategyAttempts += 1 + if error is NonRetryableError { + return .stop + } + return .backoff(.zero) + } + } + #expect(operationAttempts == 2) + #expect(strategyAttempts == 2) + } + + @available(AsyncAlgorithms 1.1, *) + @MainActor @Test func customClock() async throws { + let clock = ManualClock() + let (stream, continuation) = AsyncStream.makeStream() + let operationAttempts = ManagedCriticalState(0) + let task = Task { @MainActor in + try await retry(maxAttempts: 3, clock: clock) { + operationAttempts.withCriticalRegion { $0 += 1 } + continuation.yield() + throw Failure() + } strategy: { _ in + return .backoff(.steps(1)) + } + } + var iterator = stream.makeAsyncIterator() + _ = await iterator.next()! + #expect(operationAttempts.withCriticalRegion { $0 } == 1) + clock.advance() + _ = await iterator.next()! + #expect(operationAttempts.withCriticalRegion { $0 } == 2) + clock.advance() + _ = await iterator.next()! + #expect(operationAttempts.withCriticalRegion { $0 } == 3) + await #expect(throws: Failure.self) { + try await task.value + } + } + + @available(AsyncAlgorithms 1.1, *) + @Test func retryWithBackoffStrategy() async throws { + var operationAttempts = 0 + let backoff = Backoff.constant(.zero) + let result = try await retry(maxAttempts: 3, backoff: backoff) { + operationAttempts += 1 + if operationAttempts < 3 { + throw Failure() + } + return "success" + } + #expect(result == "success") + #expect(operationAttempts == 3) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func retryWithBackoffStrategyAndRNG() async throws { + var operationAttempts = 0 + let backoff = Backoff.constant(.zero).fullJitter() + var rng = SplitMix64(seed: 42) + let result = try await retry(maxAttempts: 3, backoff: backoff, using: &rng) { + operationAttempts += 1 + if operationAttempts < 3 { + throw Failure() + } + return "success" + } + #expect(result == "success") + #expect(operationAttempts == 3) + } + + @available(AsyncAlgorithms 1.1, *) + @Test func retryWithBackoffStrategyShouldRetryFalse() async throws { + var operationAttempts = 0 + let backoff = Backoff.constant(.zero) + await #expect(throws: Failure.self) { + try await retry(maxAttempts: 5, backoff: backoff) { + operationAttempts += 1 + throw Failure() + } strategy: { _ in + operationAttempts < 2 + } + } + #expect(operationAttempts == 2) + } + + #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Windows) + @available(AsyncAlgorithms 1.1, *) + @Test func zeroAttempts() async { + await #expect(processExitsWith: .failure) { + try await retry(maxAttempts: 0) {} + } + } + #endif +} +#endif