diff --git a/.swiftlint.yml b/.swiftlint.yml index 4473404..6df4eeb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -30,7 +30,6 @@ opt_in_rules: # some rules are only opt-in - empty_count - empty_string - empty_xctest_method - - enum_case_associated_values_count - explicit_init - fallthrough - fatal_error_message diff --git a/CHANGELOG.md b/CHANGELOG.md index 266ace6..1f67b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Change Log All notable changes to this project will be documented in this file. +## [Unreleased] + +## Added +- Implement exponential backoff with jitter + - Added in Pull Request [#4](https://github.com/space-code/typhoon/pull/4). + #### 1.x Releases - `1.0.x` Releases - [1.0.0](#100) diff --git a/README.md b/README.md index 2e694c9..3257034 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,17 @@ ## Usage -`Typhoon` provides two retry policy strategies: +`Typhoon` provides three retry policy strategies: ```swift /// A retry strategy with a constant number of attempts and fixed duration between retries. case constant(retry: Int, duration: DispatchTimeInterval) /// A retry strategy with an exponential increase in duration between retries. -case exponential(retry: Int, multiplier: Double, duration: DispatchTimeInterval) +case exponential(retry: Int, multiplier: Double = 2.0, duration: DispatchTimeInterval) + +/// A retry strategy with exponential increase in duration between retries and added jitter. +case exponentialWithJitter(retry: Int, jitterFactor: Double = 0.1, maxInterval: UInt64? = 60, multiplier: Double = 2.0, duration: DispatchTimeInterval) ``` Create a `RetryPolicyService` instance and pass a desired strategy like this: @@ -87,4 +90,4 @@ Please feel free to help out with this project! If you see something that could Nikita Vasilev, nv3212@gmail.com ## License -typhoon is available under the MIT license. See the LICENSE file for more info. \ No newline at end of file +typhoon is available under the MIT license. See the LICENSE file for more info. diff --git a/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift b/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift index d7a54ac..2047fd2 100644 --- a/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift +++ b/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift @@ -47,6 +47,13 @@ struct RetryIterator: IteratorProtocol { let value = duration * pow(multiplier, Double(retries)) return UInt64(value * .nanosec) } + case let .exponentialWithJitter(_, jitterFactor, maxInterval, multiplier, duration): + if let duration = duration.double { + let exponentialBackoff = duration * pow(multiplier, Double(retries)) + let jitter = Double.random(in: -jitterFactor * exponentialBackoff ... jitterFactor * exponentialBackoff) + let value = max(0, exponentialBackoff + jitter) + return min(maxInterval ?? UInt64.max, UInt64(value * .nanosec)) + } } return 0 diff --git a/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift b/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift index 41e6b35..5a76810 100644 --- a/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift +++ b/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift @@ -8,10 +8,35 @@ import Foundation /// A strategy used to define different retry policies. public enum RetryPolicyStrategy { /// A retry strategy with a constant number of attempts and fixed duration between retries. + /// + /// - Parameters: + /// - retry: The number of retry attempts. + /// - duration: The initial duration between retries. case constant(retry: Int, duration: DispatchTimeInterval) /// A retry strategy with an exponential increase in duration between retries. - case exponential(retry: Int, multiplier: Double, duration: DispatchTimeInterval) + /// + /// - Parameters: + /// - retry: The number of retry attempts. + /// - multiplier: The multiplier for calculating the exponential backoff duration (default is 2). + /// - duration: The initial duration between retries. + case exponential(retry: Int, multiplier: Double = 2, duration: DispatchTimeInterval) + + /// A retry strategy with exponential increase in duration between retries and added jitter. + /// + /// - Parameters: + /// - retry: The number of retry attempts. + /// - jitterFactor: The factor to control the amount of jitter (default is 0.1). + /// - maxInterval: The maximum allowed interval between retries (default is 60 seconds). + /// - multiplier: The multiplier for calculating the exponential backoff duration (default is 2). + /// - duration: The initial duration between retries. + case exponentialWithJitter( + retry: Int, + jitterFactor: Double = 0.1, + maxInterval: UInt64? = 60, + multiplier: Double = 2, + duration: DispatchTimeInterval + ) /// The number of retry attempts based on the strategy. public var retries: Int { @@ -20,6 +45,8 @@ public enum RetryPolicyStrategy { return retry case let .exponential(retry, _, _): return retry + case let .exponentialWithJitter(retry, _, _, _, _): + return retry } } @@ -30,6 +57,8 @@ public enum RetryPolicyStrategy { return duration case let .exponential(_, _, duration): return duration + case let .exponentialWithJitter(_, _, _, _, duration): + return duration } } } diff --git a/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift b/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift index 2b1f23f..97e56fd 100644 --- a/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift @@ -19,23 +19,84 @@ final class RetrySequenceTests: XCTestCase { let result: [UInt64] = sequence.map { $0 } // then - XCTAssertEqual(result, [1, 1, 1, 1, 1, 1]) + XCTAssertEqual(result, [1, 1, 1, 1, 1, 1, 1, 1]) } func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponential() { // given - let sequence = RetrySequence(strategy: .exponential(retry: .retry, multiplier: 2, duration: .nanoseconds(1))) + let sequence = RetrySequence(strategy: .exponential(retry: .retry, duration: .nanoseconds(1))) // when let result: [UInt64] = sequence.map { $0 } // then - XCTAssertEqual(result, [1, 2, 4, 8, 16, 32]) + XCTAssertEqual(result, [1, 2, 4, 8, 16, 32, 64, 128]) + } + + func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponentialWithJitter() { + // given + let sequence = RetrySequence( + strategy: .exponentialWithJitter( + retry: .retry, + jitterFactor: .jitterFactor, + maxInterval: .maxInterval, + duration: .nanoseconds(1) + ) + ) + + // when + let result: [UInt64] = sequence.map { $0 } + + // then + XCTAssertEqual(result.count, 8) + XCTAssertEqual(result[0], 1, accuracy: 1) + XCTAssertEqual(result[1], 2, accuracy: 1) + XCTAssertEqual(result[2], 4, accuracy: 1) + XCTAssertEqual(result[3], 8, accuracy: 1) + XCTAssertEqual(result[4], 16, accuracy: 2) + XCTAssertEqual(result[5], 32, accuracy: 4) + XCTAssertEqual(result[6], 64, accuracy: 7) + XCTAssertEqual(result[7], .maxInterval) + } + + func test_thatRetrySequenceDoesNotLimitASequence_whenStrategyIsExponentialWithJitterAndMaxIntervalIsNil() { + // given + let sequence = RetrySequence( + strategy: .exponentialWithJitter( + retry: .retry, + jitterFactor: .jitterFactor, + maxInterval: nil, + duration: .nanoseconds(1) + ) + ) + + // when + let result: [UInt64] = sequence.map { $0 } + + // then + XCTAssertEqual(result.count, 8) + XCTAssertEqual(result[0], 1, accuracy: 1) + XCTAssertEqual(result[1], 2, accuracy: 1) + XCTAssertEqual(result[2], 4, accuracy: 1) + XCTAssertEqual(result[3], 8, accuracy: 1) + XCTAssertEqual(result[4], 16, accuracy: 2) + XCTAssertEqual(result[5], 32, accuracy: 4) + XCTAssertEqual(result[6], 64, accuracy: 8) + XCTAssertEqual(result[7], 128, accuracy: 13) } } // MARK: - Constant private extension Int { - static let retry: Int = 6 + static let retry: Int = 8 +} + +private extension UInt64 { + static let maxInterval: UInt64 = 60 +} + +private extension Double { + static let multiplier = 2.0 + static let jitterFactor = 0.1 }