Skip to content

Commit a9c3cfb

Browse files
dnadobaLukasa
andauthored
Report last connection error if request deadline is exceeded with async/await API (#608)
Co-authored-by: Cory Benfield <[email protected]>
1 parent 3960678 commit a9c3cfb

6 files changed

+176
-17
lines changed

Sources/AsyncHTTPClient/AsyncAwait/Transaction+StateMachine.swift

+35-11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ extension Transaction {
2828
private enum State {
2929
case initialized(CheckedContinuation<HTTPClientResponse, Error>)
3030
case queued(CheckedContinuation<HTTPClientResponse, Error>, HTTPRequestScheduler)
31+
case deadlineExceededWhileQueued(CheckedContinuation<HTTPClientResponse, Error>)
3132
case executing(ExecutionContext, RequestStreamState, ResponseStreamState)
3233
case finished(error: Error?, HTTPClientResponse.Body.IteratorStream.ID?)
3334
}
@@ -105,7 +106,20 @@ extension Transaction {
105106
case .queued(let continuation, let scheduler):
106107
self.state = .finished(error: error, nil)
107108
return .failResponseHead(continuation, error, scheduler, nil, bodyStreamContinuation: nil)
108-
109+
case .deadlineExceededWhileQueued(let continuation):
110+
let realError: Error = {
111+
if (error as? HTTPClientError) == .cancelled {
112+
/// if we just get a `HTTPClientError.cancelled` we can use the original cancellation reason
113+
/// to give a more descriptive error to the user.
114+
return HTTPClientError.deadlineExceeded
115+
} else {
116+
/// otherwise we already had an intermediate connection error which we should present to the user instead
117+
return error
118+
}
119+
}()
120+
121+
self.state = .finished(error: realError, nil)
122+
return .failResponseHead(continuation, realError, nil, nil, bodyStreamContinuation: nil)
109123
case .executing(let context, let requestStreamState, .waitingForResponseHead):
110124
switch requestStreamState {
111125
case .paused(continuation: .some(let continuation)):
@@ -178,6 +192,7 @@ extension Transaction {
178192

179193
enum StartExecutionAction {
180194
case cancel(HTTPRequestExecutor)
195+
case cancelAndFail(HTTPRequestExecutor, CheckedContinuation<HTTPClientResponse, Error>, with: Error)
181196
case none
182197
}
183198

@@ -191,6 +206,8 @@ extension Transaction {
191206
)
192207
self.state = .executing(context, .requestHeadSent, .waitingForResponseHead)
193208
return .none
209+
case .deadlineExceededWhileQueued(let continuation):
210+
return .cancelAndFail(executor, continuation, with: HTTPClientError.deadlineExceeded)
194211

195212
case .finished(error: .some, .none):
196213
return .cancel(executor)
@@ -210,7 +227,7 @@ extension Transaction {
210227

211228
mutating func resumeRequestBodyStream() -> ResumeProducingAction {
212229
switch self.state {
213-
case .initialized, .queued:
230+
case .initialized, .queued, .deadlineExceededWhileQueued:
214231
preconditionFailure("Received a resumeBodyRequest on a request, that isn't executing. Invalid state: \(self.state)")
215232

216233
case .executing(let context, .requestHeadSent, let responseState):
@@ -246,6 +263,7 @@ extension Transaction {
246263
switch self.state {
247264
case .initialized,
248265
.queued,
266+
.deadlineExceededWhileQueued,
249267
.executing(_, .requestHeadSent, _):
250268
preconditionFailure("A request stream can only be resumed, if the request was started")
251269

@@ -271,6 +289,7 @@ extension Transaction {
271289
switch self.state {
272290
case .initialized,
273291
.queued,
292+
.deadlineExceededWhileQueued,
274293
.executing(_, .requestHeadSent, _):
275294
preconditionFailure("A request stream can only produce, if the request was started. Invalid state: \(self.state)")
276295

@@ -301,6 +320,7 @@ extension Transaction {
301320
switch self.state {
302321
case .initialized,
303322
.queued,
323+
.deadlineExceededWhileQueued,
304324
.executing(_, .requestHeadSent, _),
305325
.executing(_, .finished, _):
306326
preconditionFailure("A request stream can only produce, if the request was started. Invalid state: \(self.state)")
@@ -334,6 +354,7 @@ extension Transaction {
334354
switch self.state {
335355
case .initialized,
336356
.queued,
357+
.deadlineExceededWhileQueued,
337358
.executing(_, .finished, _):
338359
preconditionFailure("Invalid state: \(self.state)")
339360

@@ -372,6 +393,7 @@ extension Transaction {
372393
switch self.state {
373394
case .initialized,
374395
.queued,
396+
.deadlineExceededWhileQueued,
375397
.executing(_, _, .waitingForResponseIterator),
376398
.executing(_, _, .buffering),
377399
.executing(_, _, .waitingForRemote):
@@ -401,7 +423,7 @@ extension Transaction {
401423

402424
mutating func receiveResponseBodyParts(_ buffer: CircularBuffer<ByteBuffer>) -> ReceiveResponsePartAction {
403425
switch self.state {
404-
case .initialized, .queued:
426+
case .initialized, .queued, .deadlineExceededWhileQueued:
405427
preconditionFailure("Received a response body part, but request hasn't started yet. Invalid state: \(self.state)")
406428

407429
case .executing(_, _, .waitingForResponseHead):
@@ -457,6 +479,7 @@ extension Transaction {
457479
switch self.state {
458480
case .initialized,
459481
.queued,
482+
.deadlineExceededWhileQueued,
460483
.executing(_, _, .waitingForResponseHead):
461484
preconditionFailure("Got notice about a deinited response, before we even received a response. Invalid state: \(self.state)")
462485

@@ -486,7 +509,7 @@ extension Transaction {
486509

487510
mutating func responseBodyIteratorDeinited(streamID: HTTPClientResponse.Body.IteratorStream.ID) -> FailAction {
488511
switch self.state {
489-
case .initialized, .queued, .executing(_, _, .waitingForResponseHead):
512+
case .initialized, .queued, .deadlineExceededWhileQueued, .executing(_, _, .waitingForResponseHead):
490513
preconditionFailure("Got notice about a deinited response body iterator, before we even received a response. Invalid state: \(self.state)")
491514

492515
case .executing(_, _, .buffering(let registeredStreamID, _, next: _)),
@@ -516,6 +539,7 @@ extension Transaction {
516539
switch self.state {
517540
case .initialized,
518541
.queued,
542+
.deadlineExceededWhileQueued,
519543
.executing(_, _, .waitingForResponseHead):
520544
preconditionFailure("If we receive a response body, we must have received a head before")
521545

@@ -635,6 +659,7 @@ extension Transaction {
635659
switch self.state {
636660
case .initialized,
637661
.queued,
662+
.deadlineExceededWhileQueued,
638663
.executing(_, _, .waitingForResponseHead):
639664
preconditionFailure("Received no response head, but received a response end. Invalid state: \(self.state)")
640665

@@ -677,6 +702,7 @@ extension Transaction {
677702

678703
enum DeadlineExceededAction {
679704
case none
705+
case cancelSchedulerOnly(scheduler: HTTPRequestScheduler)
680706
/// fail response before head received. scheduler and executor are exclusive here.
681707
case cancel(
682708
requestContinuation: CheckedContinuation<HTTPClientResponse, Error>,
@@ -699,14 +725,12 @@ extension Transaction {
699725
)
700726

701727
case .queued(let continuation, let scheduler):
702-
self.state = .finished(error: error, nil)
703-
return .cancel(
704-
requestContinuation: continuation,
705-
scheduler: scheduler,
706-
executor: nil,
707-
bodyStreamContinuation: nil
728+
self.state = .deadlineExceededWhileQueued(continuation)
729+
return .cancelSchedulerOnly(
730+
scheduler: scheduler
708731
)
709-
732+
case .deadlineExceededWhileQueued:
733+
return .none
710734
case .executing(let context, let requestStreamState, .waitingForResponseHead):
711735
switch requestStreamState {
712736
case .paused(continuation: .some(let continuation)):

Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ extension Transaction: HTTPExecutableRequest {
174174
switch action {
175175
case .cancel(let executor):
176176
executor.cancelRequest(self)
177-
177+
case .cancelAndFail(let executor, let continuation, with: let error):
178+
executor.cancelRequest(self)
179+
continuation.resume(throwing: error)
178180
case .none:
179181
break
180182
}
@@ -309,7 +311,8 @@ extension Transaction: HTTPExecutableRequest {
309311
scheduler?.cancelRequest(self)
310312
executor?.cancelRequest(self)
311313
bodyStreamContinuation?.resume(throwing: HTTPClientError.deadlineExceeded)
312-
314+
case .cancelSchedulerOnly(scheduler: let scheduler):
315+
scheduler.cancelRequest(self)
313316
case .none:
314317
break
315318
}

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ extension AsyncAwaitEndToEndTests {
3838
("testCanceling", testCanceling),
3939
("testDeadline", testDeadline),
4040
("testImmediateDeadline", testImmediateDeadline),
41+
("testSelfSignedCertificateIsRejectedWithCorrectErrorIfRequestDeadlineIsExceeded", testSelfSignedCertificateIsRejectedWithCorrectErrorIfRequestDeadlineIsExceeded),
4142
("testInvalidURL", testInvalidURL),
4243
("testRedirectChangesHostHeader", testRedirectChangesHostHeader),
4344
("testShutdown", testShutdown),

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift

+48
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import Logging
1717
import NIOCore
1818
import NIOPosix
19+
import NIOSSL
1920
import XCTest
2021

2122
private func makeDefaultHTTPClient(
@@ -393,6 +394,53 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
393394
#endif
394395
}
395396

397+
func testSelfSignedCertificateIsRejectedWithCorrectErrorIfRequestDeadlineIsExceeded() {
398+
#if compiler(>=5.5.2) && canImport(_Concurrency)
399+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
400+
XCTAsyncTest(timeout: 5) {
401+
/// key + cert was created with the follwing command:
402+
/// openssl req -x509 -newkey rsa:4096 -keyout self_signed_key.pem -out self_signed_cert.pem -sha256 -days 99999 -nodes -subj '/CN=localhost'
403+
let certPath = Bundle.module.path(forResource: "self_signed_cert", ofType: "pem")!
404+
let keyPath = Bundle.module.path(forResource: "self_signed_key", ofType: "pem")!
405+
let configuration = TLSConfiguration.makeServerConfiguration(
406+
certificateChain: try NIOSSLCertificate.fromPEMFile(certPath).map { .certificate($0) },
407+
privateKey: .file(keyPath)
408+
)
409+
let sslContext = try NIOSSLContext(configuration: configuration)
410+
let serverGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
411+
defer { XCTAssertNoThrow(try serverGroup.syncShutdownGracefully()) }
412+
let server = ServerBootstrap(group: serverGroup)
413+
.childChannelInitializer { channel in
414+
channel.pipeline.addHandler(NIOSSLServerHandler(context: sslContext))
415+
}
416+
let serverChannel = try server.bind(host: "localhost", port: 0).wait()
417+
defer { XCTAssertNoThrow(try serverChannel.close().wait()) }
418+
let port = serverChannel.localAddress!.port!
419+
420+
var config = HTTPClient.Configuration()
421+
config.timeout.connect = .seconds(3)
422+
let localClient = HTTPClient(eventLoopGroupProvider: .createNew, configuration: config)
423+
defer { XCTAssertNoThrow(try localClient.syncShutdown()) }
424+
let request = HTTPClientRequest(url: "https://localhost:\(port)")
425+
await XCTAssertThrowsError(try await localClient.execute(request, deadline: .now() + .seconds(2))) { error in
426+
#if canImport(Network)
427+
guard let nwTLSError = error as? HTTPClient.NWTLSError else {
428+
XCTFail("could not cast \(error) of type \(type(of: error)) to \(HTTPClient.NWTLSError.self)")
429+
return
430+
}
431+
XCTAssertEqual(nwTLSError.status, errSSLBadCert, "unexpected tls error: \(nwTLSError)")
432+
#else
433+
guard let sslError = error as? NIOSSLError,
434+
case .handshakeFailed(.sslError) = sslError else {
435+
XCTFail("unexpected error \(error)")
436+
return
437+
}
438+
#endif
439+
}
440+
}
441+
#endif
442+
}
443+
396444
func testInvalidURL() {
397445
#if compiler(>=5.5.2) && canImport(_Concurrency)
398446
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }

Tests/AsyncHTTPClientTests/Transaction+StateMachineTests+XCTest.swift

+2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ extension Transaction_StateMachineTests {
2828
("testRequestWasQueuedAfterWillExecuteRequestWasCalled", testRequestWasQueuedAfterWillExecuteRequestWasCalled),
2929
("testRequestBodyStreamWasPaused", testRequestBodyStreamWasPaused),
3030
("testQueuedRequestGetsRemovedWhenDeadlineExceeded", testQueuedRequestGetsRemovedWhenDeadlineExceeded),
31+
("testDeadlineExceededAndFullyFailedRequestCanBeCanceledWithNoEffect", testDeadlineExceededAndFullyFailedRequestCanBeCanceledWithNoEffect),
3132
("testScheduledRequestGetsRemovedWhenDeadlineExceeded", testScheduledRequestGetsRemovedWhenDeadlineExceeded),
33+
("testDeadlineExceededRaceWithRequestWillExecute", testDeadlineExceededRaceWithRequestWillExecute),
3234
("testRequestWithHeadReceivedGetNotCancelledWhenDeadlineExceeded", testRequestWithHeadReceivedGetNotCancelledWhenDeadlineExceeded),
3335
]
3436
}

Tests/AsyncHTTPClientTests/Transaction+StateMachineTests.swift

+85-4
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ final class Transaction_StateMachineTests: XCTestCase {
7373
}
7474

7575
func testQueuedRequestGetsRemovedWhenDeadlineExceeded() {
76+
struct MyError: Error, Equatable {}
7677
#if compiler(>=5.5.2) && canImport(_Concurrency)
7778
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
7879
XCTAsyncTest {
@@ -82,16 +83,62 @@ final class Transaction_StateMachineTests: XCTestCase {
8283

8384
state.requestWasQueued(queuer)
8485

85-
let failAction = state.deadlineExceeded()
86-
guard case .cancel(let continuation, let scheduler, nil, nil) = failAction else {
86+
let deadlineExceededAction = state.deadlineExceeded()
87+
guard case .cancelSchedulerOnly(let scheduler) = deadlineExceededAction else {
88+
return XCTFail("Unexpected fail action: \(deadlineExceededAction)")
89+
}
90+
XCTAssertIdentical(scheduler as? MockTaskQueuer, queuer)
91+
92+
let failAction = state.fail(MyError())
93+
guard case .failResponseHead(let continuation, let error, nil, nil, bodyStreamContinuation: nil) = failAction else {
8794
return XCTFail("Unexpected fail action: \(failAction)")
8895
}
8996
XCTAssertIdentical(scheduler as? MockTaskQueuer, queuer)
9097

91-
continuation.resume(throwing: HTTPClientError.deadlineExceeded)
98+
continuation.resume(throwing: error)
9299
}
93100

94-
await XCTAssertThrowsError(try await withCheckedThrowingContinuation(workaround))
101+
await XCTAssertThrowsError(try await withCheckedThrowingContinuation(workaround)) {
102+
XCTAssertEqualTypeAndValue($0, MyError())
103+
}
104+
}
105+
#endif
106+
}
107+
108+
func testDeadlineExceededAndFullyFailedRequestCanBeCanceledWithNoEffect() {
109+
struct MyError: Error, Equatable {}
110+
#if compiler(>=5.5.2) && canImport(_Concurrency)
111+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
112+
XCTAsyncTest {
113+
func workaround(_ continuation: CheckedContinuation<HTTPClientResponse, Error>) {
114+
var state = Transaction.StateMachine(continuation)
115+
let queuer = MockTaskQueuer()
116+
117+
state.requestWasQueued(queuer)
118+
119+
let deadlineExceededAction = state.deadlineExceeded()
120+
guard case .cancelSchedulerOnly(let scheduler) = deadlineExceededAction else {
121+
return XCTFail("Unexpected fail action: \(deadlineExceededAction)")
122+
}
123+
XCTAssertIdentical(scheduler as? MockTaskQueuer, queuer)
124+
125+
let failAction = state.fail(MyError())
126+
guard case .failResponseHead(let continuation, let error, nil, nil, bodyStreamContinuation: nil) = failAction else {
127+
return XCTFail("Unexpected fail action: \(failAction)")
128+
}
129+
XCTAssertIdentical(scheduler as? MockTaskQueuer, queuer)
130+
131+
let secondFailAction = state.fail(HTTPClientError.cancelled)
132+
guard case .none = secondFailAction else {
133+
return XCTFail("Unexpected fail action: \(secondFailAction)")
134+
}
135+
136+
continuation.resume(throwing: error)
137+
}
138+
139+
await XCTAssertThrowsError(try await withCheckedThrowingContinuation(workaround)) {
140+
XCTAssertEqualTypeAndValue($0, MyError())
141+
}
95142
}
96143
#endif
97144
}
@@ -123,6 +170,40 @@ final class Transaction_StateMachineTests: XCTestCase {
123170
#endif
124171
}
125172

173+
func testDeadlineExceededRaceWithRequestWillExecute() {
174+
#if compiler(>=5.5.2) && canImport(_Concurrency)
175+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
176+
let eventLoop = EmbeddedEventLoop()
177+
XCTAsyncTest {
178+
func workaround(_ continuation: CheckedContinuation<HTTPClientResponse, Error>) {
179+
var state = Transaction.StateMachine(continuation)
180+
let expectedExecutor = MockRequestExecutor(eventLoop: eventLoop)
181+
let queuer = MockTaskQueuer()
182+
183+
state.requestWasQueued(queuer)
184+
185+
let deadlineExceededAction = state.deadlineExceeded()
186+
guard case .cancelSchedulerOnly(let scheduler) = deadlineExceededAction else {
187+
return XCTFail("Unexpected fail action: \(deadlineExceededAction)")
188+
}
189+
XCTAssertIdentical(scheduler as? MockTaskQueuer, queuer)
190+
191+
let failAction = state.willExecuteRequest(expectedExecutor)
192+
guard case .cancelAndFail(let returnedExecutor, let continuation, with: let error) = failAction else {
193+
return XCTFail("Unexpected fail action: \(failAction)")
194+
}
195+
XCTAssertIdentical(returnedExecutor as? MockRequestExecutor, expectedExecutor)
196+
197+
continuation.resume(throwing: error)
198+
}
199+
200+
await XCTAssertThrowsError(try await withCheckedThrowingContinuation(workaround)) {
201+
XCTAssertEqualTypeAndValue($0, HTTPClientError.deadlineExceeded)
202+
}
203+
}
204+
#endif
205+
}
206+
126207
func testRequestWithHeadReceivedGetNotCancelledWhenDeadlineExceeded() {
127208
#if compiler(>=5.5.2) && canImport(_Concurrency)
128209
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }

0 commit comments

Comments
 (0)