-
Notifications
You must be signed in to change notification settings - Fork 186
Retry & Backoff #364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Retry & Backoff #364
Changes from 8 commits
c75fc64
f32aca6
03b623d
f8cb3cd
09538a5
a5ff83c
b4b66ec
c8e6f99
24475e9
2406360
69fbbdb
93f21b4
0d21f31
d67a1f1
c38018e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| #if compiler(<6.2) | ||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| extension Duration { | ||
| @usableFromInline var attoseconds: Int128 { | ||
| return Int128(_low: _low, _high: _high) | ||
| } | ||
| @usableFromInline init(attoseconds: Int128) { | ||
| self.init(_high: attoseconds._high, low: attoseconds._low) | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| public protocol BackoffStrategy<Duration> { | ||
| associatedtype Duration: DurationProtocol | ||
| mutating func nextDuration() -> Duration | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @usableFromInline struct ConstantBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy { | ||
| @usableFromInline let constant: Duration | ||
| @usableFromInline init(constant: Duration) { | ||
| precondition(constant >= .zero, "Constant must be greater than or equal to 0") | ||
| self.constant = constant | ||
| } | ||
| @inlinable func nextDuration() -> Duration { | ||
| return constant | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @usableFromInline struct LinearBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy { | ||
| @usableFromInline var current: Duration | ||
| @usableFromInline let increment: Duration | ||
| @usableFromInline init(increment: Duration, initial: Duration) { | ||
| precondition(initial >= .zero, "Initial must be greater than or equal to 0") | ||
| precondition(increment >= .zero, "Increment must be greater than or equal to 0") | ||
| self.current = initial | ||
| self.increment = increment | ||
| } | ||
| @inlinable mutating func nextDuration() -> Duration { | ||
| defer { current += increment } | ||
| return current | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @usableFromInline struct ExponentialBackoffStrategy<Duration: DurationProtocol>: BackoffStrategy { | ||
| @usableFromInline var current: Duration | ||
| @usableFromInline let factor: Int | ||
| @usableFromInline init(factor: Int, initial: Duration) { | ||
| precondition(initial >= .zero, "Initial must be greater than or equal to 0") | ||
| precondition(factor >= .zero, "Factor must be greater than or equal to 0") | ||
| self.current = initial | ||
| self.factor = factor | ||
| } | ||
| @inlinable mutating func nextDuration() -> Duration { | ||
| defer { current *= factor } | ||
| return current | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @usableFromInline struct MinimumBackoffStrategy<Base: BackoffStrategy>: BackoffStrategy { | ||
| @usableFromInline var base: Base | ||
| @usableFromInline let minimum: Base.Duration | ||
| @usableFromInline init(base: Base, minimum: Base.Duration) { | ||
| self.base = base | ||
| self.minimum = minimum | ||
| } | ||
| @inlinable mutating func nextDuration() -> Base.Duration { | ||
| return max(minimum, base.nextDuration()) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @usableFromInline struct MaximumBackoffStrategy<Base: BackoffStrategy>: BackoffStrategy { | ||
| @usableFromInline var base: Base | ||
| @usableFromInline let maximum: Base.Duration | ||
| @usableFromInline init(base: Base, maximum: Base.Duration) { | ||
| self.base = base | ||
| self.maximum = maximum | ||
| } | ||
| @inlinable mutating func nextDuration() -> Base.Duration { | ||
| return min(maximum, base.nextDuration()) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| @usableFromInline struct FullJitterBackoffStrategy<Base: BackoffStrategy, RNG: RandomNumberGenerator>: BackoffStrategy where Base.Duration == Swift.Duration { | ||
| @usableFromInline var base: Base | ||
| @usableFromInline var generator: RNG | ||
| @usableFromInline init(base: Base, generator: RNG) { | ||
| self.base = base | ||
| self.generator = generator | ||
| } | ||
| @inlinable mutating func nextDuration() -> Base.Duration { | ||
| return .init(attoseconds: Int128.random(in: 0...base.nextDuration().attoseconds, using: &generator)) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| @usableFromInline struct EqualJitterBackoffStrategy<Base: BackoffStrategy, RNG: RandomNumberGenerator>: BackoffStrategy where Base.Duration == Swift.Duration { | ||
| @usableFromInline var base: Base | ||
| @usableFromInline var generator: RNG | ||
| @usableFromInline init(base: Base, generator: RNG) { | ||
| self.base = base | ||
| self.generator = generator | ||
| } | ||
| @inlinable mutating func nextDuration() -> Base.Duration { | ||
| let base = base.nextDuration() | ||
| return .init(attoseconds: Int128.random(in: (base / 2).attoseconds...base.attoseconds, using: &generator)) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| @usableFromInline struct DecorrelatedJitterBackoffStrategy<RNG: RandomNumberGenerator>: BackoffStrategy { | ||
| @usableFromInline let base: Duration | ||
| @usableFromInline let factor: Int | ||
| @usableFromInline var generator: RNG | ||
| @usableFromInline var current: Duration? | ||
| @usableFromInline init(base: Duration, factor: Int, generator: RNG) { | ||
| precondition(factor >= 1, "Factor must be greater than or equal to 1") | ||
| precondition(base >= .zero, "Base must be greater than or equal to 0") | ||
| self.base = base | ||
| self.generator = generator | ||
| self.factor = factor | ||
| } | ||
| @inlinable mutating func nextDuration() -> Duration { | ||
| let previous = current ?? base | ||
| let next = Duration(attoseconds: Int128.random(in: base.attoseconds...(previous * factor).attoseconds, using: &generator)) | ||
| current = next | ||
| return next | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| public enum Backoff { | ||
| @inlinable public static func constant<Duration: DurationProtocol>(_ constant: Duration) -> some BackoffStrategy<Duration> { | ||
| return ConstantBackoffStrategy(constant: constant) | ||
| } | ||
| @inlinable public static func constant(_ constant: Duration) -> some BackoffStrategy<Duration> { | ||
ph1ps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return ConstantBackoffStrategy(constant: constant) | ||
| } | ||
| @inlinable public static func linear<Duration: DurationProtocol>(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration> { | ||
| return LinearBackoffStrategy(increment: increment, initial: initial) | ||
| } | ||
| @inlinable public static func linear(increment: Duration, initial: Duration) -> some BackoffStrategy<Duration> { | ||
| return LinearBackoffStrategy(increment: increment, initial: initial) | ||
| } | ||
| @inlinable public static func exponential<Duration: DurationProtocol>(factor: Int, initial: Duration) -> some BackoffStrategy<Duration> { | ||
| return ExponentialBackoffStrategy(factor: factor, initial: initial) | ||
| } | ||
| @inlinable public static func exponential(factor: Int, initial: Duration) -> some BackoffStrategy<Duration> { | ||
| return ExponentialBackoffStrategy(factor: factor, initial: initial) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| extension Backoff { | ||
| @inlinable public static func decorrelatedJitter<RNG: RandomNumberGenerator>(factor: Int, base: Duration, using generator: RNG) -> some BackoffStrategy<Duration> { | ||
| return DecorrelatedJitterBackoffStrategy(base: base, factor: factor, generator: generator) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| extension BackoffStrategy { | ||
| @inlinable public func minimum(_ minimum: Duration) -> some BackoffStrategy<Duration> { | ||
| return MinimumBackoffStrategy(base: self, minimum: minimum) | ||
| } | ||
| @inlinable public func maximum(_ maximum: Duration) -> some BackoffStrategy<Duration> { | ||
| return MaximumBackoffStrategy(base: self, maximum: maximum) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 18.0, macCatalyst 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *) | ||
| extension BackoffStrategy where Duration == Swift.Duration { | ||
| @inlinable public func fullJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> { | ||
| return FullJitterBackoffStrategy(base: self, generator: generator) | ||
| } | ||
| @inlinable public func equalJitter<RNG: RandomNumberGenerator>(using generator: RNG = SystemRandomNumberGenerator()) -> some BackoffStrategy<Duration> { | ||
| return EqualJitterBackoffStrategy(base: self, generator: generator) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| public struct RetryStrategy<Duration: DurationProtocol> { | ||
| @usableFromInline enum Strategy { | ||
| case backoff(Duration) | ||
| case stop | ||
| } | ||
| @usableFromInline let strategy: Strategy | ||
| @usableFromInline init(strategy: Strategy) { | ||
| self.strategy = strategy | ||
| } | ||
| @inlinable public static var stop: Self { | ||
ph1ps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return .init(strategy: .stop) | ||
| } | ||
| @inlinable public static func backoff(_ duration: Duration) -> Self { | ||
| return .init(strategy: .backoff(duration)) | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @inlinable public func retry<Result, ErrorType, ClockType>( | ||
| maxAttempts: Int = 3, | ||
ph1ps marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| tolerance: ClockType.Instant.Duration? = nil, | ||
| clock: ClockType, | ||
| isolation: isolated (any Actor)? = #isolation, | ||
| operation: () async throws(ErrorType) -> sending Result, | ||
ph1ps marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| strategy: (ErrorType) -> RetryStrategy<ClockType.Instant.Duration> = { _ in .backoff(.zero) } | ||
|
||
| ) async throws -> Result where ClockType: Clock, ErrorType: Error { | ||
| precondition(maxAttempts > 0, "Must have at least one attempt") | ||
| for _ in 0..<maxAttempts - 1 { | ||
| do { | ||
| return try await operation() | ||
| } catch where Task.isCancelled { | ||
ph1ps marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| throw error | ||
| } catch { | ||
| switch strategy(error).strategy { | ||
| case .backoff(let duration): | ||
| try await Task.sleep(for: duration, tolerance: tolerance, clock: clock) | ||
| case .stop: | ||
| throw error | ||
| } | ||
| } | ||
| } | ||
| return try await operation() | ||
| } | ||
|
|
||
| @available(iOS 16.0, macCatalyst 16.0, macOS 13.0, tvOS 16.0, visionOS 1.0, watchOS 9.0, *) | ||
| @inlinable public func retry<Result, ErrorType>( | ||
| maxAttempts: Int = 3, | ||
| tolerance: ContinuousClock.Instant.Duration? = nil, | ||
| isolation: isolated (any Actor)? = #isolation, | ||
| operation: () async throws(ErrorType) -> sending Result, | ||
| strategy: (ErrorType) -> RetryStrategy<ContinuousClock.Instant.Duration> = { _ in .backoff(.zero) } | ||
| ) async throws -> Result where ErrorType: Error { | ||
| return try await retry( | ||
| maxAttempts: maxAttempts, | ||
| tolerance: tolerance, | ||
| clock: ContinuousClock(), | ||
| operation: operation, | ||
| strategy: strategy | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this multiplication can overflow when retrying many times. I just saw it happen with
.exponential(factor: 2, initial: .seconds(5)). This should check for overflows and cap the value to the max of the duration type.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bobergj Although this can overflow at some point it is not realistic, in my opinion.
The last duration before the multiplication (with factor 2 and initial 5 seconds) overflows is 1.46 trillion years, if I did the math correct.
It is generally a good idea to cap exponential backoff at a maximum which makes sense for your use case.
Would you mind sharing what you were trying to do? Maybe I should add a recommendation to the docs of exponential backoff that it should be combined with a .max modifier...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the incomplete report. On iOS < 18 I am doing this:
It turns out the
.maximumis part of the issue. Yes, with a pure exponential backoff, you may have to wait a trillion years. But with this setup the exponential keeps growing, while the waiting time is much shorter due to the .maximum. This overflows after about 65 calls tonextDuration, which is after a total retry time of just over an hour.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bobergj Yikes, now I get it. Thanks for the report.
I'll either implement what you suggested or the max-modifier should stop calling its base backoff strategy once maximum was reached.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bobergj I've updated the implementation and proposal: https://forums.swift.org/t/pitch-retry-backoff/82483/2. Thanks again.