Skip to content

Commit 9843462

Browse files
authored
Add Task Cancellation (#23)
2 parents a9961d2 + ab286d5 commit 9843462

3 files changed

Lines changed: 171 additions & 1 deletion

File tree

Sources/MicroClient/NetworkClient.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public actor NetworkClient: NetworkClientProtocol {
3636
var lastError: Error?
3737

3838
for attempt in 0...retryStrategy.count {
39+
try Task.checkCancellation()
40+
3941
do {
4042
return try await performRequest(networkRequest, attempt: attempt)
4143
} catch {
@@ -94,13 +96,18 @@ public actor NetworkClient: NetworkClientProtocol {
9496
for: urlRequest,
9597
delegate: nil
9698
)
99+
} catch let error as CancellationError {
100+
throw error
97101
} catch {
98102
log(.error, "Transport error: \(error.localizedDescription)")
99103
throw NetworkClientError.transportError(error)
100104
}
101105

102106
if let httpResponse = response as? HTTPURLResponse {
103-
log(.info, "Response: \(httpResponse.statusCode) \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString ?? "")")
107+
log(
108+
.info,
109+
"Response: \(httpResponse.statusCode) \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString ?? "")"
110+
)
104111
log(.debug, "Response headers: \(httpResponse.allHeaderFields)")
105112

106113
guard (200...299).contains(httpResponse.statusCode) else {
@@ -144,6 +151,8 @@ public actor NetworkClient: NetworkClientProtocol {
144151
throw NetworkClientError.responseInterceptorError(error)
145152
}
146153

154+
try Task.checkCancellation()
155+
147156
return networkResponse
148157
}
149158

Tests/MicroClientTests/Doubles/URLSessionMock.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ final class URLSessionMock: URLSessionProtocol, @unchecked Sendable {
1212
private var stubbedResponseToReturn = URLResponse()
1313
private var stubbedErrorToThrow: Error?
1414
var succeedAfter = 0
15+
var delay: TimeInterval = 0
1516

1617
// MARK: - Public
1718

@@ -22,6 +23,10 @@ final class URLSessionMock: URLSessionProtocol, @unchecked Sendable {
2223
lastRequest = request
2324
requestCount += 1
2425

26+
if delay > 0 {
27+
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
28+
}
29+
2530
if succeedAfter > 0, requestCount > succeedAfter {
2631
// Do not throw error, return stubbed data
2732
} else if let error = stubbedErrorToThrow {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import MicroClient
5+
6+
@Suite("NetworkClient Cancellation Tests")
7+
struct NetworkClientCancellationTests {
8+
9+
@Test("It should throw CancellationError when task is cancelled before request")
10+
func throwCancellationErrorWhenTaskIsCancelledBeforeRequest() async throws {
11+
// Given
12+
let mockSession = NetworkClientMother.makeMockSession()
13+
let client = NetworkClientMother.makeNetworkClient(
14+
session: mockSession
15+
)
16+
17+
let expectedURL = try #require(URL(string: "https://api.example.com/test"))
18+
mockSession.stubDataToReturn(
19+
data: Data(),
20+
response: NetworkClientMother.makeSuccessResponse(
21+
for: expectedURL
22+
)
23+
)
24+
25+
let request = NetworkRequest<VoidRequest, VoidResponse>(
26+
path: "/test",
27+
method: .get
28+
)
29+
30+
// When
31+
let task = Task {
32+
try await client.run(request)
33+
}
34+
35+
task.cancel()
36+
37+
// Then
38+
await #expect(throws: CancellationError.self) {
39+
try await task.value
40+
}
41+
}
42+
43+
@Test("It should cancel ongoing request when task is cancelled mid-flight")
44+
func cancelOngoingRequestWhenTaskIsCancelledMidFlight() async throws {
45+
// Given
46+
let mockSession = NetworkClientMother.makeMockSession()
47+
mockSession.delay = 0.1
48+
49+
let client = NetworkClientMother.makeNetworkClient(
50+
session: mockSession
51+
)
52+
53+
let expectedURL = try #require(URL(string: "https://api.example.com/test"))
54+
mockSession.stubDataToReturn(
55+
data: Data(),
56+
response: NetworkClientMother.makeSuccessResponse(
57+
for: expectedURL
58+
)
59+
)
60+
61+
let request = NetworkRequest<VoidRequest, VoidResponse>(
62+
path: "/test",
63+
method: .get
64+
)
65+
66+
// When
67+
let task = Task {
68+
try await client.run(request)
69+
}
70+
71+
try await Task.sleep(nanoseconds: 10_000_000)
72+
task.cancel()
73+
74+
// Then
75+
await #expect(throws: CancellationError.self) {
76+
try await task.value
77+
}
78+
}
79+
80+
@Test("It should cancel request during retry attempts")
81+
func cancelRequestDuringRetryAttempts() async throws {
82+
// Given
83+
let mockSession = NetworkClientMother.makeMockSession()
84+
mockSession.delay = 0.05
85+
mockSession.stubDataToThrow(
86+
error: URLError(.networkConnectionLost)
87+
)
88+
89+
let retryStrategy = RetryStrategy.retry(count: 5)
90+
let client = NetworkClientMother.makeNetworkClient(
91+
session: mockSession,
92+
retryStrategy: retryStrategy
93+
)
94+
95+
let request = NetworkRequest<VoidRequest, VoidResponse>(
96+
path: "/test",
97+
method: .get
98+
)
99+
100+
// When
101+
let task = Task {
102+
try await client.run(request)
103+
}
104+
105+
try await Task.sleep(nanoseconds: 60_000_000)
106+
task.cancel()
107+
108+
// Then
109+
await #expect(throws: CancellationError.self) {
110+
try await task.value
111+
}
112+
113+
#expect(
114+
mockSession.requestCount < retryStrategy.count + 1,
115+
"It should not complete all retry attempts"
116+
)
117+
}
118+
119+
@Test("It should complete successfully if not cancelled")
120+
func completeSuccessfullyIfNotCancelled() async throws {
121+
// Given
122+
let mockSession = NetworkClientMother.makeMockSession()
123+
mockSession.delay = 0.01
124+
125+
let client = NetworkClientMother.makeNetworkClient(
126+
session: mockSession
127+
)
128+
129+
let expectedURL = try #require(URL(string: "https://api.example.com/test"))
130+
mockSession.stubDataToReturn(
131+
data: Data(),
132+
response: NetworkClientMother.makeSuccessResponse(
133+
for: expectedURL
134+
)
135+
)
136+
137+
let request = NetworkRequest<VoidRequest, VoidResponse>(
138+
path: "/test",
139+
method: .get
140+
)
141+
142+
// When
143+
let response = try await client.run(request)
144+
145+
// Then
146+
#expect(
147+
type(of: response.value) == VoidResponse.self,
148+
"It should return VoidResponse"
149+
)
150+
151+
#expect(
152+
mockSession.requestCount == 1,
153+
"It should make exactly one request"
154+
)
155+
}
156+
}

0 commit comments

Comments
 (0)