Skip to content

Commit 46cc88a

Browse files
authored
chore(retry): add optional max duration (#75)
1 parent ce00ad5 commit 46cc88a

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

Sources/Typhoon/Classes/Model/RetryPolicyError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ import Foundation
99
public enum RetryPolicyError: Error {
1010
/// The retry limit for attempts to perform a request has been exceeded.
1111
case retryLimitExceeded
12+
/// Thrown when the total allowed duration for retries has been exceeded.
13+
case totalDurationExceeded
1214
}

Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,21 @@ public final class RetryPolicyService {
5656
/// The strategy defining the behavior of the retry policy.
5757
private let strategy: RetryPolicyStrategy
5858

59+
/// Optional maximum total duration allowed for all retry attempts.
60+
private let maxTotalDuration: DispatchTimeInterval?
61+
5962
// MARK: Initialization
6063

61-
/// Creates a new `RetryPolicyService` instance.
64+
/// Initializes a new instance of `RetryPolicyService`.
6265
///
63-
/// - Parameter strategy: The strategy defining the behavior of the retry policy.
64-
public init(strategy: RetryPolicyStrategy) {
66+
/// - Parameters:
67+
/// - strategy: The strategy that determines how retries are performed.
68+
/// - maxTotalDuration: Optional maximum duration for all retries combined. If `nil`,
69+
/// retries can continue indefinitely based on the
70+
/// strategy.
71+
public init(strategy: RetryPolicyStrategy, maxTotalDuration: DispatchTimeInterval? = nil) {
6572
self.strategy = strategy
73+
self.maxTotalDuration = maxTotalDuration
6674
}
6775
}
6876

@@ -86,7 +94,13 @@ extension RetryPolicyService: IRetryPolicyService {
8694

8795
var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator()
8896

97+
let deadline = maxTotalDuration?.double.map { Date().addingTimeInterval($0) }
98+
8999
while true {
100+
if let deadline, Date() > deadline {
101+
throw RetryPolicyError.totalDurationExceeded
102+
}
103+
90104
do {
91105
return try await closure()
92106
} catch {

Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,109 @@ final class RetryPolicyServiceTests: XCTestCase {
257257
let lastError = await errorContainer.getError()
258258
XCTAssertNotNil(lastError)
259259
}
260+
261+
// MARK: Tests - Max Total Duration
262+
263+
func test_thatRetryThrowsTotalDurationExceededError_whenDeadlineIsExceeded() async throws {
264+
// given
265+
let slowService = RetryPolicyService(
266+
strategy: .constant(retry: 10, duration: .milliseconds(200)),
267+
maxTotalDuration: .milliseconds(300)
268+
)
269+
270+
// when
271+
var receivedError: Error?
272+
do {
273+
_ = try await slowService.retry {
274+
throw URLError(.unknown)
275+
}
276+
} catch {
277+
receivedError = error
278+
}
279+
280+
// then
281+
XCTAssertEqual(receivedError as? RetryPolicyError, .totalDurationExceeded)
282+
}
283+
284+
func test_thatRetrySucceeds_whenOperationCompletesBeforeDeadline() async throws {
285+
// given
286+
let expectedValue = 42
287+
let service = RetryPolicyService(
288+
strategy: .constant(retry: 5, duration: .nanoseconds(1)),
289+
maxTotalDuration: .seconds(5)
290+
)
291+
292+
// when
293+
let result = try await service.retry {
294+
expectedValue
295+
}
296+
297+
// then
298+
XCTAssertEqual(result, expectedValue)
299+
}
300+
301+
func test_thatRetrySucceedsAfterRetries_whenDeadlineIsNotExceeded() async throws {
302+
// given
303+
let counter = Counter()
304+
let expectedValue = 99
305+
let service = RetryPolicyService(
306+
strategy: .constant(retry: 5, duration: .nanoseconds(1)),
307+
maxTotalDuration: .seconds(5)
308+
)
309+
310+
// when
311+
let result = try await service.retry {
312+
let count = await counter.increment()
313+
if count >= 3 { return expectedValue }
314+
throw URLError(.unknown)
315+
}
316+
317+
// then
318+
XCTAssertEqual(result, expectedValue)
319+
}
320+
321+
func test_thatRetryThrowsTotalDurationExceeded_notRetryLimitExceeded_whenDeadlineHitsFirst() async throws {
322+
// given
323+
let service = RetryPolicyService(
324+
strategy: .constant(retry: 100, duration: .milliseconds(100)),
325+
maxTotalDuration: .milliseconds(250)
326+
)
327+
328+
// when
329+
var receivedError: Error?
330+
do {
331+
_ = try await service.retry {
332+
throw URLError(.unknown)
333+
}
334+
} catch {
335+
receivedError = error
336+
}
337+
338+
// then
339+
XCTAssertEqual(receivedError as? RetryPolicyError, .totalDurationExceeded)
340+
XCTAssertNotEqual(receivedError as? RetryPolicyError, .retryLimitExceeded)
341+
}
342+
343+
func test_thatRetryIgnoresDeadline_whenMaxTotalDurationIsNil() async throws {
344+
// given
345+
let counter = Counter()
346+
let service = RetryPolicyService(
347+
strategy: .constant(retry: .defaultRetryCount, duration: .nanoseconds(1)),
348+
maxTotalDuration: nil
349+
)
350+
351+
// when
352+
do {
353+
_ = try await service.retry {
354+
_ = await counter.increment()
355+
throw URLError(.unknown)
356+
}
357+
} catch {}
358+
359+
// then
360+
let attempts = await counter.getValue()
361+
XCTAssertEqual(attempts, .defaultRetryCount + 1)
362+
}
260363
}
261364

262365
// MARK: - Counter

0 commit comments

Comments
 (0)