Skip to content

Commit ae9ed23

Browse files
authored
fix(retry): safely handle UInt64 overflow and standardize max interval unit (#43)
1 parent 2be1c2e commit ae9ed23

3 files changed

Lines changed: 227 additions & 28 deletions

File tree

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

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,108 @@ struct RetryIterator: IteratorProtocol {
3939
private func delay() -> UInt64? {
4040
switch strategy {
4141
case let .constant(_, duration):
42-
if let duration = duration.double {
43-
return UInt64(duration * .nanosec)
44-
}
42+
convertToNanoseconds(duration)
43+
4544
case let .exponential(_, multiplier, duration):
46-
if let duration = duration.double {
47-
let value = duration * pow(multiplier, Double(retries))
48-
return UInt64(value * .nanosec)
49-
}
45+
calculateExponentialDelay(
46+
duration: duration,
47+
multiplier: multiplier,
48+
retries: retries
49+
)
50+
5051
case let .exponentialWithJitter(_, jitterFactor, maxInterval, multiplier, duration):
51-
if let duration = duration.double {
52-
let exponentialBackoff = duration * pow(multiplier, Double(retries))
53-
let jitter = Double.random(in: -jitterFactor * exponentialBackoff ... jitterFactor * exponentialBackoff)
54-
let value = max(0, exponentialBackoff + jitter)
55-
return min(maxInterval ?? UInt64.max, UInt64(value * .nanosec))
56-
}
52+
calculateExponentialDelayWithJitter(
53+
duration: duration,
54+
multiplier: multiplier,
55+
retries: retries,
56+
jitterFactor: jitterFactor,
57+
maxInterval: maxInterval
58+
)
5759
}
60+
}
61+
62+
// MARK: - Helper Methods
63+
64+
private func convertToNanoseconds(_ duration: DispatchTimeInterval) -> UInt64? {
65+
guard let seconds = duration.double else { return .zero }
66+
return safeConvertToUInt64(seconds * .nanosec)
67+
}
68+
69+
private func calculateExponentialDelay(
70+
duration: DispatchTimeInterval,
71+
multiplier: Double,
72+
retries: UInt
73+
) -> UInt64? {
74+
guard let seconds = duration.double else { return .zero }
75+
76+
let baseNanos = seconds * .nanosec
77+
let value = baseNanos * pow(multiplier, Double(retries))
78+
79+
return safeConvertToUInt64(value)
80+
}
81+
82+
private func calculateExponentialDelayWithJitter(
83+
duration: DispatchTimeInterval,
84+
multiplier: Double,
85+
retries: UInt,
86+
jitterFactor: Double,
87+
maxInterval: DispatchTimeInterval?
88+
) -> UInt64? {
89+
guard let seconds = duration.double else { return .zero }
90+
91+
let maxDelayNanos = calculateMaxDelay(maxInterval)
92+
let baseNanos = seconds * .nanosec
93+
let exponentialBackoffNanos = baseNanos * pow(multiplier, Double(retries))
94+
95+
guard exponentialBackoffNanos < maxDelayNanos,
96+
exponentialBackoffNanos < Double(UInt64.max)
97+
else {
98+
return safeConvertToUInt64(maxDelayNanos)
99+
}
100+
101+
let delayWithJitter = applyJitter(
102+
to: exponentialBackoffNanos,
103+
factor: jitterFactor,
104+
maxDelay: maxDelayNanos
105+
)
58106

59-
return 0
107+
return safeConvertToUInt64(min(delayWithJitter, maxDelayNanos))
108+
}
109+
110+
private func calculateMaxDelay(_ maxInterval: DispatchTimeInterval?) -> Double {
111+
guard let maxSeconds = maxInterval?.double else {
112+
return Double(UInt64.max)
113+
}
114+
115+
let maxNanos = maxSeconds * .nanosec
116+
return min(maxNanos, Double(UInt64.max))
117+
}
118+
119+
private func applyJitter(
120+
to value: Double,
121+
factor: Double,
122+
maxDelay: Double
123+
) -> Double {
124+
let jitterRange = value * factor
125+
let minValue = value - jitterRange
126+
let maxValue = min(value + jitterRange, maxDelay)
127+
128+
guard maxValue < Double(UInt64.max) else {
129+
return maxDelay
130+
}
131+
132+
let randomized = Double.random(in: minValue ... maxValue)
133+
return max(0, randomized)
134+
}
135+
136+
private func safeConvertToUInt64(_ value: Double) -> UInt64 {
137+
if value >= Double(UInt64.max) {
138+
return UInt64.max
139+
}
140+
if value <= 0 {
141+
return .zero
142+
}
143+
return UInt64(value)
60144
}
61145
}
62146

Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public enum RetryPolicyStrategy: Sendable {
3333
case exponentialWithJitter(
3434
retry: Int,
3535
jitterFactor: Double = 0.1,
36-
maxInterval: UInt64? = 60,
36+
maxInterval: DispatchTimeInterval? = .seconds(60),
3737
multiplier: Double = 2,
3838
duration: DispatchTimeInterval
3939
)

Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift

Lines changed: 128 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,137 @@ final class RetrySequenceTests: XCTestCase {
3535

3636
func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponentialWithJitter() {
3737
// given
38+
let durationSeconds = 1.0
39+
let multiplier = 2.0
40+
let jitterFactor = 0.1
41+
3842
let sequence = RetrySequence(
3943
strategy: .exponentialWithJitter(
40-
retry: .retry,
41-
jitterFactor: .jitterFactor,
42-
maxInterval: .maxInterval,
43-
duration: .nanosecond
44+
retry: 5,
45+
jitterFactor: jitterFactor,
46+
maxInterval: nil,
47+
multiplier: multiplier,
48+
duration: .seconds(Int(durationSeconds))
4449
)
4550
)
4651

4752
// when
4853
let result: [UInt64] = sequence.map { $0 }
4954

5055
// then
51-
XCTAssertEqual(result.count, 8)
52-
XCTAssertEqual(result[0], 1, accuracy: 1)
53-
XCTAssertEqual(result[1], 2, accuracy: 1)
54-
XCTAssertEqual(result[2], 4, accuracy: 1)
55-
XCTAssertEqual(result[3], 8, accuracy: 1)
56-
XCTAssertEqual(result[4], 16, accuracy: 2)
57-
XCTAssertEqual(result[5], 32, accuracy: 4)
58-
XCTAssertEqual(result[6], 64, accuracy: 7)
59-
XCTAssertEqual(result[7], .maxInterval)
56+
XCTAssertEqual(result.count, 5)
57+
58+
for (i, valueNanos) in result.enumerated() {
59+
let seconds = toSeconds(valueNanos)
60+
61+
let expectedBase = durationSeconds * pow(multiplier, Double(i))
62+
63+
let lowerBound = expectedBase * (1.0 - jitterFactor)
64+
let upperBound = expectedBase * (1.0 + jitterFactor)
65+
66+
XCTAssertTrue(
67+
seconds >= lowerBound && seconds <= upperBound,
68+
"Attempt \(i): \(seconds)s should be between \(lowerBound)s and \(upperBound)s"
69+
)
70+
}
71+
}
72+
73+
func test_thatRetrySequenceRespectsMaxInterval_whenStrategyIsExponentialWithJitter() {
74+
// given
75+
let maxIntervalDuration: DispatchTimeInterval = .seconds(10)
76+
let maxIntervalNanos: UInt64 = 10 * 1_000_000_000
77+
78+
let sequence = RetrySequence(
79+
strategy: .exponentialWithJitter(
80+
retry: 10,
81+
jitterFactor: 0.1,
82+
maxInterval: maxIntervalDuration,
83+
multiplier: 2.0,
84+
duration: .seconds(1)
85+
)
86+
)
87+
88+
// when
89+
let result: [UInt64] = sequence.map { $0 }
90+
91+
// then
92+
XCTAssertEqual(result.count, 10)
93+
94+
for (i, val) in result.enumerated() {
95+
XCTAssertLessThanOrEqual(val, maxIntervalNanos, "Attempt \(i) exceeded maxInterval")
96+
97+
let expectedBaseSeconds = 1.0 * pow(2.0, Double(i))
98+
99+
if expectedBaseSeconds * (1.0 - 0.1) > 10.0 {
100+
XCTAssertEqual(val, maxIntervalNanos, "Attempt \(i) should be capped at maxInterval")
101+
}
102+
}
103+
}
104+
105+
func test_thatRetrySequenceAppliesJitter_whenStrategyIsExponentialWithJitter() {
106+
// given
107+
let strategy = RetryPolicyStrategy.exponentialWithJitter(
108+
retry: 30,
109+
jitterFactor: 0.5,
110+
maxInterval: nil,
111+
multiplier: 2.0,
112+
duration: .milliseconds(10)
113+
)
114+
115+
let sequence1 = RetrySequence(strategy: strategy)
116+
let sequence2 = RetrySequence(strategy: strategy)
117+
118+
// when
119+
let result1 = sequence1.map { $0 }
120+
let result2 = sequence2.map { $0 }
121+
122+
// then
123+
XCTAssertEqual(result1.count, 30)
124+
125+
XCTAssertNotEqual(result1, result2, "Two sequences with jitter should produce different values")
126+
127+
for (i, val) in result1.enumerated() {
128+
let seconds = toSeconds(val)
129+
130+
let base = 0.01 * pow(2.0, Double(i))
131+
132+
let lower = base * 0.5
133+
let upper = base * 1.5
134+
135+
XCTAssertTrue(
136+
seconds >= lower && seconds <= upper,
137+
"Attempt \(i): Value \(seconds) is out of bounds [\(lower), \(upper)]"
138+
)
139+
}
140+
}
141+
142+
func test_thatRetrySequenceWorksWithoutMaxInterval_whenStrategyIsExponentialWithJitter() {
143+
// given
144+
let sequence = RetrySequence(
145+
strategy: .exponentialWithJitter(
146+
retry: 5,
147+
jitterFactor: 0.1,
148+
maxInterval: nil,
149+
multiplier: 2.0,
150+
duration: .seconds(1)
151+
)
152+
)
153+
154+
// when
155+
let result: [UInt64] = sequence.map { $0 }
156+
157+
// then
158+
XCTAssertEqual(result.count, 5)
159+
160+
for i in 1 ..< result.count {
161+
XCTAssertGreaterThan(
162+
result[i],
163+
result[i - 1],
164+
"Each delay should be greater than previous (exponential growth)"
165+
)
166+
}
167+
168+
XCTAssertGreaterThan(result[4], result[0] * 10)
60169
}
61170

62171
func test_thatRetrySequenceDoesNotLimitASequence_whenStrategyIsExponentialWithJitterAndMaxIntervalIsNil() {
@@ -84,6 +193,12 @@ final class RetrySequenceTests: XCTestCase {
84193
XCTAssertEqual(result[6], 64, accuracy: 8)
85194
XCTAssertEqual(result[7], 128, accuracy: 13)
86195
}
196+
197+
// MARK: Helpers
198+
199+
private func toSeconds(_ nanos: UInt64) -> Double {
200+
Double(nanos) / 1_000_000_000
201+
}
87202
}
88203

89204
// MARK: - Constant

0 commit comments

Comments
 (0)