Skip to content

Commit 07119a8

Browse files
authored
feat: add retryWithResult for detailed retry results (#76)
1 parent 42c5d85 commit 07119a8

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
/// Represents the result of executing a closure with a retry policy.
9+
public struct RetryResult<T> {
10+
/// The successfully returned value from the closure.
11+
public let value: T
12+
13+
/// The number of retry attempts performed.
14+
public let attempts: UInt
15+
16+
/// Total duration spent on all retry attempts, in seconds.
17+
public let totalDuration: TimeInterval
18+
19+
/// List of errors encountered during each failed attempt.
20+
public let errors: [Error]
21+
}

Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ public protocol IRetryPolicyService: Sendable {
2222
onFailure: (@Sendable (Error) async -> Bool)?,
2323
_ closure: @Sendable () async throws -> T
2424
) async throws -> T
25+
26+
/// Retries a closure and returns a detailed `RetryResult` including success/failure info.
27+
///
28+
/// - Parameters:
29+
/// - strategy: Optional strategy that defines the retry behavior.
30+
/// - onFailure: Optional closure called on each failure; returning `true` stops retries.
31+
/// - closure: The async closure to be retried according to the strategy.
32+
///
33+
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
34+
func retryWithResult<T>(
35+
strategy: RetryPolicyStrategy?,
36+
onFailure: (@Sendable (Error) async -> Bool)?,
37+
_ closure: @Sendable () async throws -> T
38+
) async throws -> RetryResult<T>
2539
}
2640

2741
public extension IRetryPolicyService {
@@ -56,4 +70,30 @@ public extension IRetryPolicyService {
5670
func retry<T>(_ closure: @Sendable () async throws -> T, onFailure: (@Sendable (Error) async -> Bool)?) async throws -> T {
5771
try await retry(strategy: nil, onFailure: onFailure, closure)
5872
}
73+
74+
/// Retries a closure and returns a detailed `RetryResult` including success/failure info.
75+
///
76+
/// - Parameters:
77+
/// - closure: The async closure to be retried according to the strategy.
78+
///
79+
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
80+
func retryWithResult<T>(
81+
_ closure: @Sendable () async throws -> T
82+
) async throws -> RetryResult<T> {
83+
try await retryWithResult(strategy: nil, onFailure: nil, closure)
84+
}
85+
86+
/// Retries a closure and returns a detailed `RetryResult` including success/failure info.
87+
///
88+
/// - Parameters:
89+
/// - onFailure: Optional closure called on each failure; returning `true` stops retries.
90+
/// - closure: The async closure to be retried according to the strategy.
91+
///
92+
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
93+
func retryWithResult<T>(
94+
onFailure: (@Sendable (Error) async -> Bool)?,
95+
_ closure: @Sendable () async throws -> T
96+
) async throws -> RetryResult<T> {
97+
try await retryWithResult(strategy: nil, onFailure: onFailure, closure)
98+
}
5999
}

Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,63 @@ extension RetryPolicyService: IRetryPolicyService {
120120
}
121121
}
122122
}
123+
124+
/// Retries a closure and returns a detailed `RetryResult` including success/failure info.
125+
///
126+
/// - Parameters:
127+
/// - strategy: Optional strategy that defines the retry behavior.
128+
/// - onFailure: Optional closure called on each failure; returning `true` stops retries.
129+
/// - closure: The async closure to be retried according to the strategy.
130+
///
131+
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
132+
public func retryWithResult<T>(
133+
strategy: RetryPolicyStrategy? = nil,
134+
onFailure: (@Sendable (Error) async -> Bool)? = nil,
135+
_ closure: @Sendable () async throws -> T
136+
) async throws -> RetryResult<T> {
137+
let state = State()
138+
let startTime = Date()
139+
140+
let value = try await retry(
141+
strategy: strategy,
142+
onFailure: { error in
143+
await state.recordError(error)
144+
return await onFailure?(error) ?? true
145+
}, {
146+
await state.recordAttempt()
147+
return try await closure()
148+
}
149+
)
150+
151+
return await RetryResult(
152+
value: value,
153+
attempts: state.attempts,
154+
totalDuration: Date().timeIntervalSince(startTime),
155+
errors: state.errors
156+
)
157+
}
158+
}
159+
160+
// MARK: RetryPolicyService.State
161+
162+
extension RetryPolicyService {
163+
/// Internal actor to track retry attempts and errors in a thread-safe manner.
164+
private actor State {
165+
/// Number of attempts performed so far.
166+
var attempts: UInt = 0
167+
168+
/// List of errors encountered during retry attempts.
169+
var errors: [Error] = []
170+
171+
/// Increments the attempt count by one.
172+
func recordAttempt() {
173+
attempts += 1
174+
}
175+
176+
/// Records an error from a failed attempt.
177+
/// - Parameter error: The error to record.
178+
func recordError(_ error: Error) {
179+
errors.append(error)
180+
}
181+
}
123182
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
@testable import Typhoon
7+
import XCTest
8+
9+
// MARK: - RetryPolicyServiceRetryWithResultTests
10+
11+
final class RetryPolicyServiceRetryWithResultTests: XCTestCase {
12+
// MARK: - Properties
13+
14+
private enum TestError: Error, Equatable {
15+
case transient
16+
case fatal
17+
}
18+
19+
// MARK: - Counter
20+
21+
private actor Counter {
22+
private(set) var count: Int = 0
23+
24+
func increment() {
25+
count += 1
26+
}
27+
}
28+
29+
// MARK: Tests
30+
31+
func test_retryWithResult_succeedsOnFirstAttempt() async throws {
32+
let sut = RetryPolicyService(strategy: .constant(retry: 3, duration: .milliseconds(10)))
33+
34+
let result = try await sut.retryWithResult {
35+
42
36+
}
37+
38+
XCTAssertEqual(result.value, 42)
39+
XCTAssertEqual(result.attempts, 1)
40+
XCTAssertTrue(result.errors.isEmpty)
41+
XCTAssertGreaterThanOrEqual(result.totalDuration, 0)
42+
}
43+
44+
func test_retryWithResult_succeedsAfterSeveralFailures() async throws {
45+
let sut = RetryPolicyService(strategy: .constant(retry: 5, duration: .milliseconds(10)))
46+
47+
let counter = Counter()
48+
49+
let result = try await sut.retryWithResult {
50+
await counter.increment()
51+
if await counter.count < 3 {
52+
throw TestError.transient
53+
}
54+
return "ok"
55+
}
56+
57+
XCTAssertEqual(result.value, "ok")
58+
XCTAssertEqual(result.attempts, 3)
59+
XCTAssertEqual(result.errors.count, 2)
60+
XCTAssertTrue(result.errors.allSatisfy { ($0 as? TestError) == .transient })
61+
}
62+
63+
func test_retryWithResult_throwsRetryLimitExceeded_whenAllAttemptsFail() async throws {
64+
let sut = RetryPolicyService(strategy: .constant(retry: 3, duration: .milliseconds(10)))
65+
66+
do {
67+
_ = try await sut.retryWithResult {
68+
throw TestError.transient
69+
}
70+
XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown")
71+
} catch RetryPolicyError.retryLimitExceeded {}
72+
}
73+
74+
func test_retryWithResult_stopsRetrying_whenOnFailureReturnsFalse() async throws {
75+
let sut = RetryPolicyService(strategy: .constant(retry: 5, duration: .milliseconds(10)))
76+
77+
let counter = Counter()
78+
79+
do {
80+
_ = try await sut.retryWithResult(
81+
onFailure: { _ in false }
82+
) {
83+
await counter.increment()
84+
throw TestError.fatal
85+
}
86+
XCTFail("Expected error to be rethrown")
87+
} catch {
88+
XCTAssertEqual(error as? TestError, .fatal)
89+
let count = await counter.count
90+
XCTAssertEqual(count, 1)
91+
}
92+
}
93+
94+
func test_retryWithResult_stopsRetrying_onSpecificError() async throws {
95+
let sut = RetryPolicyService(strategy: .constant(retry: 5, duration: .milliseconds(10)))
96+
97+
let counter = Counter()
98+
99+
do {
100+
_ = try await sut.retryWithResult(
101+
onFailure: { error in
102+
(error as? TestError) == .transient
103+
}
104+
) {
105+
await counter.increment()
106+
let current = await counter.count
107+
throw current == 1 ? TestError.transient : TestError.fatal
108+
}
109+
XCTFail("Expected error to be rethrown")
110+
} catch {
111+
XCTAssertEqual(error as? TestError, .fatal)
112+
let count = await counter.count
113+
XCTAssertEqual(count, 2)
114+
}
115+
}
116+
117+
func test_retryWithResult_onFailureReceivesAllErrors() async throws {
118+
let sut = RetryPolicyService(strategy: .constant(retry: 4, duration: .milliseconds(10)))
119+
120+
let counter = Counter()
121+
let receivedErrors = ErrorCollector()
122+
123+
let result = try await sut.retryWithResult(
124+
onFailure: { error in
125+
await receivedErrors.append(error)
126+
return true
127+
}
128+
) {
129+
await counter.increment()
130+
if await counter.count < 4 {
131+
throw TestError.transient
132+
}
133+
return "done"
134+
}
135+
136+
XCTAssertEqual(result.value, "done")
137+
let collected = await receivedErrors.errors
138+
XCTAssertEqual(collected.count, 3)
139+
XCTAssertEqual(result.errors.count, 3)
140+
}
141+
142+
func test_retryWithResult_customStrategyOverridesDefault() async throws {
143+
let sut = RetryPolicyService(strategy: .constant(retry: 10, duration: .milliseconds(10)))
144+
let customStrategy = RetryPolicyStrategy.constant(retry: 2, duration: .milliseconds(10))
145+
146+
let counter = Counter()
147+
148+
do {
149+
_ = try await sut.retryWithResult(strategy: customStrategy) {
150+
await counter.increment()
151+
throw TestError.transient
152+
}
153+
XCTFail("Expected retryLimitExceeded")
154+
} catch RetryPolicyError.retryLimitExceeded {
155+
let count = await counter.count
156+
XCTAssertLessThanOrEqual(count, 3)
157+
}
158+
}
159+
160+
func test_retryWithResult_totalDurationIsNonNegative() async throws {
161+
let sut = RetryPolicyService(strategy: .constant(retry: 3, duration: .milliseconds(10)))
162+
163+
let counter = Counter()
164+
165+
let result = try await sut.retryWithResult {
166+
await counter.increment()
167+
if await counter.count < 2 { throw TestError.transient }
168+
return true
169+
}
170+
171+
XCTAssertGreaterThanOrEqual(result.totalDuration, 0)
172+
}
173+
}
174+
175+
// MARK: - ErrorCollector
176+
177+
/// Actor-based collector to safely accumulate errors across concurrent closures.
178+
private actor ErrorCollector {
179+
private(set) var errors: [Error] = []
180+
181+
func append(_ error: Error) {
182+
errors.append(error)
183+
}
184+
}

0 commit comments

Comments
 (0)