Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 98 additions & 14 deletions Sources/Typhoon/Classes/RetrySequence/Iterator/RetryIterator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,108 @@ struct RetryIterator: IteratorProtocol {
private func delay() -> UInt64? {
switch strategy {
case let .constant(_, duration):
if let duration = duration.double {
return UInt64(duration * .nanosec)
}
convertToNanoseconds(duration)

case let .exponential(_, multiplier, duration):
if let duration = duration.double {
let value = duration * pow(multiplier, Double(retries))
return UInt64(value * .nanosec)
}
calculateExponentialDelay(
duration: duration,
multiplier: multiplier,
retries: retries
)

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))
}
calculateExponentialDelayWithJitter(
duration: duration,
multiplier: multiplier,
retries: retries,
jitterFactor: jitterFactor,
maxInterval: maxInterval
)
}
}

// MARK: - Helper Methods

private func convertToNanoseconds(_ duration: DispatchTimeInterval) -> UInt64? {
guard let seconds = duration.double else { return .zero }
return safeConvertToUInt64(seconds * .nanosec)
}

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)
}

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 0
return safeConvertToUInt64(min(delayWithJitter, maxDelayNanos))
}

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))
}

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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public enum RetryPolicyStrategy: Sendable {
case exponentialWithJitter(
retry: Int,
jitterFactor: Double = 0.1,
maxInterval: UInt64? = 60,
maxInterval: DispatchTimeInterval? = .seconds(60),
multiplier: Double = 2,
duration: DispatchTimeInterval
)
Expand Down
141 changes: 128 additions & 13 deletions Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,137 @@ final class RetrySequenceTests: XCTestCase {

func test_thatRetrySequenceCreatesASequence_whenStrategyIsExponentialWithJitter() {
// given
let durationSeconds = 1.0
let multiplier = 2.0
let jitterFactor = 0.1

let sequence = RetrySequence(
strategy: .exponentialWithJitter(
retry: .retry,
jitterFactor: .jitterFactor,
maxInterval: .maxInterval,
duration: .nanosecond
retry: 5,
jitterFactor: jitterFactor,
maxInterval: nil,
multiplier: multiplier,
duration: .seconds(Int(durationSeconds))
)
)

// 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)
XCTAssertEqual(result.count, 5)

for (i, valueNanos) in result.enumerated() {
let seconds = toSeconds(valueNanos)

let expectedBase = durationSeconds * pow(multiplier, Double(i))

let lowerBound = expectedBase * (1.0 - jitterFactor)
let upperBound = expectedBase * (1.0 + jitterFactor)

XCTAssertTrue(
seconds >= lowerBound && seconds <= upperBound,
"Attempt \(i): \(seconds)s should be between \(lowerBound)s and \(upperBound)s"
)
}
}

func test_thatRetrySequenceRespectsMaxInterval_whenStrategyIsExponentialWithJitter() {
// given
let maxIntervalDuration: DispatchTimeInterval = .seconds(10)
let maxIntervalNanos: UInt64 = 10 * 1_000_000_000

let sequence = RetrySequence(
strategy: .exponentialWithJitter(
retry: 10,
jitterFactor: 0.1,
maxInterval: maxIntervalDuration,
multiplier: 2.0,
duration: .seconds(1)
)
)

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

// then
XCTAssertEqual(result.count, 10)

for (i, val) in result.enumerated() {
XCTAssertLessThanOrEqual(val, maxIntervalNanos, "Attempt \(i) exceeded maxInterval")

let expectedBaseSeconds = 1.0 * pow(2.0, Double(i))

if expectedBaseSeconds * (1.0 - 0.1) > 10.0 {
XCTAssertEqual(val, maxIntervalNanos, "Attempt \(i) should be capped at maxInterval")
}
}
}

func test_thatRetrySequenceAppliesJitter_whenStrategyIsExponentialWithJitter() {
// given
let strategy = RetryPolicyStrategy.exponentialWithJitter(
retry: 30,
jitterFactor: 0.5,
maxInterval: nil,
multiplier: 2.0,
duration: .milliseconds(10)
)

let sequence1 = RetrySequence(strategy: strategy)
let sequence2 = RetrySequence(strategy: strategy)

// when
let result1 = sequence1.map { $0 }
let result2 = sequence2.map { $0 }

// then
XCTAssertEqual(result1.count, 30)

XCTAssertNotEqual(result1, result2, "Two sequences with jitter should produce different values")

for (i, val) in result1.enumerated() {
let seconds = toSeconds(val)

let base = 0.01 * pow(2.0, Double(i))

let lower = base * 0.5
let upper = base * 1.5

XCTAssertTrue(
seconds >= lower && seconds <= upper,
"Attempt \(i): Value \(seconds) is out of bounds [\(lower), \(upper)]"
)
}
}

func test_thatRetrySequenceWorksWithoutMaxInterval_whenStrategyIsExponentialWithJitter() {
// given
let sequence = RetrySequence(
strategy: .exponentialWithJitter(
retry: 5,
jitterFactor: 0.1,
maxInterval: nil,
multiplier: 2.0,
duration: .seconds(1)
)
)

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

// then
XCTAssertEqual(result.count, 5)

for i in 1 ..< result.count {
XCTAssertGreaterThan(
result[i],
result[i - 1],
"Each delay should be greater than previous (exponential growth)"
)
}

XCTAssertGreaterThan(result[4], result[0] * 10)
}

func test_thatRetrySequenceDoesNotLimitASequence_whenStrategyIsExponentialWithJitterAndMaxIntervalIsNil() {
Expand Down Expand Up @@ -84,6 +193,12 @@ final class RetrySequenceTests: XCTestCase {
XCTAssertEqual(result[6], 64, accuracy: 8)
XCTAssertEqual(result[7], 128, accuracy: 13)
}

// MARK: Helpers

private func toSeconds(_ nanos: UInt64) -> Double {
Double(nanos) / 1_000_000_000
}
}

// MARK: - Constant
Expand Down