diff --git a/Sources/Typhoon/Classes/Extensions/Double+Nanosec.swift b/Sources/Typhoon/Classes/Extensions/Double+Nanosec.swift new file mode 100644 index 0000000..2e93f64 --- /dev/null +++ b/Sources/Typhoon/Classes/Extensions/Double+Nanosec.swift @@ -0,0 +1,10 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +// MARK: - Constants + +extension Double { + static let nanosec: Double = 1e+9 +} diff --git a/Sources/Typhoon/Classes/Extensions/Double+SafeUInt64.swift b/Sources/Typhoon/Classes/Extensions/Double+SafeUInt64.swift new file mode 100644 index 0000000..39be478 --- /dev/null +++ b/Sources/Typhoon/Classes/Extensions/Double+SafeUInt64.swift @@ -0,0 +1,12 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +extension Double { + var safeUInt64: UInt64 { + if self >= Double(UInt64.max) { return UInt64.max } + if self <= 0 { return .zero } + return UInt64(self) + } +} diff --git a/Sources/Typhoon/Classes/RetrySequence/IRetryDelayStrategy.swift b/Sources/Typhoon/Classes/RetrySequence/IRetryDelayStrategy.swift new file mode 100644 index 0000000..69a0457 --- /dev/null +++ b/Sources/Typhoon/Classes/RetrySequence/IRetryDelayStrategy.swift @@ -0,0 +1,18 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +/// A strategy that defines how delays between retry attempts are calculated. +/// +/// Implementations can provide different backoff algorithms, +/// such as constant, linear, exponential, or exponential with jitter. +protocol IRetryDelayStrategy { + /// Calculates the delay before the next retry attempt. + /// + /// - Parameter retries: The current retry attempt index, + /// starting from `0`. + /// - Returns: The delay in nanoseconds, or `nil` if + /// no further retries should be performed. + func delay(forRetry retries: UInt) -> UInt64? +} diff --git a/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift b/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift index 6ced30b..f673747 100644 --- a/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift +++ b/Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift @@ -25,20 +25,29 @@ struct RetryIterator: IteratorProtocol { /// This value is used when calculating exponential backoff delays. private var retries: UInt = 0 + /// The maximum number of retry attempts allowed. + /// + /// Once the number of attempts reaches this value, + /// the iterator stops producing further delays. + private let maxRetries: UInt + /// The retry policy strategy that defines: /// - The maximum number of retry attempts. /// - The algorithm used to calculate delays between retries /// (constant, exponential, or exponential with jitter). - private let strategy: RetryPolicyStrategy + private let delayStrategy: any IRetryDelayStrategy // MARK: Initialization - /// Creates a new `RetryIterator` with the specified retry policy strategy. + /// Creates a new `RetryIterator`. /// - /// - Parameter strategy: A `RetryPolicyStrategy` describing how retry delays - /// should be calculated and how many retries are allowed. - init(strategy: RetryPolicyStrategy) { - self.strategy = strategy + /// - Parameters: + /// - maxRetries: The maximum number of retry attempts allowed. + /// - delayStrategy: A strategy that defines how delays between + /// retry attempts are calculated. + init(maxRetries: UInt, delayStrategy: any IRetryDelayStrategy) { + self.maxRetries = maxRetries + self.delayStrategy = delayStrategy } // MARK: IteratorProtocol @@ -56,177 +65,8 @@ struct RetryIterator: IteratorProtocol { /// - Returns: The delay in nanoseconds for the current retry attempt, /// or `nil` if no more retries are allowed. mutating func next() -> UInt64? { - guard isValid() else { return nil } - + guard retries < maxRetries else { return nil } defer { retries += 1 } - - return delay() - } - - // MARK: Private - - /// Determines whether another retry attempt is allowed. - /// - /// This method compares the current retry count with the maximum - /// number of retries defined in the retry strategy. - /// - /// - Returns: `true` if another retry attempt is allowed; - /// `false` otherwise. - private func isValid() -> Bool { - retries < strategy.retries - } - - /// Calculates the delay for the current retry attempt - /// based on the selected retry strategy. - /// - /// - Returns: The computed delay in nanoseconds, or `0` - /// if the duration cannot be converted to seconds. - private func delay() -> UInt64? { - switch strategy { - case let .constant(_, duration): - convertToNanoseconds(duration) - - case let .exponential(_, jitterFactor, maxInterval, multiplier, duration): - calculateExponentialDelayWithJitter( - duration: duration, - multiplier: multiplier, - retries: retries, - jitterFactor: jitterFactor, - maxInterval: maxInterval - ) - } - } - - // MARK: - Helper Methods - - /// Converts a `DispatchTimeInterval` to nanoseconds. - /// - /// - Parameter duration: The time interval to convert. - /// - Returns: The equivalent duration in nanoseconds, or `0` - /// if the interval cannot be represented as seconds. - private func convertToNanoseconds(_ duration: DispatchTimeInterval) -> UInt64? { - guard let seconds = duration.double else { return .zero } - return safeConvertToUInt64(seconds * .nanosec) - } - - /// Calculates an exponential backoff delay without jitter. - /// - /// The delay is calculated as: - /// `baseDelay * multiplier ^ retries` - /// - /// - Parameters: - /// - duration: The base delay value. - /// - multiplier: The exponential growth multiplier. - /// - retries: The current retry attempt index. - /// - Returns: The calculated delay in nanoseconds. - private func calculateExponentialDelay( - duration: DispatchTimeInterval, - multiplier: Double, - retries: UInt - ) -> UInt64? { - guard let seconds = duration.double else { return .zero } - - let baseNanos = seconds * .nanosec - let value = baseNanos * pow(multiplier, Double(retries)) - - return safeConvertToUInt64(value) + return delayStrategy.delay(forRetry: retries) } - - /// Calculates an exponential backoff delay with jitter and an optional maximum interval. - /// - /// This method: - /// 1. Calculates the exponential backoff delay. - /// 2. Applies a random jitter to spread retry attempts over time. - /// 3. Caps the result at the provided maximum interval, if any. - /// - /// - Parameters: - /// - duration: The base delay value. - /// - multiplier: The exponential growth multiplier. - /// - retries: The current retry attempt index. - /// - jitterFactor: The percentage of randomness applied to the delay. - /// - maxInterval: An optional upper bound for the delay. - /// - Returns: The final delay in nanoseconds. - private func calculateExponentialDelayWithJitter( - duration: DispatchTimeInterval, - multiplier: Double, - retries: UInt, - jitterFactor: Double, - maxInterval: DispatchTimeInterval? - ) -> UInt64? { - guard let seconds = duration.double else { return .zero } - - let maxDelayNanos = calculateMaxDelay(maxInterval) - let baseNanos = seconds * .nanosec - let exponentialBackoffNanos = baseNanos * pow(multiplier, Double(retries)) - - guard exponentialBackoffNanos < maxDelayNanos, - exponentialBackoffNanos < Double(UInt64.max) - else { - return safeConvertToUInt64(maxDelayNanos) - } - - let delayWithJitter = applyJitter( - to: exponentialBackoffNanos, - factor: jitterFactor, - maxDelay: maxDelayNanos - ) - - return safeConvertToUInt64(min(delayWithJitter, maxDelayNanos)) - } - - /// Calculates the maximum allowed delay in nanoseconds. - /// - /// - Parameter maxInterval: An optional maximum delay value. - /// - Returns: The maximum delay in nanoseconds, clamped to `UInt64.max`. - private func calculateMaxDelay(_ maxInterval: DispatchTimeInterval?) -> Double { - guard let maxSeconds = maxInterval?.double else { - return Double(UInt64.max) - } - - let maxNanos = maxSeconds * .nanosec - return min(maxNanos, Double(UInt64.max)) - } - - /// Applies random jitter to a delay value. - /// - /// Jitter helps prevent synchronized retries (the "thundering herd" problem) - /// by randomizing retry timings within a defined range. - /// - /// - Parameters: - /// - value: The base delay value in nanoseconds. - /// - factor: The jitter factor defining the randomization range. - /// - maxDelay: The maximum allowed delay. - /// - Returns: A jittered delay value clamped to valid bounds. - private func applyJitter( - to value: Double, - factor: Double, - maxDelay: Double - ) -> Double { - let jitterRange = value * factor - let minValue = value - jitterRange - let maxValue = min(value + jitterRange, maxDelay) - - guard maxValue < Double(UInt64.max) else { - return maxDelay - } - - let randomized = Double.random(in: minValue ... maxValue) - return max(0, randomized) - } - - private func safeConvertToUInt64(_ value: Double) -> UInt64 { - if value >= Double(UInt64.max) { - return UInt64.max - } - if value <= 0 { - return .zero - } - return UInt64(value) - } -} - -// MARK: - Constants - -private extension Double { - static let nanosec: Double = 1e+9 } diff --git a/Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift b/Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift index 6a122ec..552ddd9 100644 --- a/Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift +++ b/Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift @@ -24,6 +24,6 @@ struct RetrySequence: Sequence { // MARK: Sequence func makeIterator() -> RetryIterator { - RetryIterator(strategy: strategy) + RetryIterator(maxRetries: strategy.retries, delayStrategy: strategy.strategy) } } diff --git a/Sources/Typhoon/Classes/RetrySequence/Strategies/ConstantDelayStrategy.swift b/Sources/Typhoon/Classes/RetrySequence/Strategies/ConstantDelayStrategy.swift new file mode 100644 index 0000000..6571c0a --- /dev/null +++ b/Sources/Typhoon/Classes/RetrySequence/Strategies/ConstantDelayStrategy.swift @@ -0,0 +1,26 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +import Foundation + +struct ConstantDelayStrategy: IRetryDelayStrategy { + // MARK: - Properties + + /// The fixed delay interval applied to every retry attempt. + /// + /// This value does not change based on the retry index. + let duration: DispatchTimeInterval + + // MARK: - IRetryDelayStrategy + + /// Returns a constant delay for each retry attempt. + /// + /// - Parameter retries: The current retry attempt index (ignored). + /// - Returns: The delay in nanoseconds. + func delay(forRetry _: UInt) -> UInt64? { + guard let seconds = duration.double else { return .zero } + return (seconds * .nanosec).safeUInt64 + } +} diff --git a/Sources/Typhoon/Classes/RetrySequence/Strategies/ExponentialDelayStrategy.swift b/Sources/Typhoon/Classes/RetrySequence/Strategies/ExponentialDelayStrategy.swift new file mode 100644 index 0000000..4f8494d --- /dev/null +++ b/Sources/Typhoon/Classes/RetrySequence/Strategies/ExponentialDelayStrategy.swift @@ -0,0 +1,60 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +import Foundation + +struct ExponentialDelayStrategy: IRetryDelayStrategy { + // MARK: - Properties + + /// The initial delay duration before the first retry attempt. + /// + /// This value acts as the base interval for exponential backoff. + let duration: DispatchTimeInterval + + /// The exponential growth multiplier. + /// + /// Each subsequent retry delay is calculated as: + /// `baseDelay * pow(multiplier, retryIndex)` + let multiplier: Double + + /// A value between `0.0` and `1.0` that defines + /// the percentage of randomness applied to the delay. + /// + /// For example, `0.2` means the final delay may vary + /// ±20% around the computed exponential value. + let jitterFactor: Double + + /// An optional upper bound for the delay interval. + /// + /// If specified, the computed delay will never exceed this value. + let maxInterval: DispatchTimeInterval? + + // MARK: - IRetryDelayStrategy + + /// Calculates the delay for a given retry attempt using + /// exponential backoff with optional jitter. + /// + /// - Parameter retries: The current retry attempt index (starting at `0`). + /// - Returns: The delay in nanoseconds, or `nil` if it cannot be computed. + func delay(forRetry retries: UInt) -> UInt64? { + guard let seconds = duration.double else { return .zero } + + let maxDelayNanos = maxInterval.flatMap(\.double) + .map { min($0 * .nanosec, Double(UInt64.max)) } ?? Double(UInt64.max) + + let base = seconds * .nanosec * pow(multiplier, Double(retries)) + + guard base < maxDelayNanos, base < Double(UInt64.max) else { + return maxDelayNanos.safeUInt64 + } + + let jitterRange = base * jitterFactor + let jittered = Double.random( + in: max(0, base - jitterRange) ... min(base + jitterRange, maxDelayNanos) + ) + + return min(jittered, maxDelayNanos).safeUInt64 + } +} diff --git a/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift b/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift index 82e1836..02f495f 100644 --- a/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift +++ b/Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift @@ -5,6 +5,8 @@ import Foundation +// MARK: - RetryPolicyStrategy + /// A strategy used to define different retry policies. public enum RetryPolicyStrategy: Sendable { /// A retry strategy with a constant number of attempts and fixed duration between retries. @@ -12,7 +14,7 @@ public enum RetryPolicyStrategy: Sendable { /// - Parameters: /// - retry: The number of retry attempts. /// - duration: The initial duration between retries. - case constant(retry: Int, duration: DispatchTimeInterval) + case constant(retry: UInt, duration: DispatchTimeInterval) /// A retry strategy with exponential increase in duration between retries and added jitter. /// @@ -23,7 +25,7 @@ public enum RetryPolicyStrategy: Sendable { /// - multiplier: The multiplier for calculating the exponential backoff duration (default is 2). /// - duration: The initial duration between retries. case exponential( - retry: Int, + retry: UInt, jitterFactor: Double = 0.1, maxInterval: DispatchTimeInterval? = .seconds(60), multiplier: Double = 2, @@ -31,7 +33,7 @@ public enum RetryPolicyStrategy: Sendable { ) /// The number of retry attempts based on the strategy. - public var retries: Int { + public var retries: UInt { switch self { case let .constant(retry, _): retry @@ -50,3 +52,19 @@ public enum RetryPolicyStrategy: Sendable { } } } + +extension RetryPolicyStrategy { + var strategy: IRetryDelayStrategy { + switch self { + case let .exponential(retry, jitterFactor, maxInterval, multiplier, duration): + ExponentialDelayStrategy( + duration: duration, + multiplier: multiplier, + jitterFactor: jitterFactor, + maxInterval: maxInterval + ) + case let .constant(retry, duration): + ConstantDelayStrategy(duration: duration) + } + } +} diff --git a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift b/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift index ef86069..aaf4573 100644 --- a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift @@ -171,7 +171,7 @@ final class RetryPolicyServiceTests: XCTestCase { func test_thatRetryInvokesOnFailureMultipleTimes_whenMultipleRetriesFail() async { // given let counter = Counter() - let expectedCallCount = 3 + let expectedCallCount: UInt = 3 // when do { @@ -242,14 +242,14 @@ final class RetryPolicyServiceTests: XCTestCase { // when do { _ = try await sut.retry( - strategy: .constant(retry: errors.count, duration: .nanoseconds(1)), + strategy: .constant(retry: UInt(errors.count), duration: .nanoseconds(1)), onFailure: { error in await errorContainer.setError(error as NSError) return true } ) { let index = await counter.increment() - 1 - throw errors[min(index, errors.count - 1)] + throw errors[min(Int(index), errors.count - 1)] } } catch {} @@ -264,16 +264,16 @@ final class RetryPolicyServiceTests: XCTestCase { private actor Counter { // MARK: Properties - private var value: Int = 0 + private var value: UInt = 0 // MARK: Internal - func increment() -> Int { + func increment() -> UInt { value += 1 return value } - func getValue() -> Int { + func getValue() -> UInt { value } } @@ -298,6 +298,6 @@ private actor ErrorContainer { // MARK: - Constants -private extension Int { - static let defaultRetryCount = 5 +private extension UInt { + static let defaultRetryCount: UInt = 5 } diff --git a/Tests/TyphoonTests/UnitTests/RetryPolicyStrategyTests.swift b/Tests/TyphoonTests/UnitTests/RetryPolicyStrategyTests.swift index 21e646a..783c0af 100644 --- a/Tests/TyphoonTests/UnitTests/RetryPolicyStrategyTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryPolicyStrategyTests.swift @@ -38,8 +38,8 @@ final class RetryPolicyStrategyTests: XCTestCase { // MARK: Constants -private extension Int { - static let retry = 5 +private extension UInt { + static let retry: UInt = 5 } private extension DispatchTimeInterval { diff --git a/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift b/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift index 95cd224..3f6aadb 100644 --- a/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift @@ -203,8 +203,8 @@ final class RetrySequenceTests: XCTestCase { // MARK: - Constant -private extension Int { - static let retry: Int = 8 +private extension UInt { + static let retry: UInt = 8 } private extension UInt64 {