Skip to content

Commit 1a9611b

Browse files
committed
feat: introduce URLSession extension
1 parent 5e02152 commit 1a9611b

12 files changed

+536
-59
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
#if canImport(Darwin)
7+
import Foundation
8+
9+
public extension URLSession {
10+
/// Performs a data task with retry policy applied.
11+
///
12+
/// - Parameters:
13+
/// - request: The URL request to perform.
14+
/// - strategy: The retry strategy to apply.
15+
/// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early.
16+
/// - Returns: A tuple of `(Data, URLResponse)`.
17+
func data(
18+
for request: URLRequest,
19+
retryPolicy strategy: RetryPolicyStrategy,
20+
onFailure: (@Sendable (Error) async -> Bool)? = nil
21+
) async throws -> (Data, URLResponse) {
22+
try await RetryPolicyService(strategy: strategy).retry(
23+
strategy: nil,
24+
onFailure: onFailure
25+
) {
26+
try await self.data(for: request)
27+
}
28+
}
29+
30+
/// Performs a data task for a URL with retry policy applied.
31+
///
32+
/// - Parameters:
33+
/// - url: The URL to fetch.
34+
/// - strategy: The retry strategy to apply.
35+
/// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early.
36+
/// - Returns: A tuple of `(Data, URLResponse)`.
37+
func data(
38+
from url: URL,
39+
retryPolicy strategy: RetryPolicyStrategy,
40+
onFailure: (@Sendable (Error) async -> Bool)? = nil
41+
) async throws -> (Data, URLResponse) {
42+
try await RetryPolicyService(strategy: strategy).retry(
43+
strategy: nil,
44+
onFailure: onFailure
45+
) {
46+
try await self.data(from: url)
47+
}
48+
}
49+
50+
/// Uploads data for a request with retry policy applied.
51+
///
52+
/// - Parameters:
53+
/// - request: The URL request to use for the upload.
54+
/// - bodyData: The data to upload.
55+
/// - strategy: The retry strategy to apply.
56+
/// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early.
57+
/// - Returns: A tuple of `(Data, URLResponse)`.
58+
func upload(
59+
for request: URLRequest,
60+
from bodyData: Data,
61+
retryPolicy strategy: RetryPolicyStrategy,
62+
onFailure: (@Sendable (Error) async -> Bool)? = nil
63+
) async throws -> (Data, URLResponse) {
64+
try await RetryPolicyService(strategy: strategy).retry(
65+
strategy: nil,
66+
onFailure: onFailure
67+
) {
68+
try await self.upload(for: request, from: bodyData)
69+
}
70+
}
71+
72+
/// Downloads a file for a request with retry policy applied.
73+
///
74+
/// - Parameters:
75+
/// - request: The URL request to use for the download.
76+
/// - strategy: The retry strategy to apply.
77+
/// - delegate: A delegate that receives life cycle and authentication challenge callbacks as the transfer progresses.
78+
/// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early.
79+
/// - Returns: A tuple of `(URL, URLResponse)` where `URL` is the temporary file location.
80+
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
81+
func download(
82+
for request: URLRequest,
83+
retryPolicy strategy: RetryPolicyStrategy,
84+
delegate: (any URLSessionTaskDelegate)? = nil,
85+
onFailure: (@Sendable (Error) async -> Bool)? = nil
86+
) async throws -> (URL, URLResponse) {
87+
try await RetryPolicyService(strategy: strategy).retry(
88+
strategy: nil,
89+
onFailure: onFailure
90+
) {
91+
try await self.download(for: request, delegate: delegate)
92+
}
93+
}
94+
}
95+
#endif
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
final class Counter: @unchecked Sendable {
9+
private let lock = NSLock()
10+
private var _value: UInt = 0
11+
12+
var value: UInt {
13+
lock.withLock { _value }
14+
}
15+
16+
@discardableResult
17+
func increment() -> UInt {
18+
lock.withLock {
19+
_value += 1
20+
return _value
21+
}
22+
}
23+
24+
@discardableResult
25+
func getValue() -> UInt {
26+
lock.withLock {
27+
_value
28+
}
29+
}
30+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
#if canImport(Darwin)
7+
import Foundation
8+
9+
// MARK: - MockURLProtocol
10+
11+
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
12+
override class func canInit(with _: URLRequest) -> Bool {
13+
true
14+
}
15+
16+
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
17+
request
18+
}
19+
20+
override func startLoading() {
21+
let client = client
22+
Task {
23+
do {
24+
let (response, data) = try await MockURLProtocolHandler.shared.callHandler()
25+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
26+
client?.urlProtocol(self, didLoad: data)
27+
client?.urlProtocolDidFinishLoading(self)
28+
} catch {
29+
client?.urlProtocol(self, didFailWithError: error)
30+
}
31+
}
32+
}
33+
34+
override func stopLoading() {}
35+
}
36+
#endif
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
#if canImport(Darwin)
7+
import Foundation
8+
9+
// MARK: - MockURLProtocolHandler
10+
11+
actor MockURLProtocolHandler {
12+
typealias Handler = @Sendable () throws -> (HTTPURLResponse, Data)
13+
14+
static let shared = MockURLProtocolHandler()
15+
16+
private var handler: Handler?
17+
18+
func set(_ handler: Handler?) {
19+
self.handler = handler
20+
}
21+
22+
func callHandler() throws -> (HTTPURLResponse, Data) {
23+
guard let handler else { throw URLError(.unknown) }
24+
return try handler()
25+
}
26+
}
27+
#endif

Tests/TyphoonTests/UnitTests/RetryPolicyServiceRetryWithResultTests.swift renamed to Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,53 +16,51 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase {
1616
case fatal
1717
}
1818

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-
2919
// MARK: Tests
3020

3121
func test_retryWithResult_succeedsOnFirstAttempt() async throws {
22+
// given
3223
let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10)))
3324

25+
// when
3426
let result = try await sut.retryWithResult {
3527
42
3628
}
3729

30+
// then
3831
XCTAssertEqual(result.value, 42)
3932
XCTAssertEqual(result.attempts, 1)
4033
XCTAssertTrue(result.errors.isEmpty)
4134
XCTAssertGreaterThanOrEqual(result.totalDuration, 0)
4235
}
4336

4437
func test_retryWithResult_succeedsAfterSeveralFailures() async throws {
38+
// given
4539
let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10)))
4640

4741
let counter = Counter()
4842

43+
// when
4944
let result = try await sut.retryWithResult {
50-
await counter.increment()
51-
if await counter.count < 3 {
45+
counter.increment()
46+
if counter.value < 3 {
5247
throw TestError.transient
5348
}
5449
return "ok"
5550
}
5651

52+
// then
5753
XCTAssertEqual(result.value, "ok")
5854
XCTAssertEqual(result.attempts, 3)
5955
XCTAssertEqual(result.errors.count, 2)
6056
XCTAssertTrue(result.errors.allSatisfy { ($0 as? TestError) == .transient })
6157
}
6258

6359
func test_retryWithResult_throwsRetryLimitExceeded_whenAllAttemptsFail() async throws {
60+
// given
6461
let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10)))
6562

63+
// when
6664
do {
6765
_ = try await sut.retryWithResult {
6866
throw TestError.transient
@@ -72,102 +70,114 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase {
7270
}
7371

7472
func test_retryWithResult_stopsRetrying_whenOnFailureReturnsFalse() async throws {
73+
// given
7574
let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10)))
7675

7776
let counter = Counter()
7877

78+
// when
7979
do {
8080
_ = try await sut.retryWithResult(
8181
onFailure: { _ in false }
8282
) {
83-
await counter.increment()
83+
counter.increment()
8484
throw TestError.fatal
8585
}
8686
XCTFail("Expected error to be rethrown")
8787
} catch {
8888
XCTAssertEqual(error as? TestError, .fatal)
89-
let count = await counter.count
89+
let count = counter.value
9090
XCTAssertEqual(count, 1)
9191
}
9292
}
9393

9494
func test_retryWithResult_stopsRetrying_onSpecificError() async throws {
95+
// given
9596
let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10)))
9697

9798
let counter = Counter()
9899

100+
// when
99101
do {
100102
_ = try await sut.retryWithResult(
101103
onFailure: { error in
102104
(error as? TestError) == .transient
103105
}
104106
) {
105-
await counter.increment()
106-
let current = await counter.count
107+
counter.increment()
108+
let current = counter.value
107109
throw current == 1 ? TestError.transient : TestError.fatal
108110
}
109111
XCTFail("Expected error to be rethrown")
110112
} catch {
111113
XCTAssertEqual(error as? TestError, .fatal)
112-
let count = await counter.count
114+
let count = counter.value
113115
XCTAssertEqual(count, 2)
114116
}
115117
}
116118

117119
func test_retryWithResult_onFailureReceivesAllErrors() async throws {
120+
// given
118121
let sut = RetryPolicyService(strategy: .constant(retry: 4, dispatchDuration: .milliseconds(10)))
119122

120123
let counter = Counter()
121124
let receivedErrors = ErrorCollector()
122125

126+
// when
123127
let result = try await sut.retryWithResult(
124128
onFailure: { error in
125129
await receivedErrors.append(error)
126130
return true
127131
}
128132
) {
129-
await counter.increment()
130-
if await counter.count < 4 {
133+
counter.increment()
134+
if counter.value < 4 {
131135
throw TestError.transient
132136
}
133137
return "done"
134138
}
135139

140+
// then
136141
XCTAssertEqual(result.value, "done")
137142
let collected = await receivedErrors.errors
138143
XCTAssertEqual(collected.count, 3)
139144
XCTAssertEqual(result.errors.count, 3)
140145
}
141146

142147
func test_retryWithResult_customStrategyOverridesDefault() async throws {
148+
// given
143149
let sut = RetryPolicyService(strategy: .constant(retry: 10, dispatchDuration: .milliseconds(10)))
144150
let customStrategy = RetryPolicyStrategy.constant(retry: 2, dispatchDuration: .milliseconds(10))
145151

146152
let counter = Counter()
147153

154+
// when
148155
do {
149156
_ = try await sut.retryWithResult(strategy: customStrategy) {
150-
await counter.increment()
157+
counter.increment()
151158
throw TestError.transient
152159
}
153160
XCTFail("Expected retryLimitExceeded")
154161
} catch RetryPolicyError.retryLimitExceeded {
155-
let count = await counter.count
162+
let count = counter.value
156163
XCTAssertLessThanOrEqual(count, 3)
157164
}
158165
}
159166

160167
func test_retryWithResult_totalDurationIsNonNegative() async throws {
168+
// given
161169
let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10)))
162170

163171
let counter = Counter()
164172

173+
// when
165174
let result = try await sut.retryWithResult {
166-
await counter.increment()
167-
if await counter.count < 2 { throw TestError.transient }
175+
counter.increment()
176+
if counter.value < 2 { throw TestError.transient }
168177
return true
169178
}
170179

180+
// then
171181
XCTAssertGreaterThanOrEqual(result.totalDuration, 0)
172182
}
173183
}

0 commit comments

Comments
 (0)