From c75fc64e7efe437136d0a75f6c559fc91f2dd321 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Fri, 5 Sep 2025 12:31:36 +0200 Subject: [PATCH 01/13] add retry&backoff --- Package.swift | 4 +- Sources/AsyncAlgorithms/Retry/Backoff.swift | 198 ++++++++++++++++++++ Sources/AsyncAlgorithms/Retry/Retry.swift | 51 +++++ Sources/_CPowSupport/_CPowSupport.c | 1 + Sources/_CPowSupport/include/_CPowSupport.h | 3 + 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 Sources/AsyncAlgorithms/Retry/Backoff.swift create mode 100644 Sources/AsyncAlgorithms/Retry/Retry.swift create mode 100644 Sources/_CPowSupport/_CPowSupport.c create mode 100644 Sources/_CPowSupport/include/_CPowSupport.h diff --git a/Package.swift b/Package.swift index b34c676a..9b39078d 100644 --- a/Package.swift +++ b/Package.swift @@ -16,9 +16,12 @@ let package = Package( .library(name: "AsyncAlgorithms", targets: ["AsyncAlgorithms"]) ], targets: [ + .systemLibrary(name: "_CAsyncSequenceValidationSupport"), + .target(name: "_CPowSupport"), .target( name: "AsyncAlgorithms", dependencies: [ + "_CPowSupport", .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "DequeModule", package: "swift-collections"), ], @@ -33,7 +36,6 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency=complete") ] ), - .systemLibrary(name: "_CAsyncSequenceValidationSupport"), .target( name: "AsyncAlgorithms_XCTest", dependencies: ["AsyncAlgorithms", "AsyncSequenceValidation"], diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift new file mode 100644 index 00000000..ddfbdc39 --- /dev/null +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -0,0 +1,198 @@ +import _CPowSupport + +#if compiler(<6.2) +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +extension Duration { + @usableFromInline var attoseconds: Int128 { + return Int128(_low: _low, _high: _high) + } + @usableFromInline init(attoseconds: Int128) { + self.init(_high: attoseconds._high, low: attoseconds._low) + } +} +#endif + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public protocol BackoffStrategy { + associatedtype Duration: DurationProtocol + mutating func duration(_ attempt: Int) -> Duration + mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +extension BackoffStrategy { + public mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration { + return duration(attempt) + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@usableFromInline +struct ConstantBackoffStrategy: BackoffStrategy { + @usableFromInline let c: Duration + @usableFromInline init(c: Duration) { + self.c = c + } + @inlinable func duration(_ attempt: Int) -> Duration { + return c + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@usableFromInline +struct LinearBackoffStrategy: BackoffStrategy { + @usableFromInline let a: Duration + @usableFromInline let b: Duration + @usableFromInline init(a: Duration, b: Duration) { + self.a = a + self.b = b + } + @inlinable func duration(_ attempt: Int) -> Duration { + return a * attempt + b + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@usableFromInline struct ExponentialBackoffStrategy: BackoffStrategy { + @usableFromInline let a: Duration + @usableFromInline let b: Double + @usableFromInline init(a: Duration, b: Double) { + self.a = a + self.b = b + } + @inlinable func duration(_ attempt: Int) -> Duration { + return a * pow(b, Double(attempt)) + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@usableFromInline +struct MinimumBackoffStrategy: BackoffStrategy { + @usableFromInline var base: Base + @usableFromInline let minimum: Base.Duration + @usableFromInline init(base: Base, minimum: Base.Duration) { + self.base = base + self.minimum = minimum + } + @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { + return max(minimum, base.duration(attempt)) + } + @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { + return max(minimum, base.duration(attempt, using: &generator)) + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@usableFromInline +struct MaximumBackoffStrategy: BackoffStrategy { + @usableFromInline var base: Base + @usableFromInline let maximum: Base.Duration + @usableFromInline init(base: Base, maximum: Base.Duration) { + self.base = base + self.maximum = maximum + } + @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { + return min(maximum, base.duration(attempt)) + } + @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { + return min(maximum, base.duration(attempt, using: &generator)) + } +} + +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +@usableFromInline +struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { + @usableFromInline var base: Base + @usableFromInline init(base: Base) { + self.base = base + } + @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { + return .init(attoseconds: Int128.random(in: 0...base.duration(attempt).attoseconds)) + } + @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { + return .init(attoseconds: Int128.random(in: 0...base.duration(attempt, using: &generator).attoseconds, using: &generator)) + } +} + +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +@usableFromInline +struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { + @usableFromInline var base: Base + @usableFromInline init(base: Base) { + self.base = base + } + @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { + let halfBase = (base.duration(attempt) / 2).attoseconds + return .init(attoseconds: halfBase + Int128.random(in: 0...halfBase)) + } + @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { + let halfBase = (base.duration(attempt, using: &generator) / 2).attoseconds + return .init(attoseconds: halfBase + Int128.random(in: 0...halfBase, using: &generator)) + } +} + +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +@usableFromInline +struct DecorrelatedJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { + @usableFromInline var base: Base + @usableFromInline let divisor: Int128 + @usableFromInline var previousDuration: Duration? + @usableFromInline init(base: Base, divisor: Int128) { + self.base = base + self.divisor = divisor + } + @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { + let base = base.duration(attempt) + let previousDuration = previousDuration ?? base + self.previousDuration = previousDuration + return .init(attoseconds: Int128.random(in: base.attoseconds...previousDuration.attoseconds / divisor)) + } + @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { + let base = base.duration(attempt, using: &generator) + let previousDuration = previousDuration ?? base + self.previousDuration = previousDuration + return .init(attoseconds: Int128.random(in: base.attoseconds...previousDuration.attoseconds / divisor, using: &generator)) + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public enum Backoff { + @inlinable public static func constant(_ c: Duration) -> some BackoffStrategy { + return ConstantBackoffStrategy(c: c) + } + @inlinable public static func constant(_ c: Duration) -> some BackoffStrategy { + return ConstantBackoffStrategy(c: c) + } + @inlinable public static func linear(increment a: Duration, initial b: Duration) -> some BackoffStrategy { + return LinearBackoffStrategy(a: a, b: b) + } + @inlinable public static func linear(increment a: Duration, initial b: Duration) -> some BackoffStrategy { + return LinearBackoffStrategy(a: a, b: b) + } + @inlinable public static func exponential(multiplier b: Double = 2, initial a: Duration) -> some BackoffStrategy { + return ExponentialBackoffStrategy(a: a, b: b) + } +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +extension BackoffStrategy { + @inlinable public func minimum(_ minimum: Duration) -> some BackoffStrategy { + return MinimumBackoffStrategy(base: self, minimum: minimum) + } + @inlinable public func maximum(_ maximum: Duration) -> some BackoffStrategy { + return MaximumBackoffStrategy(base: self, maximum: maximum) + } +} + +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +extension BackoffStrategy where Duration == Swift.Duration { + @inlinable public func fullJitter() -> some BackoffStrategy { + return FullJitterBackoffStrategy(base: self) + } + @inlinable public func equalJitter() -> some BackoffStrategy { + return EqualJitterBackoffStrategy(base: self) + } + @inlinable public func decorrelatedJitter(divisor: Int = 3) -> some BackoffStrategy { + return DecorrelatedJitterBackoffStrategy(base: self, divisor: Int128(divisor)) + } +} diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift new file mode 100644 index 00000000..83d4cde0 --- /dev/null +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -0,0 +1,51 @@ +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public enum RetryStrategy { + case backoff(Duration) + case stop +} + +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +@inlinable +public func retry( + maxAttempts: Int = 3, + tolerance: ClockType.Instant.Duration? = nil, + clock: ClockType, + isolation: isolated (any Actor)? = #isolation, + operation: () async throws(ErrorType) -> sending Result, + strategy: (_ attempt: Int, ErrorType) -> RetryStrategy = { _, _ in .backoff(.zero) } +) async throws -> Result where ClockType: Clock, ErrorType: Error { + precondition(maxAttempts >= 0, "Must have at least one attempt") + for attempt in 0..( + maxAttempts: Int = 3, + tolerance: ContinuousClock.Instant.Duration? = nil, + isolation: isolated (any Actor)? = #isolation, + operation: () async throws(ErrorType) -> sending Result, + strategy: (_ attempt: Int, ErrorType) -> RetryStrategy = { _, _ in .backoff(.zero) } +) async throws -> Result where ErrorType: Error { + return try await retry( + maxAttempts: maxAttempts, + tolerance: tolerance, + clock: ContinuousClock(), + operation: operation, + strategy: strategy + ) +} diff --git a/Sources/_CPowSupport/_CPowSupport.c b/Sources/_CPowSupport/_CPowSupport.c new file mode 100644 index 00000000..8e8daf9d --- /dev/null +++ b/Sources/_CPowSupport/_CPowSupport.c @@ -0,0 +1 @@ +#include "_CPowSupport.h" diff --git a/Sources/_CPowSupport/include/_CPowSupport.h b/Sources/_CPowSupport/include/_CPowSupport.h new file mode 100644 index 00000000..75acf947 --- /dev/null +++ b/Sources/_CPowSupport/include/_CPowSupport.h @@ -0,0 +1,3 @@ +static inline __attribute__((__always_inline__)) double pow(double x, double y) { + return __builtin_pow(x, y); +} From f32aca61515ecbd672f38f64426b24cac4e45625 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Fri, 5 Sep 2025 13:08:04 +0200 Subject: [PATCH 02/13] add missing inlinable annotation --- Sources/AsyncAlgorithms/Retry/Backoff.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index ddfbdc39..92091d4e 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -21,7 +21,7 @@ public protocol BackoffStrategy { @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) extension BackoffStrategy { - public mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration { + @inlinable public mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration { return duration(attempt) } } From 03b623dc75c86536939e1e6e01b21ef5422f5a85 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Fri, 5 Sep 2025 14:32:44 +0200 Subject: [PATCH 03/13] correct logic --- Sources/AsyncAlgorithms/Retry/Retry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index 83d4cde0..c583cd07 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -14,7 +14,7 @@ public func retry( operation: () async throws(ErrorType) -> sending Result, strategy: (_ attempt: Int, ErrorType) -> RetryStrategy = { _, _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error { - precondition(maxAttempts >= 0, "Must have at least one attempt") + precondition(maxAttempts > 0, "Must have at least one attempt") for attempt in 0.. Date: Fri, 12 Sep 2025 00:34:35 +0200 Subject: [PATCH 04/13] mr feedback (#1) --- Package.swift | 2 - Sources/AsyncAlgorithms/Retry/Backoff.swift | 159 +++++++++----------- Sources/AsyncAlgorithms/Retry/Retry.swift | 26 +++- Sources/_CPowSupport/_CPowSupport.c | 1 - Sources/_CPowSupport/include/_CPowSupport.h | 3 - 5 files changed, 90 insertions(+), 101 deletions(-) delete mode 100644 Sources/_CPowSupport/_CPowSupport.c delete mode 100644 Sources/_CPowSupport/include/_CPowSupport.h diff --git a/Package.swift b/Package.swift index 9b39078d..c5c73311 100644 --- a/Package.swift +++ b/Package.swift @@ -17,11 +17,9 @@ let package = Package( ], targets: [ .systemLibrary(name: "_CAsyncSequenceValidationSupport"), - .target(name: "_CPowSupport"), .target( name: "AsyncAlgorithms", dependencies: [ - "_CPowSupport", .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "DequeModule", package: "swift-collections"), ], diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index 92091d4e..ed26a9f5 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -1,5 +1,3 @@ -import _CPowSupport - #if compiler(<6.2) @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) extension Duration { @@ -15,53 +13,47 @@ extension Duration { @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public protocol BackoffStrategy { associatedtype Duration: DurationProtocol - mutating func duration(_ attempt: Int) -> Duration - mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration -} - -@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -extension BackoffStrategy { - @inlinable public mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Duration { - return duration(attempt) - } + mutating func nextDuration() -> Duration } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @usableFromInline struct ConstantBackoffStrategy: BackoffStrategy { - @usableFromInline let c: Duration - @usableFromInline init(c: Duration) { - self.c = c + @usableFromInline let constant: Duration + @usableFromInline init(constant: Duration) { + self.constant = constant } - @inlinable func duration(_ attempt: Int) -> Duration { - return c + @inlinable func nextDuration() -> Duration { + return constant } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @usableFromInline struct LinearBackoffStrategy: BackoffStrategy { - @usableFromInline let a: Duration - @usableFromInline let b: Duration - @usableFromInline init(a: Duration, b: Duration) { - self.a = a - self.b = b + @usableFromInline var current: Duration + @usableFromInline var increment: Duration + @usableFromInline init(increment: Duration, initial: Duration) { + self.current = initial + self.increment = increment } - @inlinable func duration(_ attempt: Int) -> Duration { - return a * attempt + b + @inlinable mutating func nextDuration() -> Duration { + defer { current += increment } + return current } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@usableFromInline struct ExponentialBackoffStrategy: BackoffStrategy { - @usableFromInline let a: Duration - @usableFromInline let b: Double - @usableFromInline init(a: Duration, b: Double) { - self.a = a - self.b = b +@usableFromInline struct ExponentialBackoffStrategy: BackoffStrategy { + @usableFromInline var current: Duration + @usableFromInline let factor: Int + @usableFromInline init(factor: Int, initial: Duration) { + self.current = initial + self.factor = factor } - @inlinable func duration(_ attempt: Int) -> Duration { - return a * pow(b, Double(attempt)) + @inlinable mutating func nextDuration() -> Duration { + defer { current *= factor } + return current } } @@ -74,11 +66,8 @@ struct MinimumBackoffStrategy: BackoffStrategy { self.base = base self.minimum = minimum } - @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { - return max(minimum, base.duration(attempt)) - } - @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { - return max(minimum, base.duration(attempt, using: &generator)) + @inlinable mutating func nextDuration() -> Base.Duration { + return max(minimum, base.nextDuration()) } } @@ -91,86 +80,80 @@ struct MaximumBackoffStrategy: BackoffStrategy { self.base = base self.maximum = maximum } - @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { - return min(maximum, base.duration(attempt)) - } - @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { - return min(maximum, base.duration(attempt, using: &generator)) + @inlinable mutating func nextDuration() -> Base.Duration { + return min(maximum, base.nextDuration()) } } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) @usableFromInline -struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { +struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { @usableFromInline var base: Base - @usableFromInline init(base: Base) { + @usableFromInline var generator: RNG + @usableFromInline init(base: Base, generator: RNG) { self.base = base + self.generator = generator } - @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { - return .init(attoseconds: Int128.random(in: 0...base.duration(attempt).attoseconds)) - } - @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { - return .init(attoseconds: Int128.random(in: 0...base.duration(attempt, using: &generator).attoseconds, using: &generator)) + @inlinable mutating func nextDuration() -> Base.Duration { + return .init(attoseconds: Int128.random(in: 0...base.nextDuration().attoseconds)) } } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) @usableFromInline -struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { +struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { @usableFromInline var base: Base - @usableFromInline init(base: Base) { + @usableFromInline var generator: RNG + @usableFromInline init(base: Base, generator: RNG) { self.base = base + self.generator = generator } - @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { - let halfBase = (base.duration(attempt) / 2).attoseconds - return .init(attoseconds: halfBase + Int128.random(in: 0...halfBase)) - } - @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { - let halfBase = (base.duration(attempt, using: &generator) / 2).attoseconds + @inlinable mutating func nextDuration() -> Base.Duration { + let halfBase = (base.nextDuration() / 2).attoseconds return .init(attoseconds: halfBase + Int128.random(in: 0...halfBase, using: &generator)) } } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) @usableFromInline -struct DecorrelatedJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { +struct DecorrelatedJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { @usableFromInline var base: Base - @usableFromInline let divisor: Int128 - @usableFromInline var previousDuration: Duration? - @usableFromInline init(base: Base, divisor: Int128) { + @usableFromInline var generator: RNG + @usableFromInline var current: Duration? + @usableFromInline let factor: Int + @usableFromInline init(base: Base, generator: RNG, factor: Int) { self.base = base - self.divisor = divisor - } - @inlinable mutating func duration(_ attempt: Int) -> Base.Duration { - let base = base.duration(attempt) - let previousDuration = previousDuration ?? base - self.previousDuration = previousDuration - return .init(attoseconds: Int128.random(in: base.attoseconds...previousDuration.attoseconds / divisor)) + self.generator = generator + self.factor = factor } - @inlinable mutating func duration(_ attempt: Int, using generator: inout some RandomNumberGenerator) -> Base.Duration { - let base = base.duration(attempt, using: &generator) - let previousDuration = previousDuration ?? base - self.previousDuration = previousDuration - return .init(attoseconds: Int128.random(in: base.attoseconds...previousDuration.attoseconds / divisor, using: &generator)) + @inlinable mutating func nextDuration() -> Base.Duration { + let base = base.nextDuration() + let current = current ?? base + let next = Duration(attoseconds: Int128.random(in: base.attoseconds...(current * factor).attoseconds, using: &generator)) + self.current = next + return next } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public enum Backoff { - @inlinable public static func constant(_ c: Duration) -> some BackoffStrategy { - return ConstantBackoffStrategy(c: c) + @inlinable public static func constant(_ constant: Duration) -> some BackoffStrategy { + return ConstantBackoffStrategy(constant: constant) + } + @inlinable public static func constant(_ constant: Duration) -> some BackoffStrategy { + return ConstantBackoffStrategy(constant: constant) } - @inlinable public static func constant(_ c: Duration) -> some BackoffStrategy { - return ConstantBackoffStrategy(c: c) + @inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy { + return LinearBackoffStrategy(increment: increment, initial: initial) } - @inlinable public static func linear(increment a: Duration, initial b: Duration) -> some BackoffStrategy { - return LinearBackoffStrategy(a: a, b: b) + @inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy { + return LinearBackoffStrategy(increment: increment, initial: initial) } - @inlinable public static func linear(increment a: Duration, initial b: Duration) -> some BackoffStrategy { - return LinearBackoffStrategy(a: a, b: b) + @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { + return ExponentialBackoffStrategy(factor: factor, initial: initial) } - @inlinable public static func exponential(multiplier b: Double = 2, initial a: Duration) -> some BackoffStrategy { - return ExponentialBackoffStrategy(a: a, b: b) + @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { + return ExponentialBackoffStrategy(factor: factor, initial: initial) } } @@ -186,13 +169,13 @@ extension BackoffStrategy { @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) extension BackoffStrategy where Duration == Swift.Duration { - @inlinable public func fullJitter() -> some BackoffStrategy { - return FullJitterBackoffStrategy(base: self) + @inlinable public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { + return FullJitterBackoffStrategy(base: self, generator: generator) } - @inlinable public func equalJitter() -> some BackoffStrategy { - return EqualJitterBackoffStrategy(base: self) + @inlinable public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { + return EqualJitterBackoffStrategy(base: self, generator: generator) } - @inlinable public func decorrelatedJitter(divisor: Int = 3) -> some BackoffStrategy { - return DecorrelatedJitterBackoffStrategy(base: self, divisor: Int128(divisor)) + @inlinable public func decorrelatedJitter(factor: Int, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { + return DecorrelatedJitterBackoffStrategy(base: self, generator: generator, factor: factor) } } diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index c583cd07..e7e85f8e 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -1,7 +1,19 @@ @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -public enum RetryStrategy { - case backoff(Duration) - case stop +public struct RetryStrategy { + enum Strategy { + case backoff(Duration) + case stop + } + + let strategy: Strategy + + public static var stop: Self { + return .init(strategy: .stop) + } + + public static func backoff(_ duration: Duration) -> Self { + return .init(strategy: .backoff(duration)) + } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @@ -12,16 +24,16 @@ public func retry( clock: ClockType, isolation: isolated (any Actor)? = #isolation, operation: () async throws(ErrorType) -> sending Result, - strategy: (_ attempt: Int, ErrorType) -> RetryStrategy = { _, _ in .backoff(.zero) } + strategy: (ErrorType) -> RetryStrategy = { _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error { precondition(maxAttempts > 0, "Must have at least one attempt") - for attempt in 0..( tolerance: ContinuousClock.Instant.Duration? = nil, isolation: isolated (any Actor)? = #isolation, operation: () async throws(ErrorType) -> sending Result, - strategy: (_ attempt: Int, ErrorType) -> RetryStrategy = { _, _ in .backoff(.zero) } + strategy: (ErrorType) -> RetryStrategy = { _ in .backoff(.zero) } ) async throws -> Result where ErrorType: Error { return try await retry( maxAttempts: maxAttempts, diff --git a/Sources/_CPowSupport/_CPowSupport.c b/Sources/_CPowSupport/_CPowSupport.c deleted file mode 100644 index 8e8daf9d..00000000 --- a/Sources/_CPowSupport/_CPowSupport.c +++ /dev/null @@ -1 +0,0 @@ -#include "_CPowSupport.h" diff --git a/Sources/_CPowSupport/include/_CPowSupport.h b/Sources/_CPowSupport/include/_CPowSupport.h deleted file mode 100644 index 75acf947..00000000 --- a/Sources/_CPowSupport/include/_CPowSupport.h +++ /dev/null @@ -1,3 +0,0 @@ -static inline __attribute__((__always_inline__)) double pow(double x, double y) { - return __builtin_pow(x, y); -} From 09538a54b4992ada7f307caf792afedcae62ff1b Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Sun, 14 Sep 2025 00:28:32 +0200 Subject: [PATCH 05/13] clean up and preconditions (#2) --- Sources/AsyncAlgorithms/Retry/Backoff.swift | 12 +++++++++--- Sources/AsyncAlgorithms/Retry/Retry.swift | 14 +++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index ed26a9f5..13442c02 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -21,6 +21,7 @@ public protocol BackoffStrategy { struct ConstantBackoffStrategy: BackoffStrategy { @usableFromInline let constant: Duration @usableFromInline init(constant: Duration) { + precondition(constant >= .zero, "Constsnt must be greater than or equal to 0") self.constant = constant } @inlinable func nextDuration() -> Duration { @@ -32,8 +33,10 @@ struct ConstantBackoffStrategy: BackoffStrategy { @usableFromInline struct LinearBackoffStrategy: BackoffStrategy { @usableFromInline var current: Duration - @usableFromInline var increment: 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.current = initial self.increment = increment } @@ -48,6 +51,8 @@ struct LinearBackoffStrategy: BackoffStrategy { @usableFromInline var current: Duration @usableFromInline let factor: Int @usableFromInline init(factor: Int, 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.current = initial self.factor = factor } @@ -109,8 +114,8 @@ struct EqualJitterBackoffStrategy Base.Duration { - let halfBase = (base.nextDuration() / 2).attoseconds - return .init(attoseconds: halfBase + Int128.random(in: 0...halfBase, using: &generator)) + let base = base.nextDuration() + return .init(attoseconds: Int128.random(in: (base / 2).attoseconds...base.attoseconds, using: &generator)) } } @@ -122,6 +127,7 @@ struct DecorrelatedJitterBackoffStrategy= 1, "Factor must be greater than or equal to 1") self.base = base self.generator = generator self.factor = factor diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index e7e85f8e..e81e9694 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -1,17 +1,17 @@ @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public struct RetryStrategy { - enum Strategy { + @usableFromInline enum Strategy { case backoff(Duration) case stop } - - let strategy: Strategy - - public static var stop: Self { + @usableFromInline let strategy: Strategy + @usableFromInline init(strategy: Strategy) { + self.strategy = strategy + } + @inlinable public static var stop: Self { return .init(strategy: .stop) } - - public static func backoff(_ duration: Duration) -> Self { + @inlinable public static func backoff(_ duration: Duration) -> Self { return .init(strategy: .backoff(duration)) } } From a5ff83c8ccf46ec05325d9d45a57a425816644e8 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Mon, 15 Sep 2025 00:30:28 +0200 Subject: [PATCH 06/13] correct decorrelated algorithm --- Sources/AsyncAlgorithms/Retry/Backoff.swift | 47 +++++++++------------ 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index 13442c02..3b92462b 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -17,11 +17,10 @@ public protocol BackoffStrategy { } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@usableFromInline -struct ConstantBackoffStrategy: BackoffStrategy { +@usableFromInline struct ConstantBackoffStrategy: BackoffStrategy { @usableFromInline let constant: Duration @usableFromInline init(constant: Duration) { - precondition(constant >= .zero, "Constsnt must be greater than or equal to 0") + precondition(constant >= .zero, "Constant must be greater than or equal to 0") self.constant = constant } @inlinable func nextDuration() -> Duration { @@ -30,8 +29,7 @@ struct ConstantBackoffStrategy: BackoffStrategy { } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@usableFromInline -struct LinearBackoffStrategy: BackoffStrategy { +@usableFromInline struct LinearBackoffStrategy: BackoffStrategy { @usableFromInline var current: Duration @usableFromInline let increment: Duration @usableFromInline init(increment: Duration, initial: Duration) { @@ -63,8 +61,7 @@ struct LinearBackoffStrategy: BackoffStrategy { } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@usableFromInline -struct MinimumBackoffStrategy: BackoffStrategy { +@usableFromInline struct MinimumBackoffStrategy: BackoffStrategy { @usableFromInline var base: Base @usableFromInline let minimum: Base.Duration @usableFromInline init(base: Base, minimum: Base.Duration) { @@ -77,8 +74,7 @@ struct MinimumBackoffStrategy: BackoffStrategy { } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@usableFromInline -struct MaximumBackoffStrategy: BackoffStrategy { +@usableFromInline struct MaximumBackoffStrategy: BackoffStrategy { @usableFromInline var base: Base @usableFromInline let maximum: Base.Duration @usableFromInline init(base: Base, maximum: Base.Duration) { @@ -91,8 +87,7 @@ struct MaximumBackoffStrategy: BackoffStrategy { } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) -@usableFromInline -struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { +@usableFromInline struct FullJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { @usableFromInline var base: Base @usableFromInline var generator: RNG @usableFromInline init(base: Base, generator: RNG) { @@ -100,13 +95,12 @@ struct FullJitterBackoffStrategy Base.Duration { - return .init(attoseconds: Int128.random(in: 0...base.nextDuration().attoseconds)) + return .init(attoseconds: Int128.random(in: 0...base.nextDuration().attoseconds, using: &generator)) } } @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) -@usableFromInline -struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { +@usableFromInline struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { @usableFromInline var base: Base @usableFromInline var generator: RNG @usableFromInline init(base: Base, generator: RNG) { @@ -120,23 +114,22 @@ struct EqualJitterBackoffStrategy: BackoffStrategy where Base.Duration == Swift.Duration { - @usableFromInline var base: Base +@usableFromInline struct DecorrelatedJitterBackoffStrategy: BackoffStrategy { + @usableFromInline let base: Duration + @usableFromInline let factor: Int @usableFromInline var generator: RNG @usableFromInline var current: Duration? - @usableFromInline let factor: Int - @usableFromInline init(base: Base, generator: RNG, factor: Int) { + @usableFromInline init(base: Duration, factor: Int, generator: RNG) { precondition(factor >= 1, "Factor must be greater than or equal to 1") + precondition(base >= .zero, "Base must be greater than or equal to 0") self.base = base self.generator = generator self.factor = factor } - @inlinable mutating func nextDuration() -> Base.Duration { - let base = base.nextDuration() - let current = current ?? base - let next = Duration(attoseconds: Int128.random(in: base.attoseconds...(current * factor).attoseconds, using: &generator)) - self.current = next + @inlinable mutating func nextDuration() -> Duration { + let previous = current ?? base + let next = Duration(attoseconds: Int128.random(in: base.attoseconds...(previous * factor).attoseconds, using: &generator)) + current = next return next } } @@ -161,6 +154,9 @@ public enum Backoff { @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { return ExponentialBackoffStrategy(factor: factor, initial: initial) } + @inlinable public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG) -> some BackoffStrategy { + return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator) + } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @@ -181,7 +177,4 @@ extension BackoffStrategy where Duration == Swift.Duration { @inlinable public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { return EqualJitterBackoffStrategy(base: self, generator: generator) } - @inlinable public func decorrelatedJitter(factor: Int, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { - return DecorrelatedJitterBackoffStrategy(base: self, generator: generator, factor: factor) - } } From b4b66ec16c61547da765baa7c9b905c164c8752b Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Mon, 15 Sep 2025 00:51:08 +0200 Subject: [PATCH 07/13] revert package shuffle --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index c5c73311..b34c676a 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,6 @@ let package = Package( .library(name: "AsyncAlgorithms", targets: ["AsyncAlgorithms"]) ], targets: [ - .systemLibrary(name: "_CAsyncSequenceValidationSupport"), .target( name: "AsyncAlgorithms", dependencies: [ @@ -34,6 +33,7 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency=complete") ] ), + .systemLibrary(name: "_CAsyncSequenceValidationSupport"), .target( name: "AsyncAlgorithms_XCTest", dependencies: ["AsyncAlgorithms", "AsyncSequenceValidation"], From c8e6f9960500bc4295f12ca0ec8f78e7082cbf0a Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Tue, 16 Sep 2025 00:27:24 +0200 Subject: [PATCH 08/13] fix availability (#4) --- Sources/AsyncAlgorithms/Retry/Backoff.swift | 6 +++++- Sources/AsyncAlgorithms/Retry/Retry.swift | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index 3b92462b..3825b3dd 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -50,7 +50,7 @@ public protocol BackoffStrategy { @usableFromInline let factor: Int @usableFromInline init(factor: Int, 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") + precondition(factor >= .zero, "Factor must be greater than or equal to 0") self.current = initial self.factor = factor } @@ -154,6 +154,10 @@ public enum Backoff { @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { return ExponentialBackoffStrategy(factor: factor, initial: initial) } +} + +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +extension Backoff { @inlinable public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG) -> some BackoffStrategy { return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator) } diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index e81e9694..618d6d53 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -17,8 +17,7 @@ public struct RetryStrategy { } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@inlinable -public func retry( +@inlinable public func retry( maxAttempts: Int = 3, tolerance: ClockType.Instant.Duration? = nil, clock: ClockType, @@ -45,8 +44,7 @@ public func retry( } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@inlinable -public func retry( +@inlinable public func retry( maxAttempts: Int = 3, tolerance: ContinuousClock.Instant.Duration? = nil, isolation: isolated (any Actor)? = #isolation, From 24475e90a47abdfd77609d154f2844205fcf0546 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 07:54:15 +0200 Subject: [PATCH 09/13] add proposal, unit tests and implement feedback (#5) --- Evolution/NNNN-retry-backoff.md | 215 ++++++++++++++++++ Sources/AsyncAlgorithms/Retry/Backoff.swift | 3 +- Sources/AsyncAlgorithms/Retry/Retry.swift | 33 ++- .../Support/SplitMix64.swift | 16 ++ Tests/AsyncAlgorithmsTests/TestBackoff.swift | 135 +++++++++++ Tests/AsyncAlgorithmsTests/TestRetry.swift | 170 ++++++++++++++ 6 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 Evolution/NNNN-retry-backoff.md create mode 100644 Tests/AsyncAlgorithmsTests/Support/SplitMix64.swift create mode 100644 Tests/AsyncAlgorithmsTests/TestBackoff.swift create mode 100644 Tests/AsyncAlgorithmsTests/TestRetry.swift diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md new file mode 100644 index 00000000..1655f2ad --- /dev/null +++ b/Evolution/NNNN-retry-backoff.md @@ -0,0 +1,215 @@ +# 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/) + +## 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 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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public func retry( + maxAttempts: Int, + tolerance: ClockType.Instant.Duration? = nil, + clock: ClockType = ContinuousClock(), + isolation: isolated (any Actor)? = #isolation, + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } +) async throws -> Result where ClockType: Clock, ErrorType: Error +``` + +```swift +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public enum RetryAction { + case backoff(Duration) + case stop +} +``` + +Additionally, this proposal includes a suite of backoff strategies that can be used to generate delays between retry attempts. The core strategies provide different patterns for calculating delays: constant intervals, linear growth, exponential growth, and decorrelated jitter. + +```swift +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +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 linear(increment: Duration, initial: Duration) -> some BackoffStrategy + public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy + public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy +} +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +extension Backoff { + public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> 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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +extension BackoffStrategy { + public func minimum(_ minimum: Duration) -> some BackoffStrategy + public func maximum(_ maximum: Duration) -> some BackoffStrategy +} +@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) +extension BackoffStrategy where Duration == Swift.Duration { + public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy + public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy +} +``` + +Constant, linear, and exponential backoff provide overloads for both `Duration` and `DurationProtocol`. This matches the `retry` overloads where the default clock is `ContinuousClock` whose duration type is `Duration`. + +Jitter variants currently require `Duration` rather than a generic `DurationProtocol`, because only `Duration` exposes a numeric representation suitable for randomization (see [SE-0457](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0457-duration-attosecond-represenation.md)). + +Each of those strategies conforms to the `BackoffStrategy` protocol: + +```swift +@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) +public protocol BackoffStrategy { + associatedtype Duration: DurationProtocol + mutating func nextDuration() -> Duration +} +``` + +## 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 + +#### 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 (which, at the time of writing, both `ContinuousClock` and `SuspendingClock` do). + +### Backoff + +All proposed strategies conform to `BackoffStrategy` which allows for builder-like syntax like this: +```swift +var backoff = Backoff + .exponential(factor: 2, initial: .milliseconds(100)) + .maximum(.seconds(5)) + .fullJitter() +``` + +#### Custom backoff + +Adopters may choose to create their own strategies. There is no requirement to conform to `BackoffStrategy`, since retry and backoff are decoupled; however, to use the provided modifiers (`minimum`, `maximum`, `jitter`), a strategy must conform. + +Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are naturally stateful. For instance, they may track the number of invocations or the previously returned duration to calculate the next delay. + +#### Standard backoff + +As previously mentioned this proposal introduces several common backoff strategies which include: + +- **Constant**: $f(n) = constant$ +- **Linear**: $f(n) = initial + increment * n$ +- **Exponential**: $f(n) = initial * factor ^ n$ +- **Decorrelated Jitter**: $f(n) = random(base, f(n - 1) * factor)$ where $f(0) = base$ +- **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 + +##### Sendability + +The proposed backoff strategies are not marked `Sendable`. +They are not meant to be shared across isolation domains, because their state evolves with each call to `nextDuration()`. +Re-creating the strategies when they are used in different domains is usually the correct approach. + +### 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 rng = SystemRandomNumberGenerator() // or a seeded RNG for unit tests +var backoff = Backoff + .exponential(factor: 2, initial: .milliseconds(100)) + .maximum(.seconds(10)) + .fullJitter(using: rng) + +let response = try await retry(maxAttempts: 5) { + try await URLSession.shared.data(from: url) +} strategy: { error in + return .backoff(backoff.nextDuration()) +} +``` + +```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. + +## Future directions + +The jitter variants introduced by this proposal support custom `RandomNumberGenerator` by **copying** it in order to perform the necessary mutations. +This is not optimal and does not match the standard library's signatures of e.g. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. +Due to the composability of backoff algorithms proposed here, this is not possible to adopt in current Swift. +If Swift gains the capability to "store" `inout` variables, the jitter variants should adopt this by adding new `inout` overloads and deprecating the copying overloads. + +## Alternatives considered + +Another option considered was to pass the current attempt number into the `BackoffStrategy`. + +Although this initially seems useful, it conflicts with the idea of strategies being stateful. A strategy is supposed to track its own progression (e.g. by counting invocations or storing the last duration). If the attempt number were provided externally, strategies would become "semi-stateful": mutating because of internal components such as a `RandomNumberGenerator`, but at the same time relying on an external counter instead of their own stored history. This dual model is harder to reason about and less consistent, so it was deliberately avoided. + +If adopters require access to the attempt number, they are free to implement this themselves, since the strategy is invoked each time a failure occurs, making it straightforward to maintain an external attempt counter. + +## 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. \ No newline at end of file diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index 3825b3dd..aca75e95 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -50,7 +50,6 @@ public protocol BackoffStrategy { @usableFromInline let factor: Int @usableFromInline init(factor: Int, initial: Duration) { precondition(initial >= .zero, "Initial must be greater than or equal to 0") - precondition(factor >= .zero, "Factor must be greater than or equal to 0") self.current = initial self.factor = factor } @@ -158,7 +157,7 @@ public enum Backoff { @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) extension Backoff { - @inlinable public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG) -> some BackoffStrategy { + @inlinable public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator) } } diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index 618d6d53..a7e33fdc 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -1,40 +1,39 @@ @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -public struct RetryStrategy { - @usableFromInline enum Strategy { +public struct RetryAction { + @usableFromInline enum Action { case backoff(Duration) case stop } - @usableFromInline let strategy: Strategy - @usableFromInline init(strategy: Strategy) { - self.strategy = strategy + @usableFromInline let action: Action + @usableFromInline init(action: Action) { + self.action = action } @inlinable public static var stop: Self { - return .init(strategy: .stop) + return .init(action: .stop) } @inlinable public static func backoff(_ duration: Duration) -> Self { - return .init(strategy: .backoff(duration)) + return .init(action: .backoff(duration)) } } @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @inlinable public func retry( - maxAttempts: Int = 3, + maxAttempts: Int, tolerance: ClockType.Instant.Duration? = nil, clock: ClockType, isolation: isolated (any Actor)? = #isolation, - operation: () async throws(ErrorType) -> sending Result, - strategy: (ErrorType) -> RetryStrategy = { _ in .backoff(.zero) } + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error { precondition(maxAttempts > 0, "Must have at least one attempt") for _ in 0.. { @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @inlinable public func retry( - maxAttempts: Int = 3, + maxAttempts: Int, tolerance: ContinuousClock.Instant.Duration? = nil, isolation: isolated (any Actor)? = #isolation, - operation: () async throws(ErrorType) -> sending Result, - strategy: (ErrorType) -> RetryStrategy = { _ in .backoff(.zero) } + operation: () async throws(ErrorType) -> Result, + strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ErrorType: Error { return try await retry( maxAttempts: maxAttempts, 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..836ef9b7 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestBackoff.swift @@ -0,0 +1,135 @@ +import AsyncAlgorithms +import Testing + +@Suite struct BackoffTests { + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func constantBackoff() { + var strategy = Backoff.constant(Duration.milliseconds(5)) + #expect(strategy.nextDuration() == .milliseconds(5)) + #expect(strategy.nextDuration() == .milliseconds(5)) + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func linearBackoff() { + var strategy = Backoff.linear(increment: .milliseconds(2), initial: .milliseconds(1)) + #expect(strategy.nextDuration() == .milliseconds(1)) + #expect(strategy.nextDuration() == .milliseconds(3)) + #expect(strategy.nextDuration() == .milliseconds(5)) + #expect(strategy.nextDuration() == .milliseconds(7)) + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func exponentialBackoff() { + var strategy = Backoff.exponential(factor: 2, initial: .milliseconds(1)) + #expect(strategy.nextDuration() == .milliseconds(1)) + #expect(strategy.nextDuration() == .milliseconds(2)) + #expect(strategy.nextDuration() == .milliseconds(4)) + #expect(strategy.nextDuration() == .milliseconds(8)) + } + + @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) + @Test func decorrelatedJitter() { + var strategy = Backoff.decorrelatedJitter(factor: 3, base: .milliseconds(1), using: SplitMix64(seed: 43)) + #expect(strategy.nextDuration() == Duration(attoseconds: 2225543084173069)) // 2.22 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 5714816987299352)) // 5.71 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 2569829207199874)) // 2.56 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 6927552963135803)) // 6.92 ms + } + + @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) + @Test func fullJitter() { + var strategy = Backoff.constant(.milliseconds(100)).fullJitter(using: SplitMix64(seed: 42)) + #expect(strategy.nextDuration() == Duration(attoseconds: 15991039287692012)) // 15.99 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 34419071652363758)) // 34.41 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 86822807654653238)) // 86.82 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 80063187671350344)) // 80.06 ms + } + + @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) + @Test func equalJitter() { + var strategy = Backoff.constant(.milliseconds(100)).equalJitter(using: SplitMix64(seed: 42)) + #expect(strategy.nextDuration() == Duration(attoseconds: 57995519643846006)) // 57.99 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 67209535826181879)) // 67.20 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 93411403827326619)) // 93.41 ms + #expect(strategy.nextDuration() == Duration(attoseconds: 90031593835675172)) // 90.03 ms + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func minimum() { + var strategy = Backoff.exponential(factor: 2, initial: .milliseconds(1)).minimum(.milliseconds(2)) + #expect(strategy.nextDuration() == .milliseconds(2)) // 1 clamped to min 2 + #expect(strategy.nextDuration() == .milliseconds(2)) // 2 unchanged + #expect(strategy.nextDuration() == .milliseconds(4)) // 4 unchanged + #expect(strategy.nextDuration() == .milliseconds(8)) // 8 unchanged + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func maximum() { + var strategy = Backoff.exponential(factor: 2, initial: .milliseconds(1)).maximum(.milliseconds(5)) + #expect(strategy.nextDuration() == .milliseconds(1)) // 1 unchanged + #expect(strategy.nextDuration() == .milliseconds(2)) // 2 unchanged + #expect(strategy.nextDuration() == .milliseconds(4)) // 4 unchanged + #expect(strategy.nextDuration() == .milliseconds(5)) // 8 unchanged clamped to max 5 + } + + #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Windows) + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @Test func constantPrecondition() async { + await #expect(processExitsWith: .success) { + _ = Backoff.constant(.milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.constant(.milliseconds(-1)) + } + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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: .success) { + _ = Backoff.exponential(factor: -1, initial: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.exponential(factor: -1, initial: .milliseconds(-1)) + } + } + + @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) + @Test func decorrelatedJitterPrecondition() async { + await #expect(processExitsWith: .success) { + _ = Backoff.decorrelatedJitter(factor: 1, base: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.decorrelatedJitter(factor: 1, base: .milliseconds(-1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.decorrelatedJitter(factor: -1, base: .milliseconds(1)) + } + await #expect(processExitsWith: .failure) { + _ = Backoff.decorrelatedJitter(factor: -1, base: .milliseconds(-1)) + } + } + #endif +} diff --git a/Tests/AsyncAlgorithmsTests/TestRetry.swift b/Tests/AsyncAlgorithmsTests/TestRetry.swift new file mode 100644 index 00000000..dd60e671 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestRetry.swift @@ -0,0 +1,170 @@ +@testable import AsyncAlgorithms +import Testing + +@Suite struct RetryTests { + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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 + if error is CustomCancellationError { + return .stop + } else { + return .backoff(.zero) + } + } + } + task.cancel() + await #expect(throws: CustomCancellationError.self) { + try await task.value + } + } + + @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) + @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 + } + } + + #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Windows) + @available(macCatalyst 16.0, macOS 13.0, *) + @Test func zeroAttempts() async { + await #expect(processExitsWith: .failure) { + try await retry(maxAttempts: 0) { } + } + } + #endif +} From 2406360f1175f310deb37e6c55b82bf2b75f60f9 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 22:18:37 +0200 Subject: [PATCH 10/13] add docs --- Sources/AsyncAlgorithms/Retry/Backoff.swift | 170 ++++++++++++++++++-- Sources/AsyncAlgorithms/Retry/Retry.swift | 82 ++++++++++ 2 files changed, 240 insertions(+), 12 deletions(-) diff --git a/Sources/AsyncAlgorithms/Retry/Backoff.swift b/Sources/AsyncAlgorithms/Retry/Backoff.swift index aca75e95..8868d376 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -1,15 +1,17 @@ -#if compiler(<6.2) -@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) -extension Duration { - @usableFromInline var attoseconds: Int128 { - return Int128(_low: _low, _high: _high) - } - @usableFromInline init(attoseconds: Int128) { - self.init(_high: attoseconds._high, low: attoseconds._low) - } -} -#endif - +#if compiler(>=6.2) +/// A protocol for defining backoff strategies that generate delays between retry attempts. +/// +/// Backoff strategies are stateful and generate progressively changing delays based on their +/// internal algorithm. Each call to `nextDuration()` returns the delay for the next retry attempt. +/// +/// ## Example +/// +/// ```swift +/// var strategy = Backoff.exponential(factor: 2, initial: .milliseconds(100)) +/// strategy.nextDuration() // 100ms +/// strategy.nextDuration() // 200ms +/// strategy.nextDuration() // 400ms +/// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public protocol BackoffStrategy { associatedtype Duration: DurationProtocol @@ -135,21 +137,96 @@ public protocol BackoffStrategy { @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public enum Backoff { + /// Creates a constant backoff strategy that always returns the same delay. + /// + /// Formula: `f(n) = constant` + /// + /// - 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 { return ConstantBackoffStrategy(constant: constant) } + + /// Creates a constant backoff strategy that always returns the same delay. + /// + /// Formula: `f(n) = constant` + /// + /// - Parameter constant: The fixed duration to wait between retry attempts. + /// - Returns: A backoff strategy that always returns the constant duration. + /// + /// ## Example + /// + /// ```swift + /// var backoff = Backoff.constant(.milliseconds(100)) + /// backoff.nextDuration() // 100ms + /// backoff.nextDuration() // 100ms + /// ``` @inlinable public static func constant(_ constant: Duration) -> some BackoffStrategy { return ConstantBackoffStrategy(constant: constant) } + + /// Creates a linear backoff strategy where delays increase by a fixed increment. + /// + /// Formula: `f(n) = initial + increment * n` + /// + /// - 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. @inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy { return LinearBackoffStrategy(increment: increment, initial: initial) } + + /// Creates a linear backoff strategy where delays increase by a fixed increment. + /// + /// Formula: `f(n) = initial + increment * n` + /// + /// - 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. + /// + /// ## Example + /// + /// ```swift + /// var backoff = Backoff.linear(increment: .milliseconds(100), initial: .milliseconds(100)) + /// backoff.nextDuration() // 100ms + /// backoff.nextDuration() // 200ms + /// backoff.nextDuration() // 300ms + /// ``` @inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy { return LinearBackoffStrategy(increment: increment, initial: initial) } + + /// Creates an exponential backoff strategy where delays grow exponentially. + /// + /// Formula: `f(n) = initial * factor^n` + /// + /// - 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. @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { return ExponentialBackoffStrategy(factor: factor, initial: initial) } + + /// Creates an exponential backoff strategy where delays grow exponentially. + /// + /// Formula: `f(n) = initial * factor^n` + /// + /// - 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. + /// + /// ## Example + /// + /// ```swift + /// var backoff = Backoff.exponential(factor: 2, initial: .milliseconds(100)) + /// backoff.nextDuration() // 100ms + /// backoff.nextDuration() // 200ms + /// backoff.nextDuration() // 400ms + /// ``` @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy { return ExponentialBackoffStrategy(factor: factor, initial: initial) } @@ -157,6 +234,18 @@ public enum Backoff { @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) extension Backoff { + /// Creates a decorrelated jitter backoff strategy that uses randomized delays. + /// + /// Formula: `f(n) = random(base, f(n - 1) * factor)` where `f(0) = base` + /// + /// Jitter prevents the "thundering herd" problem where multiple clients retry + /// simultaneously, reducing server load spikes and improving system stability. + /// + /// - Parameters: + /// - factor: The multiplication factor for calculating the upper bound of randomness. + /// - base: The base duration used as the minimum delay and initial reference. + /// - generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`. + /// - Returns: A backoff strategy with decorrelated jitter. @inlinable public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator) } @@ -164,9 +253,46 @@ extension Backoff { @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) 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 + /// var backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .minimum(.milliseconds(200)) + /// backoff.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 + /// var backoff = Backoff + /// .exponential(factor: 2, initial: .milliseconds(100)) + /// .maximum(.seconds(5)) + /// // Delays will cap at 5 seconds instead of growing indefinitely + /// ``` @inlinable public func maximum(_ maximum: Duration) -> some BackoffStrategy { return MaximumBackoffStrategy(base: self, maximum: maximum) } @@ -174,10 +300,30 @@ extension BackoffStrategy { @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) extension BackoffStrategy where Duration == Swift.Duration { + /// 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. + /// + /// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`. + /// - Returns: A backoff strategy with full jitter applied. @inlinable public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { return FullJitterBackoffStrategy(base: self, generator: generator) } + + /// Applies equal jitter to this backoff strategy. + /// + /// Formula: `f(n) = random(g(n) / 2, 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. + /// + /// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`. + /// - Returns: A backoff strategy with equal jitter applied. @inlinable public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy { return EqualJitterBackoffStrategy(base: self, generator: generator) } } +#endif diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index a7e33fdc..bc31be9c 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -1,3 +1,4 @@ +#if compiler(>=6.2) @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) public struct RetryAction { @usableFromInline enum Action { @@ -8,14 +9,58 @@ public struct RetryAction { @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. + /// - Returns: A retry action that will cause the retry operation to wait. @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 attempts to execute the provided operation up to `maxAttempts` times. +/// Between failed attempts, it consults the strategy function to determine whether to +/// continue retrying with a delay or stop immediately. +/// +/// 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 +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. Must be greater than 0. +/// - tolerance: The tolerance for the sleep operation between retries. +/// - clock: The clock to use for timing delays between retries. +/// - isolation: The actor isolation to maintain during execution. +/// - 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 +/// +/// ```swift +/// var backoff = Backoff.exponential(factor: 2, initial: .milliseconds(100)) +/// let result = try await retry(maxAttempts: 3, clock: ContinuousClock()) { +/// try await someNetworkOperation() +/// } strategy: { error in +/// .backoff(backoff.nextDuration()) +/// } +/// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @inlinable public func retry( maxAttempts: Int, @@ -42,6 +87,42 @@ public struct RetryAction { return try await operation() } +/// Executes an asynchronous operation with retry logic and customizable backoff strategies. +/// +/// This function attempts to execute the provided operation up to `maxAttempts` times. +/// Between failed attempts, it consults the strategy function to determine whether to +/// continue retrying with a delay or stop immediately. +/// +/// 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 +/// +/// - Parameters: +/// - maxAttempts: The maximum number of attempts to make. Must be greater than 0. +/// - tolerance: The tolerance for the sleep operation between retries. +/// - isolation: The actor isolation to maintain during execution. +/// - 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 +/// +/// ```swift +/// var backoff = Backoff.exponential(factor: 2, initial: .milliseconds(100)) +/// let result = try await retry(maxAttempts: 3) { +/// try await someNetworkOperation() +/// } strategy: { error in +/// .backoff(backoff.nextDuration()) +/// } +/// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @inlinable public func retry( maxAttempts: Int, @@ -58,3 +139,4 @@ public struct RetryAction { strategy: strategy ) } +#endif From 93f21b4612f9529ffe26a5957aada67f4207baf0 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 2 Oct 2025 18:27:12 +0200 Subject: [PATCH 11/13] unify docs and proposal --- Evolution/NNNN-retry-backoff.md | 10 ++++- Sources/AsyncAlgorithms/Retry/Backoff.swift | 28 +++++++++--- Sources/AsyncAlgorithms/Retry/Retry.swift | 49 ++++++++++++++++----- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 1655f2ad..bff118e2 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -204,12 +204,20 @@ If Swift gains the capability to "store" `inout` variables, the jitter variants ## Alternatives considered +### Passing attempt number to `BackoffStrategy ` + Another option considered was to pass the current attempt number into the `BackoffStrategy`. Although this initially seems useful, it conflicts with the idea of strategies being stateful. A strategy is supposed to track its own progression (e.g. by counting invocations or storing the last duration). If the attempt number were provided externally, strategies would become "semi-stateful": mutating because of internal components such as a `RandomNumberGenerator`, but at the same time relying on an external counter instead of their own stored history. This dual model is harder to reason about and less consistent, so it was deliberately avoided. If adopters require access to the attempt number, they are free to implement this themselves, since the strategy 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. + ## 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. \ No newline at end of file +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 index 8868d376..4cb4d542 100644 --- a/Sources/AsyncAlgorithms/Retry/Backoff.swift +++ b/Sources/AsyncAlgorithms/Retry/Backoff.swift @@ -1,8 +1,12 @@ #if compiler(>=6.2) /// A protocol for defining backoff strategies that generate delays between retry attempts. /// -/// Backoff strategies are stateful and generate progressively changing delays based on their -/// internal algorithm. Each call to `nextDuration()` returns the delay for the next retry attempt. +/// Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are +/// naturally stateful. For instance, they may track the number of invocations or the previously +/// returned duration to calculate the next delay. +/// +/// - Precondition: Strategies should only increase or stay the same over time, never decrease. +/// Decreasing delays may cause issues with modifiers like jitter which expect non-decreasing values. /// /// ## Example /// @@ -141,6 +145,8 @@ public enum Backoff { /// /// 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 { @@ -151,6 +157,8 @@ public enum Backoff { /// /// 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. /// @@ -169,6 +177,8 @@ public enum Backoff { /// /// 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. @@ -181,6 +191,8 @@ public enum Backoff { /// /// 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. @@ -202,6 +214,8 @@ public enum Backoff { /// /// Formula: `f(n) = initial * factor^n` /// + /// - Precondition: `initial` must be greater than or equal to zero. + /// /// - Parameters: /// - factor: The multiplication factor for each retry attempt. /// - initial: The initial delay for the first retry attempt. @@ -214,6 +228,8 @@ public enum Backoff { /// /// Formula: `f(n) = initial * factor^n` /// + /// - Precondition: `initial` must be greater than or equal to zero. + /// /// - Parameters: /// - factor: The multiplication factor for each retry attempt. /// - initial: The initial delay for the first retry attempt. @@ -238,9 +254,11 @@ extension Backoff { /// /// Formula: `f(n) = random(base, f(n - 1) * factor)` where `f(0) = base` /// - /// Jitter prevents the "thundering herd" problem where multiple clients retry + /// Jitter prevents the thundering herd problem where multiple clients retry /// simultaneously, reducing server load spikes and improving system stability. /// + /// - Precondition: `factor` must be greater than or equal to 1, and `base` must be greater than or equal to zero. + /// /// - Parameters: /// - factor: The multiplication factor for calculating the upper bound of randomness. /// - base: The base duration used as the minimum delay and initial reference. @@ -304,7 +322,7 @@ extension BackoffStrategy where Duration == Swift.Duration { /// /// Formula: `f(n) = random(0, g(n))` where `g(n)` is the base strategy /// - /// Jitter prevents the "thundering herd" problem where multiple clients retry + /// Jitter prevents the thundering herd problem where multiple clients retry /// simultaneously, reducing server load spikes and improving system stability. /// /// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`. @@ -317,7 +335,7 @@ extension BackoffStrategy where Duration == Swift.Duration { /// /// Formula: `f(n) = random(g(n) / 2, g(n))` where `g(n)` is the base strategy /// - /// Jitter prevents the "thundering herd" problem where multiple clients retry + /// Jitter prevents the thundering herd problem where multiple clients retry /// simultaneously, reducing server load spikes and improving system stability. /// /// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`. diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index bc31be9c..0df25335 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -18,7 +18,6 @@ public struct RetryAction { /// Indicates that retrying should continue after waiting for the specified duration. /// /// - Parameter duration: The duration to wait before the next retry attempt. - /// - Returns: A retry action that will cause the retry operation to wait. @inlinable public static func backoff(_ duration: Duration) -> Self { return .init(action: .backoff(duration)) } @@ -26,9 +25,8 @@ public struct RetryAction { /// Executes an asynchronous operation with retry logic and customizable backoff strategies. /// -/// This function attempts to execute the provided operation up to `maxAttempts` times. -/// Between failed attempts, it consults the strategy function to determine whether to -/// continue retrying with a delay or stop immediately. +/// 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 @@ -40,8 +38,23 @@ public struct RetryAction { /// - 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 clock throws on cancellation (which, at the time of writing, +/// both `ContinuousClock` and `SuspendingClock` do). +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// /// - Parameters: -/// - maxAttempts: The maximum number of attempts to make. Must be greater than 0. +/// - maxAttempts: The maximum number of attempts to make. /// - tolerance: The tolerance for the sleep operation between retries. /// - clock: The clock to use for timing delays between retries. /// - isolation: The actor isolation to maintain during execution. @@ -58,7 +71,7 @@ public struct RetryAction { /// let result = try await retry(maxAttempts: 3, clock: ContinuousClock()) { /// try await someNetworkOperation() /// } strategy: { error in -/// .backoff(backoff.nextDuration()) +/// return .backoff(backoff.nextDuration()) /// } /// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @@ -89,9 +102,8 @@ public struct RetryAction { /// Executes an asynchronous operation with retry logic and customizable backoff strategies. /// -/// This function attempts to execute the provided operation up to `maxAttempts` times. -/// Between failed attempts, it consults the strategy function to determine whether to -/// continue retrying with a delay or stop immediately. +/// 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 @@ -103,8 +115,23 @@ public struct RetryAction { /// - 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 clock throws on cancellation (which, at the time of writing, +/// both `ContinuousClock` and `SuspendingClock` do). +/// +/// - Precondition: `maxAttempts` must be greater than 0. +/// /// - Parameters: -/// - maxAttempts: The maximum number of attempts to make. Must be greater than 0. +/// - maxAttempts: The maximum number of attempts to make. /// - tolerance: The tolerance for the sleep operation between retries. /// - isolation: The actor isolation to maintain during execution. /// - operation: The asynchronous operation to retry. @@ -120,7 +147,7 @@ public struct RetryAction { /// let result = try await retry(maxAttempts: 3) { /// try await someNetworkOperation() /// } strategy: { error in -/// .backoff(backoff.nextDuration()) +/// return .backoff(backoff.nextDuration()) /// } /// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) From 0d21f311154116984cfdf06340e724bc1127e569 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 2 Oct 2025 18:52:03 +0200 Subject: [PATCH 12/13] Update NNNN-retry-backoff.md --- Evolution/NNNN-retry-backoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index bff118e2..9048304c 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -9,7 +9,7 @@ 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/) +Swift forums thread: [Discussion thread topic for that proposal](https://forums.swift.org/t/pitch-retry-backoff/82483) ## Motivation From d67a1f1d3e3c4c5b1386aa45de27fcf3f2edce75 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Sun, 5 Oct 2025 12:54:29 +0200 Subject: [PATCH 13/13] use nonisolated(nonsending) --- Evolution/NNNN-retry-backoff.md | 3 +-- Sources/AsyncAlgorithms/Retry/Retry.swift | 6 ++---- Tests/AsyncAlgorithmsTests/TestBackoff.swift | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 9048304c..6de7f703 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -23,11 +23,10 @@ This proposal introduces a retry function that executes an asynchronous operatio ```swift @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -public func retry( +nonisolated(nonsending) public func retry( maxAttempts: Int, tolerance: ClockType.Instant.Duration? = nil, clock: ClockType = ContinuousClock(), - isolation: isolated (any Actor)? = #isolation, operation: () async throws(ErrorType) -> Result, strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error diff --git a/Sources/AsyncAlgorithms/Retry/Retry.swift b/Sources/AsyncAlgorithms/Retry/Retry.swift index 0df25335..2522747b 100644 --- a/Sources/AsyncAlgorithms/Retry/Retry.swift +++ b/Sources/AsyncAlgorithms/Retry/Retry.swift @@ -75,11 +75,10 @@ public struct RetryAction { /// } /// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@inlinable public func retry( +@inlinable nonisolated(nonsending) public func retry( maxAttempts: Int, tolerance: ClockType.Instant.Duration? = nil, clock: ClockType, - isolation: isolated (any Actor)? = #isolation, operation: () async throws(ErrorType) -> Result, strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error { @@ -151,10 +150,9 @@ public struct RetryAction { /// } /// ``` @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) -@inlinable public func retry( +@inlinable nonisolated(nonsending) public func retry( maxAttempts: Int, tolerance: ContinuousClock.Instant.Duration? = nil, - isolation: isolated (any Actor)? = #isolation, operation: () async throws(ErrorType) -> Result, strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ErrorType: Error { diff --git a/Tests/AsyncAlgorithmsTests/TestBackoff.swift b/Tests/AsyncAlgorithmsTests/TestBackoff.swift index 836ef9b7..d481a691 100644 --- a/Tests/AsyncAlgorithmsTests/TestBackoff.swift +++ b/Tests/AsyncAlgorithmsTests/TestBackoff.swift @@ -5,7 +5,7 @@ import Testing @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) @Test func constantBackoff() { - var strategy = Backoff.constant(Duration.milliseconds(5)) + var strategy = Backoff.constant(.milliseconds(5)) #expect(strategy.nextDuration() == .milliseconds(5)) #expect(strategy.nextDuration() == .milliseconds(5)) }