From 883de57388be6f57f195814b96908615fa473cc8 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Mon, 22 Sep 2025 21:59:50 +0200 Subject: [PATCH 01/24] wip --- 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 ++++++++++++++++++ 5 files changed, 338 insertions(+), 19 deletions(-) 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/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 de7ce2a9eee8dbb14e1c3e180d1f788051c9f28b Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Tue, 23 Sep 2025 22:30:32 +0200 Subject: [PATCH 02/24] wip --- Evolution/NNNN-retry-backoff.md | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 Evolution/NNNN-retry-backoff.md diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md new file mode 100644 index 00000000..307c47c5 --- /dev/null +++ b/Evolution/NNNN-retry-backoff.md @@ -0,0 +1,129 @@ +# 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 to Swift Async Algorithms, enabling robust retry of failed asynchronous operations with customizable delays and error-driven retry 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 async operation up to a specified number of attempts, with customizable delays and error-based retry decisions between attempts. + +```swift +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 + +public enum RetryAction { + case backoff(Duration) + case stop +} +``` + +Additionally, this proposal includes a family 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 +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 + 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 +extension BackoffStrategy { + public func minimum(_ minimum: Duration) -> some BackoffStrategy + public func maximum(_ maximum: Duration) -> some BackoffStrategy + public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy + public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy +} +``` + +## Detailed design + +### Retry + +The retry algorithm follows this sequence: +1. Execute the operation +2. If successful, return the result +3. If failed and this was not the final attempt: +- Call the `strategy` closure with the error + - If strategy returns `.stop`, rethrow the error immediately + - If 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 is a total of four termination conditions: +1. **Success**: The operation completes without throwing an error +2. **Maximum attempts exhausted**: The operation has been attempted `maxAttempts` times +3. **Strategy decision to stop**: The strategy closure returns `.stop` +3. **Clock throws**: The given clock throws, which will be rethrown + +#### Cancellation + +`retry` itself does not introduce any specific cancellation handling. If asynchronous code opts into cooperative cancellation by throwing an error, it has to make sure it handles this case in the retry strategy, by returning `.stop`, as this is a non-retryable error, usually. +If you forget to do this, retrying will not be stopped, except when the given clock does cancel cooperatively by throwing (which at the time of writing both `ContinuousClock` and `SuspendingClock` do). + +### Backoff + +All strategies conform to: +```swift +public protocol BackoffStrategy { + associatedtype Duration: DurationProtocol + mutating func nextDuration() -> Duration +} +``` +Each call to nextDuration() returns the delay for the next retry attempt. Strategies are stateful - they may track the number of invocations or the previously returned duration to calculate the next delay. + +#### Constant +Formula: $`f(n) = constant`$ +#### Linear +Formula: $`f(n) = initial + increment * n`$ +#### Exponential +Formula: $`f(n) = initial * factor ^ n`$ +#### Decorrelated Jitter +Formula: $`f(n) = random(base, f(n - 1) * factor)`$, $`f(0) = base`$ +#### Minimum +Formula: $`f(n) = max(minimum, g(n))`$, `g(n)` is the base strategy. +#### Maximum +Formula: $`f(n) = min(maximum, g(n))`$, `g(n)` is the base strategy. +#### Full Jitter +Formula: $`f(n) = random(0, g(n))`$, `g(n)` is the base strategy. +#### Equal Jitter +Formula: $`f(n) = random(g(n) / 2, g(n))`$, `g(n)` is the base strategy. + +## Effect on API resilience + +This proposal introduces purely additive API with no impact on existing functionality or API resilience. + +## Alternatives considered + +Describe alternative approaches to addressing the same problem, and +why you chose this approach instead. + +## 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. From 39df4718f69a8c5136fc84d578bd10fa5de71fd6 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 06:29:39 +0200 Subject: [PATCH 03/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 307c47c5..d98d0a16 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -30,7 +30,9 @@ public func retry( operation: () async throws(ErrorType) -> Result, strategy: (ErrorType) -> RetryAction = { _ in .backoff(.zero) } ) async throws -> Result where ClockType: Clock, ErrorType: Error +``` +```swift public enum RetryAction { case backoff(Duration) case stop From a1e0c9358b4f29fc64108983a0ba972fad837325 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 06:32:47 +0200 Subject: [PATCH 04/24] NNNN-retry-backoff.md aktualisieren --- 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 d98d0a16..b57e8fd6 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -87,7 +87,7 @@ Given this sequence, there is a total of four termination conditions: #### Cancellation `retry` itself does not introduce any specific cancellation handling. If asynchronous code opts into cooperative cancellation by throwing an error, it has to make sure it handles this case in the retry strategy, by returning `.stop`, as this is a non-retryable error, usually. -If you forget to do this, retrying will not be stopped, except when the given clock does cancel cooperatively by throwing (which at the time of writing both `ContinuousClock` and `SuspendingClock` do). +If you forget to do this, retrying will not be stopped, unless the given clock does cancel cooperatively by throwing (which at the time of writing both `ContinuousClock` and `SuspendingClock` do). ### Backoff From beab4d3427af40352ca2557fd0c47b0234988d13 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 07:13:24 +0200 Subject: [PATCH 05/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 67 +++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index b57e8fd6..c65f5b68 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -19,7 +19,7 @@ Providing a standard `retry` function and reusable backoff strategies in Swift A ## Proposed solution -This proposal introduces a retry function that executes an async operation up to a specified number of attempts, with customizable delays and error-based retry decisions between attempts. +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 public func retry( @@ -64,6 +64,15 @@ extension BackoffStrategy { } ``` +Each of those strategies conforms to the `BackoffStrategy` protocol: + +```swift +public protocol BackoffStrategy { + associatedtype Duration: DurationProtocol + mutating func nextDuration() -> Duration +} +``` + ## Detailed design ### Retry @@ -78,11 +87,11 @@ The retry algorithm follows this sequence: - Return to step 1 4. If failed on the final attempt, rethrow the error without consulting the strategy -Given this sequence, there is a total of four termination conditions: -1. **Success**: The operation completes without throwing an error -2. **Maximum attempts exhausted**: The operation has been attempted `maxAttempts` times -3. **Strategy decision to stop**: The strategy closure returns `.stop` -3. **Clock throws**: The given clock throws, which will be rethrown +Given this sequence, there is a total of 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 given clock throws #### Cancellation @@ -91,31 +100,31 @@ If you forget to do this, retrying will not be stopped, unless the given clock d ### Backoff -All strategies conform to: +All proposed strategies conform to `BackoffStrategy` which allows for builder-like patterns like these: ```swift -public protocol BackoffStrategy { - associatedtype Duration: DurationProtocol - mutating func nextDuration() -> Duration -} +var backoff = Backoff + .exponential(factor: 2, initial: .milliseconds(100)) + .maximum(.seconds(5)) + .fullJitter() ``` -Each call to nextDuration() returns the delay for the next retry attempt. Strategies are stateful - they may track the number of invocations or the previously returned duration to calculate the next delay. - -#### Constant -Formula: $`f(n) = constant`$ -#### Linear -Formula: $`f(n) = initial + increment * n`$ -#### Exponential -Formula: $`f(n) = initial * factor ^ n`$ -#### Decorrelated Jitter -Formula: $`f(n) = random(base, f(n - 1) * factor)`$, $`f(0) = base`$ -#### Minimum -Formula: $`f(n) = max(minimum, g(n))`$, `g(n)` is the base strategy. -#### Maximum -Formula: $`f(n) = min(maximum, g(n))`$, `g(n)` is the base strategy. -#### Full Jitter -Formula: $`f(n) = random(0, g(n))`$, `g(n)` is the base strategy. -#### Equal Jitter -Formula: $`f(n) = random(g(n) / 2, g(n))`$, `g(n)` is the base strategy. + +#### Custom backoff + +You can create custom strategies that conform to `BackoffStrategy` if you want to opt into the "backoff modifiers" like `minimum`, `maximum` and jitter variants. +Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are stateful, they may track eg. 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 ## Effect on API resilience From b3beb592159d435be1c599fe63627e5b4ec23191 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 07:18:00 +0200 Subject: [PATCH 06/24] NNNN-retry-backoff.md aktualisieren --- 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 c65f5b68..03f127e2 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -100,7 +100,7 @@ If you forget to do this, retrying will not be stopped, unless the given clock d ### Backoff -All proposed strategies conform to `BackoffStrategy` which allows for builder-like patterns like these: +All proposed strategies conform to `BackoffStrategy` which allows for builder-like syntax like this: ```swift var backoff = Backoff .exponential(factor: 2, initial: .milliseconds(100)) From 84e4d831479a05db550377e278463ce4345852f5 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 07:24:01 +0200 Subject: [PATCH 07/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 03f127e2..2ad1b436 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -110,21 +110,21 @@ var backoff = Backoff #### Custom backoff -You can create custom strategies that conform to `BackoffStrategy` if you want to opt into the "backoff modifiers" like `minimum`, `maximum` and jitter variants. -Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are stateful, they may track eg. the number of invocations or the previously returned duration to calculate the next delay. +Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are loosely coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. +Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are naturally stateful, they may track eg. 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 +- **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 ## Effect on API resilience From ca161e05f00388b6e564e32eff8fbfd881712f4f Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 07:35:32 +0200 Subject: [PATCH 08/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 2ad1b436..4cf36a77 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -73,6 +73,9 @@ public protocol BackoffStrategy { } ``` +Jitter variants are not able to utilize the generic form of `Duration`, `DurationProtocol` due to the lack of randomizing capabilities. +`Duration` recently gained this capability by exposing its underlying numerical representation via [SE-0457](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0457-duration-attosecond-represenation.md) + ## Detailed design ### Retry @@ -96,6 +99,7 @@ Given this sequence, there is a total of four termination conditions (when retry #### Cancellation `retry` itself does not introduce any specific cancellation handling. If asynchronous code opts into cooperative cancellation by throwing an error, it has to make sure it handles this case in the retry strategy, by returning `.stop`, as this is a non-retryable error, usually. + If you forget to do this, retrying will not be stopped, unless the given clock does cancel cooperatively by throwing (which at the time of writing both `ContinuousClock` and `SuspendingClock` do). ### Backoff @@ -111,6 +115,7 @@ var backoff = Backoff #### Custom backoff Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are loosely coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. + Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are naturally stateful, they may track eg. the number of invocations or the previously returned duration to calculate the next delay. #### Standard backoff From 4b20aa0a6f19c3291024bd4c755f39bd1130acab Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 07:58:21 +0200 Subject: [PATCH 09/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 4cf36a77..befb6d5f 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -64,6 +64,11 @@ extension BackoffStrategy { } ``` +Constant, linear and exponential backoff provide an overload for `Duration` **and** `DurationProtocol`. +This is convenient and matches the overloads of `retry`'s where the default clock is `ContinuousClock` which `DurationProtocol` is `Duration`. +Jitter variants are not able to utilize `DurationProtocol` due to the lack of randomizing capabilities. +`Duration` recently gained this capability by exposing its underlying numerical representation via [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 @@ -73,9 +78,6 @@ public protocol BackoffStrategy { } ``` -Jitter variants are not able to utilize the generic form of `Duration`, `DurationProtocol` due to the lack of randomizing capabilities. -`Duration` recently gained this capability by exposing its underlying numerical representation via [SE-0457](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0457-duration-attosecond-represenation.md) - ## Detailed design ### Retry @@ -131,6 +133,15 @@ As previously mentioned this proposal introduces several common backoff strategi - **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 +### 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: + + ## Effect on API resilience This proposal introduces purely additive API with no impact on existing functionality or API resilience. From 469418bd71c555ad7003b280cb597b7b0d3cd125 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:03:30 +0200 Subject: [PATCH 10/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index befb6d5f..52bcd337 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -141,6 +141,40 @@ The most common use cases encountered for recovering from transient failures are 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") + { + throw TooManyRequestsError(retryAfter: Double(retryAfter)!) + } + 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 was chosen as remote system) ## Effect on API resilience From 9077e169d417c84e8d87122f035e2ca637ffefd1 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:04:00 +0200 Subject: [PATCH 11/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 52bcd337..cd263400 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -66,6 +66,7 @@ extension BackoffStrategy { Constant, linear and exponential backoff provide an overload for `Duration` **and** `DurationProtocol`. This is convenient and matches the overloads of `retry`'s where the default clock is `ContinuousClock` which `DurationProtocol` is `Duration`. + Jitter variants are not able to utilize `DurationProtocol` due to the lack of randomizing capabilities. `Duration` recently gained this capability by exposing its underlying numerical representation via [SE-0457](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0457-duration-attosecond-represenation.md). From 55715d2f3ecf05f7589b61d3143c4c5238cdeef3 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:05:28 +0200 Subject: [PATCH 12/24] NNNN-retry-backoff.md aktualisieren --- 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 cd263400..6f58249d 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -117,7 +117,7 @@ var backoff = Backoff #### Custom backoff -Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are loosely coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. +Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are not coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are naturally stateful, they may track eg. the number of invocations or the previously returned duration to calculate the next delay. From 8f1c9f34fd8794e2bcc77f516113f5f8d24b4ed6 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:06:51 +0200 Subject: [PATCH 13/24] NNNN-retry-backoff.md aktualisieren --- 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 6f58249d..75cc9087 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -119,7 +119,7 @@ var backoff = Backoff Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are not coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. -Each call to `nextDuration()` returns the delay for the next retry attempt. Strategies are naturally stateful, they may track eg. the number of invocations or the previously returned duration to calculate the next delay. +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 From 0f664d29291c04383438eef7afc64aadec6cbe65 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:21:18 +0200 Subject: [PATCH 14/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 75cc9087..3aa30341 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -181,6 +181,13 @@ let response = try await retry(maxAttempts: 5) { This proposal introduces 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 libraries signatures of eg. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. +Due to the composability of backoff algorithms proposed, this is not possible to adopt in current Swift. +If the compiler at one point gains the capability to "store" `inout` variables the jitter variants can be adopted to support this in a non-breaking manner by introducing new overloads and deprecating the copying overloads. + ## Alternatives considered Describe alternative approaches to addressing the same problem, and From c7d74c1a98ce461163b503892cc5ec8a0427fce0 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 08:33:34 +0200 Subject: [PATCH 15/24] NNNN-retry-backoff.md aktualisieren --- 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 3aa30341..40ed5d97 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -186,7 +186,7 @@ This proposal introduces purely additive API with no impact on existing function 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 libraries signatures of eg. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. Due to the composability of backoff algorithms proposed, this is not possible to adopt in current Swift. -If the compiler at one point gains the capability to "store" `inout` variables the jitter variants can be adopted to support this in a non-breaking manner by introducing new overloads and deprecating the copying overloads. +If Swift at one point gains the capability to "store" `inout` variables the jitter variants should try to adopt this by introducing new `inout` overloads and deprecating the copying overloads. ## Alternatives considered From 93d555b687202b5de7ecfc999d52ca8ba32e3fb7 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 14:32:38 +0200 Subject: [PATCH 16/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 40ed5d97..19ea1ff8 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -7,7 +7,7 @@ ## Introduction -This proposal introduces a `retry` function and a suite of backoff strategies to Swift Async Algorithms, enabling robust retry of failed asynchronous operations with customizable delays and error-driven retry decisions. +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/) @@ -15,7 +15,7 @@ Swift forums thread: [Discussion thread topic for that proposal](https://forums. 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. +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 @@ -64,11 +64,9 @@ extension BackoffStrategy { } ``` -Constant, linear and exponential backoff provide an overload for `Duration` **and** `DurationProtocol`. -This is convenient and matches the overloads of `retry`'s where the default clock is `ContinuousClock` which `DurationProtocol` is `Duration`. +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 are not able to utilize `DurationProtocol` due to the lack of randomizing capabilities. -`Duration` recently gained this capability by exposing its underlying numerical representation via [SE-0457](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0457-duration-attosecond-represenation.md). +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: @@ -101,9 +99,7 @@ Given this sequence, there is a total of four termination conditions (when retry #### Cancellation -`retry` itself does not introduce any specific cancellation handling. If asynchronous code opts into cooperative cancellation by throwing an error, it has to make sure it handles this case in the retry strategy, by returning `.stop`, as this is a non-retryable error, usually. - -If you forget to do this, retrying will not be stopped, unless the given clock does cancel cooperatively by throwing (which at the time of writing both `ContinuousClock` and `SuspendingClock` do). +`retry` does not introduce special cancellation handling. If your code cooperatively cancels by throwing, ensure your strategy returns `.stop` for that error. Otherwise, retries will continue unless the given clock throws on cancellation (which, at the time of writing, both `ContinuousClock` and `SuspendingClock` do). ### Backoff @@ -117,7 +113,7 @@ var backoff = Backoff #### Custom backoff -Adopters may choose to create own strategies. There is no requirement to conform to `BackoffStrategy` since retry and backoff are not coupled. However if they want to allow for "backoff modifiers" like `minimum`, `maximum` and jitter variants, they are required to do so. +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. @@ -175,7 +171,7 @@ let response = try await retry(maxAttempts: 5) { } } ``` -(For demonstration purposes only, a network server was chosen as remote system) +(For demonstration purposes only, a network server is used as remote system) ## Effect on API resilience @@ -184,7 +180,7 @@ This proposal introduces purely additive API with no impact on existing function ## 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 libraries signatures of eg. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. +This is not optimal and does not match the standard libraries signatures of e.g. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. Due to the composability of backoff algorithms proposed, this is not possible to adopt in current Swift. If Swift at one point gains the capability to "store" `inout` variables the jitter variants should try to adopt this by introducing new `inout` overloads and deprecating the copying overloads. From 23a56b9ebfcfcf802f0e2c3024044f84173f1755 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 14:49:46 +0200 Subject: [PATCH 17/24] NNNN-retry-backoff.md aktualisieren --- 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 19ea1ff8..b2cf3571 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -39,7 +39,7 @@ public enum RetryAction { } ``` -Additionally, this proposal includes a family 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. +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 public enum Backoff { From 84fe85a4f78f1c63a53d40eb1216393c3c8370c6 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 14:55:17 +0200 Subject: [PATCH 18/24] NNNN-retry-backoff.md aktualisieren --- 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 b2cf3571..d8ac5ae3 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -66,7 +66,7 @@ extension 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). +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: From 9d1d5bc195c32d0fbf29fa4831a76731aacec43d Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 14:59:03 +0200 Subject: [PATCH 19/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index d8ac5ae3..72d08b81 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -22,6 +22,7 @@ Providing a standard `retry` function and reusable backoff strategies in Swift A 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, @@ -33,6 +34,7 @@ public func retry( ``` ```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 @@ -42,6 +44,7 @@ public enum RetryAction { 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 @@ -49,6 +52,8 @@ public enum Backoff { 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 +} +extension Backoff { public static func decorrelatedJitter(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy } ``` @@ -56,9 +61,12 @@ public enum Backoff { 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 +} +extension BackoffStrategy { public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy } @@ -66,11 +74,12 @@ extension 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). +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 From 18331e29701f98e4240ed20800855bd4e084d0ab Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Wed, 24 Sep 2025 15:00:22 +0200 Subject: [PATCH 20/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 72d08b81..6ce6cc40 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -53,6 +53,7 @@ public enum Backoff { 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 } @@ -66,6 +67,7 @@ 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 { public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy From b985ed001bda01bc2d968639700ce75cee9af752 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 07:09:17 +0200 Subject: [PATCH 21/24] NNNN-retry-backoff.md aktualisieren (#6) --- Evolution/NNNN-retry-backoff.md | 53 +++++++++++++++++---------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 6ce6cc40..9e153a00 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -68,7 +68,7 @@ extension 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 { +extension BackoffStrategy where Duration == Swift.Duration { public func fullJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy public func equalJitter(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy } @@ -96,21 +96,21 @@ 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 strategy returns `.stop`, rethrow the error immediately - - If strategy returns `.backoff`, suspend for the given duration - - Return to step 1 + - 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 is a total of four termination conditions (when retrying will be stopped): +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 given clock throws +- 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 will continue unless the given clock throws on cancellation (which, at the time of writing, both `ContinuousClock` and `SuspendingClock` do). +`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 @@ -126,20 +126,20 @@ var backoff = 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. +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 +- **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 ### Case studies @@ -150,7 +150,7 @@ The most common use cases encountered for recovering from transient failures are Both of these use cases can be implemented using the proposed algorithm, respectively: ```swift -let rng = SystemRandomNumberGenerator() // or a seeded rng for unit tests +let rng = SystemRandomNumberGenerator() // or a seeded RNG for unit tests var backoff = Backoff .exponential(factor: 2, initial: .milliseconds(100)) .maximum(.seconds(10)) @@ -169,9 +169,10 @@ let response = try await retry(maxAttempts: 5) { if let response = response as? HTTPURLResponse, response.statusCode == 429, - let retryAfter = response.value(forHTTPHeaderField: "Retry-After") + let retryAfter = response.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(retryAfter) { - throw TooManyRequestsError(retryAfter: Double(retryAfter)!) + throw TooManyRequestsError(retryAfter: seconds) } return (data, response) } strategy: { error in @@ -182,18 +183,18 @@ let response = try await retry(maxAttempts: 5) { } } ``` -(For demonstration purposes only, a network server is used as remote system) +(For demonstration purposes only, a network server is used as the remote system.) ## Effect on API resilience -This proposal introduces purely additive API with no impact on existing functionality or 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 libraries signatures of e.g. `shuffle()` or `randomElement()` which take an **`inout`** random number generator. -Due to the composability of backoff algorithms proposed, this is not possible to adopt in current Swift. -If Swift at one point gains the capability to "store" `inout` variables the jitter variants should try to adopt this by introducing new `inout` overloads and deprecating the copying overloads. +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 @@ -202,4 +203,4 @@ why you chose this approach instead. ## 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. +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 From 8b410d46d68d49055e957ae448da7e4e2381b1cd Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 07:40:04 +0200 Subject: [PATCH 22/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index 9e153a00..a9c290b1 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -141,6 +141,12 @@ As previously mentioned this proposal introduces several common backoff strategi - **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: From d174d0483050b68e9a71b478ceb04b92c065fa93 Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 07:50:55 +0200 Subject: [PATCH 23/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index a9c290b1..b4d68d4e 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -204,8 +204,13 @@ If Swift gains the capability to "store" `inout` variables, the jitter variants ## Alternatives considered -Describe alternative approaches to addressing the same problem, and -why you chose this approach instead. +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 From 653141dbaba84f673dc22adbfcbaf9e24d00561d Mon Sep 17 00:00:00 2001 From: Philipp Gabriel Date: Thu, 25 Sep 2025 07:52:18 +0200 Subject: [PATCH 24/24] NNNN-retry-backoff.md aktualisieren --- Evolution/NNNN-retry-backoff.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Evolution/NNNN-retry-backoff.md b/Evolution/NNNN-retry-backoff.md index b4d68d4e..1655f2ad 100644 --- a/Evolution/NNNN-retry-backoff.md +++ b/Evolution/NNNN-retry-backoff.md @@ -204,11 +204,9 @@ If Swift gains the capability to "store" `inout` variables, the jitter variants ## 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. +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.