Skip to content

Commit c6199f8

Browse files
authored
refactor(retry): extract delay calculation into strategy implementations (#72)
1 parent 44a8864 commit c6199f8

11 files changed

Lines changed: 177 additions & 193 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
// MARK: - Constants
7+
8+
extension Double {
9+
static let nanosec: Double = 1e+9
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
extension Double {
7+
var safeUInt64: UInt64 {
8+
if self >= Double(UInt64.max) { return UInt64.max }
9+
if self <= 0 { return .zero }
10+
return UInt64(self)
11+
}
12+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
/// A strategy that defines how delays between retry attempts are calculated.
7+
///
8+
/// Implementations can provide different backoff algorithms,
9+
/// such as constant, linear, exponential, or exponential with jitter.
10+
protocol IRetryDelayStrategy {
11+
/// Calculates the delay before the next retry attempt.
12+
///
13+
/// - Parameter retries: The current retry attempt index,
14+
/// starting from `0`.
15+
/// - Returns: The delay in nanoseconds, or `nil` if
16+
/// no further retries should be performed.
17+
func delay(forRetry retries: UInt) -> UInt64?
18+
}

Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift

Lines changed: 17 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,29 @@ struct RetryIterator: IteratorProtocol {
2525
/// This value is used when calculating exponential backoff delays.
2626
private var retries: UInt = 0
2727

28+
/// The maximum number of retry attempts allowed.
29+
///
30+
/// Once the number of attempts reaches this value,
31+
/// the iterator stops producing further delays.
32+
private let maxRetries: UInt
33+
2834
/// The retry policy strategy that defines:
2935
/// - The maximum number of retry attempts.
3036
/// - The algorithm used to calculate delays between retries
3137
/// (constant, exponential, or exponential with jitter).
32-
private let strategy: RetryPolicyStrategy
38+
private let delayStrategy: any IRetryDelayStrategy
3339

3440
// MARK: Initialization
3541

36-
/// Creates a new `RetryIterator` with the specified retry policy strategy.
42+
/// Creates a new `RetryIterator`.
3743
///
38-
/// - Parameter strategy: A `RetryPolicyStrategy` describing how retry delays
39-
/// should be calculated and how many retries are allowed.
40-
init(strategy: RetryPolicyStrategy) {
41-
self.strategy = strategy
44+
/// - Parameters:
45+
/// - maxRetries: The maximum number of retry attempts allowed.
46+
/// - delayStrategy: A strategy that defines how delays between
47+
/// retry attempts are calculated.
48+
init(maxRetries: UInt, delayStrategy: any IRetryDelayStrategy) {
49+
self.maxRetries = maxRetries
50+
self.delayStrategy = delayStrategy
4251
}
4352

4453
// MARK: IteratorProtocol
@@ -56,177 +65,8 @@ struct RetryIterator: IteratorProtocol {
5665
/// - Returns: The delay in nanoseconds for the current retry attempt,
5766
/// or `nil` if no more retries are allowed.
5867
mutating func next() -> UInt64? {
59-
guard isValid() else { return nil }
60-
68+
guard retries < maxRetries else { return nil }
6169
defer { retries += 1 }
62-
63-
return delay()
64-
}
65-
66-
// MARK: Private
67-
68-
/// Determines whether another retry attempt is allowed.
69-
///
70-
/// This method compares the current retry count with the maximum
71-
/// number of retries defined in the retry strategy.
72-
///
73-
/// - Returns: `true` if another retry attempt is allowed;
74-
/// `false` otherwise.
75-
private func isValid() -> Bool {
76-
retries < strategy.retries
77-
}
78-
79-
/// Calculates the delay for the current retry attempt
80-
/// based on the selected retry strategy.
81-
///
82-
/// - Returns: The computed delay in nanoseconds, or `0`
83-
/// if the duration cannot be converted to seconds.
84-
private func delay() -> UInt64? {
85-
switch strategy {
86-
case let .constant(_, duration):
87-
convertToNanoseconds(duration)
88-
89-
case let .exponential(_, jitterFactor, maxInterval, multiplier, duration):
90-
calculateExponentialDelayWithJitter(
91-
duration: duration,
92-
multiplier: multiplier,
93-
retries: retries,
94-
jitterFactor: jitterFactor,
95-
maxInterval: maxInterval
96-
)
97-
}
98-
}
99-
100-
// MARK: - Helper Methods
101-
102-
/// Converts a `DispatchTimeInterval` to nanoseconds.
103-
///
104-
/// - Parameter duration: The time interval to convert.
105-
/// - Returns: The equivalent duration in nanoseconds, or `0`
106-
/// if the interval cannot be represented as seconds.
107-
private func convertToNanoseconds(_ duration: DispatchTimeInterval) -> UInt64? {
108-
guard let seconds = duration.double else { return .zero }
109-
return safeConvertToUInt64(seconds * .nanosec)
110-
}
111-
112-
/// Calculates an exponential backoff delay without jitter.
113-
///
114-
/// The delay is calculated as:
115-
/// `baseDelay * multiplier ^ retries`
116-
///
117-
/// - Parameters:
118-
/// - duration: The base delay value.
119-
/// - multiplier: The exponential growth multiplier.
120-
/// - retries: The current retry attempt index.
121-
/// - Returns: The calculated delay in nanoseconds.
122-
private func calculateExponentialDelay(
123-
duration: DispatchTimeInterval,
124-
multiplier: Double,
125-
retries: UInt
126-
) -> UInt64? {
127-
guard let seconds = duration.double else { return .zero }
128-
129-
let baseNanos = seconds * .nanosec
130-
let value = baseNanos * pow(multiplier, Double(retries))
131-
132-
return safeConvertToUInt64(value)
70+
return delayStrategy.delay(forRetry: retries)
13371
}
134-
135-
/// Calculates an exponential backoff delay with jitter and an optional maximum interval.
136-
///
137-
/// This method:
138-
/// 1. Calculates the exponential backoff delay.
139-
/// 2. Applies a random jitter to spread retry attempts over time.
140-
/// 3. Caps the result at the provided maximum interval, if any.
141-
///
142-
/// - Parameters:
143-
/// - duration: The base delay value.
144-
/// - multiplier: The exponential growth multiplier.
145-
/// - retries: The current retry attempt index.
146-
/// - jitterFactor: The percentage of randomness applied to the delay.
147-
/// - maxInterval: An optional upper bound for the delay.
148-
/// - Returns: The final delay in nanoseconds.
149-
private func calculateExponentialDelayWithJitter(
150-
duration: DispatchTimeInterval,
151-
multiplier: Double,
152-
retries: UInt,
153-
jitterFactor: Double,
154-
maxInterval: DispatchTimeInterval?
155-
) -> UInt64? {
156-
guard let seconds = duration.double else { return .zero }
157-
158-
let maxDelayNanos = calculateMaxDelay(maxInterval)
159-
let baseNanos = seconds * .nanosec
160-
let exponentialBackoffNanos = baseNanos * pow(multiplier, Double(retries))
161-
162-
guard exponentialBackoffNanos < maxDelayNanos,
163-
exponentialBackoffNanos < Double(UInt64.max)
164-
else {
165-
return safeConvertToUInt64(maxDelayNanos)
166-
}
167-
168-
let delayWithJitter = applyJitter(
169-
to: exponentialBackoffNanos,
170-
factor: jitterFactor,
171-
maxDelay: maxDelayNanos
172-
)
173-
174-
return safeConvertToUInt64(min(delayWithJitter, maxDelayNanos))
175-
}
176-
177-
/// Calculates the maximum allowed delay in nanoseconds.
178-
///
179-
/// - Parameter maxInterval: An optional maximum delay value.
180-
/// - Returns: The maximum delay in nanoseconds, clamped to `UInt64.max`.
181-
private func calculateMaxDelay(_ maxInterval: DispatchTimeInterval?) -> Double {
182-
guard let maxSeconds = maxInterval?.double else {
183-
return Double(UInt64.max)
184-
}
185-
186-
let maxNanos = maxSeconds * .nanosec
187-
return min(maxNanos, Double(UInt64.max))
188-
}
189-
190-
/// Applies random jitter to a delay value.
191-
///
192-
/// Jitter helps prevent synchronized retries (the "thundering herd" problem)
193-
/// by randomizing retry timings within a defined range.
194-
///
195-
/// - Parameters:
196-
/// - value: The base delay value in nanoseconds.
197-
/// - factor: The jitter factor defining the randomization range.
198-
/// - maxDelay: The maximum allowed delay.
199-
/// - Returns: A jittered delay value clamped to valid bounds.
200-
private func applyJitter(
201-
to value: Double,
202-
factor: Double,
203-
maxDelay: Double
204-
) -> Double {
205-
let jitterRange = value * factor
206-
let minValue = value - jitterRange
207-
let maxValue = min(value + jitterRange, maxDelay)
208-
209-
guard maxValue < Double(UInt64.max) else {
210-
return maxDelay
211-
}
212-
213-
let randomized = Double.random(in: minValue ... maxValue)
214-
return max(0, randomized)
215-
}
216-
217-
private func safeConvertToUInt64(_ value: Double) -> UInt64 {
218-
if value >= Double(UInt64.max) {
219-
return UInt64.max
220-
}
221-
if value <= 0 {
222-
return .zero
223-
}
224-
return UInt64(value)
225-
}
226-
}
227-
228-
// MARK: - Constants
229-
230-
private extension Double {
231-
static let nanosec: Double = 1e+9
23272
}

Sources/Typhoon/Classes/RetrySequence/RetrySequence.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ struct RetrySequence: Sequence {
2424
// MARK: Sequence
2525

2626
func makeIterator() -> RetryIterator {
27-
RetryIterator(strategy: strategy)
27+
RetryIterator(maxRetries: strategy.retries, delayStrategy: strategy.strategy)
2828
}
2929
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
struct ConstantDelayStrategy: IRetryDelayStrategy {
9+
// MARK: - Properties
10+
11+
/// The fixed delay interval applied to every retry attempt.
12+
///
13+
/// This value does not change based on the retry index.
14+
let duration: DispatchTimeInterval
15+
16+
// MARK: - IRetryDelayStrategy
17+
18+
/// Returns a constant delay for each retry attempt.
19+
///
20+
/// - Parameter retries: The current retry attempt index (ignored).
21+
/// - Returns: The delay in nanoseconds.
22+
func delay(forRetry _: UInt) -> UInt64? {
23+
guard let seconds = duration.double else { return .zero }
24+
return (seconds * .nanosec).safeUInt64
25+
}
26+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
struct ExponentialDelayStrategy: IRetryDelayStrategy {
9+
// MARK: - Properties
10+
11+
/// The initial delay duration before the first retry attempt.
12+
///
13+
/// This value acts as the base interval for exponential backoff.
14+
let duration: DispatchTimeInterval
15+
16+
/// The exponential growth multiplier.
17+
///
18+
/// Each subsequent retry delay is calculated as:
19+
/// `baseDelay * pow(multiplier, retryIndex)`
20+
let multiplier: Double
21+
22+
/// A value between `0.0` and `1.0` that defines
23+
/// the percentage of randomness applied to the delay.
24+
///
25+
/// For example, `0.2` means the final delay may vary
26+
/// ±20% around the computed exponential value.
27+
let jitterFactor: Double
28+
29+
/// An optional upper bound for the delay interval.
30+
///
31+
/// If specified, the computed delay will never exceed this value.
32+
let maxInterval: DispatchTimeInterval?
33+
34+
// MARK: - IRetryDelayStrategy
35+
36+
/// Calculates the delay for a given retry attempt using
37+
/// exponential backoff with optional jitter.
38+
///
39+
/// - Parameter retries: The current retry attempt index (starting at `0`).
40+
/// - Returns: The delay in nanoseconds, or `nil` if it cannot be computed.
41+
func delay(forRetry retries: UInt) -> UInt64? {
42+
guard let seconds = duration.double else { return .zero }
43+
44+
let maxDelayNanos = maxInterval.flatMap(\.double)
45+
.map { min($0 * .nanosec, Double(UInt64.max)) } ?? Double(UInt64.max)
46+
47+
let base = seconds * .nanosec * pow(multiplier, Double(retries))
48+
49+
guard base < maxDelayNanos, base < Double(UInt64.max) else {
50+
return maxDelayNanos.safeUInt64
51+
}
52+
53+
let jitterRange = base * jitterFactor
54+
let jittered = Double.random(
55+
in: max(0, base - jitterRange) ... min(base + jitterRange, maxDelayNanos)
56+
)
57+
58+
return min(jittered, maxDelayNanos).safeUInt64
59+
}
60+
}

0 commit comments

Comments
 (0)