Skip to content

Commit c38018e

Browse files
committed
add overflow protection, remove decorrelated jitter
1 parent d67a1f1 commit c38018e

File tree

5 files changed

+93
-173
lines changed

5 files changed

+93
-173
lines changed

Evolution/NNNN-retry-backoff.md

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Providing a standard `retry` function and reusable backoff strategies in Swift A
2222
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.
2323

2424
```swift
25-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
25+
@available(AsyncAlgorithms 1.1, *)
2626
nonisolated(nonsending) public func retry<Result, ErrorType, ClockType>(
2727
maxAttempts: Int,
2828
tolerance: ClockType.Instant.Duration? = nil,
@@ -33,60 +33,52 @@ nonisolated(nonsending) public func retry<Result, ErrorType, ClockType>(
3333
```
3434

3535
```swift
36-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
37-
public enum RetryAction<Duration: DurationProtocol> {
38-
case backoff(Duration)
39-
case stop
36+
@available(AsyncAlgorithms 1.1, *)
37+
public struct RetryAction<Duration: DurationProtocol> {
38+
public static var stop: Self
39+
public static func backoff(_ duration: Duration) -> Self
4040
}
4141
```
4242

43-
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.
43+
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, and exponential growth.
4444

4545
```swift
46-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
46+
@available(AsyncAlgorithms 1.1, *)
4747
public enum Backoff {
4848
public static func constant<Duration: DurationProtocol>(_ constant: Duration) -> some BackoffStrategy<Duration>
4949
public static func constant(_ constant: Duration) -> some BackoffStrategy<Duration>
50-
public static func linear<Duration: DurationProtocol>(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration>
5150
public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration>
52-
public static func exponential<Duration: DurationProtocol>(factor: Int, initial: Duration) -> some BackoffStrategy<Duration>
53-
public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy<Duration>
54-
}
55-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
56-
extension Backoff {
57-
public static func decorrelatedJitter<RNG: RandomNumberGenerator>(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration>
51+
public static func exponential(factor: Int128, initial: Duration) -> some BackoffStrategy<Duration>
5852
}
5953
```
6054

6155
These strategies can be modified to enforce minimum or maximum delays, or to add jitter for preventing the thundering herd problem.
6256

6357
```swift
64-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
58+
@available(AsyncAlgorithms 1.1, *)
6559
extension BackoffStrategy {
6660
public func minimum(_ minimum: Duration) -> some BackoffStrategy<Duration>
6761
public func maximum(_ maximum: Duration) -> some BackoffStrategy<Duration>
6862
}
69-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
63+
@available(AsyncAlgorithms 1.1, *)
7064
extension BackoffStrategy where Duration == Swift.Duration {
7165
public func fullJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration>
7266
public func equalJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration>
7367
}
7468
```
7569

76-
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`.
77-
78-
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)).
79-
80-
Each of those strategies conforms to the `BackoffStrategy` protocol:
70+
`BackoffStrategy` is a protocol with an associated type `Duration` which is required to conform to `DurationProtocol`:
8171

8272
```swift
83-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
73+
@available(AsyncAlgorithms 1.1, *)
8474
public protocol BackoffStrategy<Duration> {
8575
associatedtype Duration: DurationProtocol
8676
mutating func nextDuration() -> Duration
8777
}
8878
```
8979

80+
Linear, exponential, and jitter backoff require the use of `Swift.Duration` rather than any type conforming to `DurationProtocol` due to limitations of `DurationProtocol` to do more complex mathematical operations, such as adding or multiplying with reporting overflows or generating random values. Constant, minimum and maximum are able to use `DurationProtocol`.
81+
9082
## Detailed design
9183

9284
### Retry
@@ -123,7 +115,7 @@ var backoff = Backoff
123115

124116
#### Custom backoff
125117

126-
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.
118+
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`, `fullJitter`, `equalJitter`), a strategy must conform.
127119

128120
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.
129121

@@ -134,7 +126,6 @@ As previously mentioned this proposal introduces several common backoff strategi
134126
- **Constant**: $f(n) = constant$
135127
- **Linear**: $f(n) = initial + increment * n$
136128
- **Exponential**: $f(n) = initial * factor ^ n$
137-
- **Decorrelated Jitter**: $f(n) = random(base, f(n - 1) * factor)$ where $f(0) = base$
138129
- **Minimum**: $f(n) = max(minimum, g(n))$ where $g(n)$ is the base strategy
139130
- **Maximum**: $f(n) = min(maximum, g(n))$ where $g(n)$ is the base strategy
140131
- **Full Jitter**: $f(n) = random(0, g(n))$ where $g(n)$ is the base strategy

Sources/AsyncAlgorithms/Retry/Backoff.swift

Lines changed: 47 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
/// naturally stateful. For instance, they may track the number of invocations or the previously
66
/// returned duration to calculate the next delay.
77
///
8-
/// - Precondition: Strategies should only increase or stay the same over time, never decrease.
9-
/// Decreasing delays may cause issues with modifiers like jitter which expect non-decreasing values.
10-
///
118
/// ## Example
129
///
1310
/// ```swift
@@ -16,13 +13,13 @@
1613
/// strategy.nextDuration() // 200ms
1714
/// strategy.nextDuration() // 400ms
1815
/// ```
19-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
16+
@available(AsyncAlgorithms 1.1, *)
2017
public protocol BackoffStrategy<Duration> {
2118
associatedtype Duration: DurationProtocol
2219
mutating func nextDuration() -> Duration
2320
}
2421

25-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
22+
@available(AsyncAlgorithms 1.1, *)
2623
@usableFromInline struct ConstantBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy {
2724
@usableFromInline let constant: Duration
2825
@usableFromInline init(constant: Duration) {
@@ -34,38 +31,60 @@ public protocol BackoffStrategy<Duration> {
3431
}
3532
}
3633

37-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
38-
@usableFromInline struct LinearBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy {
34+
@available(AsyncAlgorithms 1.1, *)
35+
@usableFromInline struct LinearBackoffStrategy: BackoffStrategy {
3936
@usableFromInline var current: Duration
4037
@usableFromInline let increment: Duration
38+
@usableFromInline var hasOverflown = false
4139
@usableFromInline init(increment: Duration, initial: Duration) {
4240
precondition(initial >= .zero, "Initial must be greater than or equal to 0")
4341
precondition(increment >= .zero, "Increment must be greater than or equal to 0")
4442
self.current = initial
4543
self.increment = increment
4644
}
4745
@inlinable mutating func nextDuration() -> Duration {
48-
defer { current += increment }
49-
return current
46+
if hasOverflown {
47+
return Duration(attoseconds: .max)
48+
} else {
49+
let (next, hasOverflown) = current.attoseconds.addingReportingOverflow(increment.attoseconds)
50+
if hasOverflown {
51+
self.hasOverflown = true
52+
return nextDuration()
53+
} else {
54+
defer { current = Duration(attoseconds: next) }
55+
return current
56+
}
57+
}
5058
}
5159
}
5260

53-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
54-
@usableFromInline struct ExponentialBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy {
61+
@available(AsyncAlgorithms 1.1, *)
62+
@usableFromInline struct ExponentialBackoffStrategy: BackoffStrategy {
5563
@usableFromInline var current: Duration
56-
@usableFromInline let factor: Int
57-
@usableFromInline init(factor: Int, initial: Duration) {
64+
@usableFromInline let factor: Int128
65+
@usableFromInline var hasOverflown = false
66+
@usableFromInline init(factor: Int128, initial: Duration) {
5867
precondition(initial >= .zero, "Initial must be greater than or equal to 0")
5968
self.current = initial
6069
self.factor = factor
6170
}
6271
@inlinable mutating func nextDuration() -> Duration {
63-
defer { current *= factor }
64-
return current
72+
if hasOverflown {
73+
return Duration(attoseconds: .max)
74+
} else {
75+
let (next, hasOverflown) = current.attoseconds.multipliedReportingOverflow(by: factor)
76+
if hasOverflown {
77+
self.hasOverflown = true
78+
return nextDuration()
79+
} else {
80+
defer { current = Duration(attoseconds: next) }
81+
return current
82+
}
83+
}
6584
}
6685
}
6786

68-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
87+
@available(AsyncAlgorithms 1.1, *)
6988
@usableFromInline struct MinimumBackoffStrategy<Base: BackoffStrategy>: BackoffStrategy {
7089
@usableFromInline var base: Base
7190
@usableFromInline let minimum: Base.Duration
@@ -78,7 +97,7 @@ public protocol BackoffStrategy<Duration> {
7897
}
7998
}
8099

81-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
100+
@available(AsyncAlgorithms 1.1, *)
82101
@usableFromInline struct MaximumBackoffStrategy<Base: BackoffStrategy>: BackoffStrategy {
83102
@usableFromInline var base: Base
84103
@usableFromInline let maximum: Base.Duration
@@ -91,7 +110,7 @@ public protocol BackoffStrategy<Duration> {
91110
}
92111
}
93112

94-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
113+
@available(AsyncAlgorithms 1.1, *)
95114
@usableFromInline struct FullJitterBackoffStrategy<Base: BackoffStrategy, RNG: RandomNumberGenerator>: BackoffStrategy where Base.Duration == Swift.Duration {
96115
@usableFromInline var base: Base
97116
@usableFromInline var generator: RNG
@@ -104,7 +123,7 @@ public protocol BackoffStrategy<Duration> {
104123
}
105124
}
106125

107-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
126+
@available(AsyncAlgorithms 1.1, *)
108127
@usableFromInline struct EqualJitterBackoffStrategy<Base: BackoffStrategy, RNG: RandomNumberGenerator>: BackoffStrategy where Base.Duration == Swift.Duration {
109128
@usableFromInline var base: Base
110129
@usableFromInline var generator: RNG
@@ -118,28 +137,7 @@ public protocol BackoffStrategy<Duration> {
118137
}
119138
}
120139

121-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
122-
@usableFromInline struct DecorrelatedJitterBackoffStrategy<RNG: RandomNumberGenerator>: BackoffStrategy {
123-
@usableFromInline let base: Duration
124-
@usableFromInline let factor: Int
125-
@usableFromInline var generator: RNG
126-
@usableFromInline var current: Duration?
127-
@usableFromInline init(base: Duration, factor: Int, generator: RNG) {
128-
precondition(factor >= 1, "Factor must be greater than or equal to 1")
129-
precondition(base >= .zero, "Base must be greater than or equal to 0")
130-
self.base = base
131-
self.generator = generator
132-
self.factor = factor
133-
}
134-
@inlinable mutating func nextDuration() -> Duration {
135-
let previous = current ?? base
136-
let next = Duration(attoseconds: Int128.random(in: base.attoseconds...(previous * factor).attoseconds, using: &generator))
137-
current = next
138-
return next
139-
}
140-
}
141-
142-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
140+
@available(AsyncAlgorithms 1.1, *)
143141
public enum Backoff {
144142
/// Creates a constant backoff strategy that always returns the same delay.
145143
///
@@ -152,7 +150,7 @@ public enum Backoff {
152150
@inlinable public static func constant<Duration: DurationProtocol>(_ constant: Duration) -> some BackoffStrategy<Duration> {
153151
return ConstantBackoffStrategy(constant: constant)
154152
}
155-
153+
156154
/// Creates a constant backoff strategy that always returns the same delay.
157155
///
158156
/// Formula: `f(n) = constant`
@@ -172,21 +170,7 @@ public enum Backoff {
172170
@inlinable public static func constant(_ constant: Duration) -> some BackoffStrategy<Duration> {
173171
return ConstantBackoffStrategy(constant: constant)
174172
}
175-
176-
/// Creates a linear backoff strategy where delays increase by a fixed increment.
177-
///
178-
/// Formula: `f(n) = initial + increment * n`
179-
///
180-
/// - Precondition: `initial` and `increment` must be greater than or equal to zero.
181-
///
182-
/// - Parameters:
183-
/// - increment: The amount to increase the delay by on each attempt.
184-
/// - initial: The initial delay for the first retry attempt.
185-
/// - Returns: A backoff strategy with linearly increasing delays.
186-
@inlinable public static func linear<Duration: DurationProtocol>(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration> {
187-
return LinearBackoffStrategy(increment: increment, initial: initial)
188-
}
189-
173+
190174
/// Creates a linear backoff strategy where delays increase by a fixed increment.
191175
///
192176
/// Formula: `f(n) = initial + increment * n`
@@ -209,21 +193,7 @@ public enum Backoff {
209193
@inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration> {
210194
return LinearBackoffStrategy(increment: increment, initial: initial)
211195
}
212-
213-
/// Creates an exponential backoff strategy where delays grow exponentially.
214-
///
215-
/// Formula: `f(n) = initial * factor^n`
216-
///
217-
/// - Precondition: `initial` must be greater than or equal to zero.
218-
///
219-
/// - Parameters:
220-
/// - factor: The multiplication factor for each retry attempt.
221-
/// - initial: The initial delay for the first retry attempt.
222-
/// - Returns: A backoff strategy with exponentially increasing delays.
223-
@inlinable public static func exponential<Duration: DurationProtocol>(factor: Int, initial: Duration) -> some BackoffStrategy<Duration> {
224-
return ExponentialBackoffStrategy(factor: factor, initial: initial)
225-
}
226-
196+
227197
/// Creates an exponential backoff strategy where delays grow exponentially.
228198
///
229199
/// Formula: `f(n) = initial * factor^n`
@@ -243,33 +213,12 @@ public enum Backoff {
243213
/// backoff.nextDuration() // 200ms
244214
/// backoff.nextDuration() // 400ms
245215
/// ```
246-
@inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy<Duration> {
216+
@inlinable public static func exponential(factor: Int128, initial: Duration) -> some BackoffStrategy<Duration> {
247217
return ExponentialBackoffStrategy(factor: factor, initial: initial)
248218
}
249219
}
250220

251-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
252-
extension Backoff {
253-
/// Creates a decorrelated jitter backoff strategy that uses randomized delays.
254-
///
255-
/// Formula: `f(n) = random(base, f(n - 1) * factor)` where `f(0) = base`
256-
///
257-
/// Jitter prevents the thundering herd problem where multiple clients retry
258-
/// simultaneously, reducing server load spikes and improving system stability.
259-
///
260-
/// - Precondition: `factor` must be greater than or equal to 1, and `base` must be greater than or equal to zero.
261-
///
262-
/// - Parameters:
263-
/// - factor: The multiplication factor for calculating the upper bound of randomness.
264-
/// - base: The base duration used as the minimum delay and initial reference.
265-
/// - generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`.
266-
/// - Returns: A backoff strategy with decorrelated jitter.
267-
@inlinable public static func decorrelatedJitter<RNG: RandomNumberGenerator>(factor: Int, base: Duration, using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> {
268-
return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator)
269-
}
270-
}
271-
272-
@available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *)
221+
@available(AsyncAlgorithms 1.1, *)
273222
extension BackoffStrategy {
274223
/// Applies a minimum duration constraint to this backoff strategy.
275224
///
@@ -314,10 +263,7 @@ extension BackoffStrategy {
314263
@inlinable public func maximum(_ maximum: Duration) -> some BackoffStrategy<Duration> {
315264
return MaximumBackoffStrategy(base: self, maximum: maximum)
316265
}
317-
}
318-
319-
@available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
320-
extension BackoffStrategy where Duration == Swift.Duration {
266+
321267
/// Applies full jitter to this backoff strategy.
322268
///
323269
/// Formula: `f(n) = random(0, g(n))` where `g(n)` is the base strategy
@@ -327,7 +273,7 @@ extension BackoffStrategy where Duration == Swift.Duration {
327273
///
328274
/// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`.
329275
/// - Returns: A backoff strategy with full jitter applied.
330-
@inlinable public func fullJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> {
276+
@inlinable public func fullJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> where Duration == Swift.Duration {
331277
return FullJitterBackoffStrategy(base: self, generator: generator)
332278
}
333279

@@ -340,7 +286,7 @@ extension BackoffStrategy where Duration == Swift.Duration {
340286
///
341287
/// - Parameter generator: The random number generator to use. Defaults to `SystemRandomNumberGenerator()`.
342288
/// - Returns: A backoff strategy with equal jitter applied.
343-
@inlinable public func equalJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> {
289+
@inlinable public func equalJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> where Duration == Swift.Duration {
344290
return EqualJitterBackoffStrategy(base: self, generator: generator)
345291
}
346292
}

0 commit comments

Comments
 (0)