Skip to content

Commit 0c36de2

Browse files
authored
HTTPConnectionPool timeout requests: preserve connection errors
If a request times out because no connection could be attained, we should fail the request with the last connection creation error. If no connection creation error is present and there is no active connection, we fail the request with a connectTimeout error.
1 parent 75b716e commit 0c36de2

File tree

5 files changed

+127
-3
lines changed

5 files changed

+127
-3
lines changed

Diff for: Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1Connections.swift

+5
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ extension HTTPConnectionPool {
257257
return connecting
258258
}
259259

260+
/// Is there at least one connection that is able to run requests
261+
var hasActiveConnections: Bool {
262+
self.connections.contains(where: { $0.isIdle || $0.isLeased })
263+
}
264+
260265
func startingEventLoopConnections(on eventLoop: EventLoop) -> Int {
261266
return self.connections[self.overflowIndex..<self.connections.endIndex].reduce(into: 0) { count, connection in
262267
guard connection.eventLoop === eventLoop else { return }

Diff for: Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift

+11-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ extension HTTPConnectionPool {
2626

2727
private var connections: HTTP1Connections
2828
private var failedConsecutiveConnectionAttempts: Int = 0
29+
/// the error from the last connection creation
30+
private var lastConnectFailure: Error?
2931

3032
private var requests: RequestQueue
3133
private var state: State = .running
@@ -136,12 +138,14 @@ extension HTTPConnectionPool {
136138

137139
mutating func newHTTP1ConnectionEstablished(_ connection: Connection) -> Action {
138140
self.failedConsecutiveConnectionAttempts = 0
141+
self.lastConnectFailure = nil
139142
let (index, context) = self.connections.newHTTP1ConnectionEstablished(connection)
140143
return self.nextActionForIdleConnection(at: index, context: context)
141144
}
142145

143146
mutating func failedToCreateNewConnection(_ error: Error, connectionID: Connection.ID) -> Action {
144147
self.failedConsecutiveConnectionAttempts += 1
148+
self.lastConnectFailure = error
145149

146150
switch self.state {
147151
case .running:
@@ -223,8 +227,14 @@ extension HTTPConnectionPool {
223227
mutating func timeoutRequest(_ requestID: Request.ID) -> Action {
224228
// 1. check requests in queue
225229
if let request = self.requests.remove(requestID) {
230+
var error: Error = HTTPClientError.getConnectionFromPoolTimeout
231+
if let lastError = self.lastConnectFailure {
232+
error = lastError
233+
} else if !self.connections.hasActiveConnections {
234+
error = HTTPClientError.connectTimeout
235+
}
226236
return .init(
227-
request: .failRequest(request, HTTPClientError.getConnectionFromPoolTimeout, cancelTimeout: false),
237+
request: .failRequest(request, error, cancelTimeout: false),
228238
connection: .none
229239
)
230240
}

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP1StateTests+XCTest.swift

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ extension HTTPConnectionPool_HTTP1StateMachineTests {
3838
("testConnectionPoolFullOfParkedConnectionsIsShutdownImmediately", testConnectionPoolFullOfParkedConnectionsIsShutdownImmediately),
3939
("testParkedConnectionTimesOutButIsAlsoClosedByRemote", testParkedConnectionTimesOutButIsAlsoClosedByRemote),
4040
("testConnectionBackoffVsShutdownRace", testConnectionBackoffVsShutdownRace),
41+
("testRequestThatTimesOutIsFailedWithLastConnectionCreationError", testRequestThatTimesOutIsFailedWithLastConnectionCreationError),
42+
("testRequestThatTimesOutBeforeAConnectionIsEstablishedIsFailedWithConnectTimeoutError", testRequestThatTimesOutBeforeAConnectionIsEstablishedIsFailedWithConnectTimeoutError),
43+
("testRequestThatTimesOutAfterAConnectionWasEstablishedSuccessfullyTimesOutWithGenericError", testRequestThatTimesOutAfterAConnectionWasEstablishedSuccessfullyTimesOutWithGenericError),
4144
]
4245
}
4346
}

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP1StateTests.swift

+107-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ class HTTPConnectionPool_HTTP1StateMachineTests: XCTestCase {
153153
return XCTFail("Unexpected request action: \(action.request)")
154154
}
155155
XCTAssert(requestToFail.__testOnly_wrapped_request() === mockRequest) // XCTAssertIdentical not available on Linux
156-
XCTAssertEqual(requestError as? HTTPClientError, .getConnectionFromPoolTimeout)
156+
XCTAssertEqual(requestError as? HTTPClientError, .connectTimeout)
157157
XCTAssertEqual(failRequest.connection, .none)
158158

159159
// 4. retry connection, but no more queued requests.
@@ -626,4 +626,110 @@ class HTTPConnectionPool_HTTP1StateMachineTests: XCTestCase {
626626

627627
XCTAssertEqual(state.connectionCreationBackoffDone(connectionID), .none)
628628
}
629+
630+
func testRequestThatTimesOutIsFailedWithLastConnectionCreationError() {
631+
let elg = EmbeddedEventLoopGroup(loops: 1)
632+
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
633+
634+
var state = HTTPConnectionPool.StateMachine(
635+
eventLoopGroup: elg,
636+
idGenerator: .init(),
637+
maximumConcurrentHTTP1Connections: 6
638+
)
639+
640+
let mockRequest = MockHTTPRequest(eventLoop: elg.next(), requiresEventLoopForChannel: false)
641+
let request = HTTPConnectionPool.Request(mockRequest)
642+
643+
let executeAction = state.executeRequest(request)
644+
guard case .createConnection(let connectionID, on: let connEL) = executeAction.connection else {
645+
return XCTFail("Expected to create a connection")
646+
}
647+
648+
XCTAssertEqual(executeAction.request, .scheduleRequestTimeout(for: request, on: mockRequest.eventLoop))
649+
650+
let failAction = state.failedToCreateNewConnection(HTTPClientError.httpProxyHandshakeTimeout, connectionID: connectionID)
651+
guard case .scheduleBackoffTimer(connectionID, backoff: _, on: let timerEL) = failAction.connection else {
652+
return XCTFail("Expected to create a backoff timer")
653+
}
654+
XCTAssert(timerEL === connEL)
655+
XCTAssertEqual(failAction.request, .none)
656+
657+
let timeoutAction = state.timeoutRequest(request.id)
658+
XCTAssertEqual(timeoutAction.request, .failRequest(request, HTTPClientError.httpProxyHandshakeTimeout, cancelTimeout: false))
659+
XCTAssertEqual(timeoutAction.connection, .none)
660+
}
661+
662+
func testRequestThatTimesOutBeforeAConnectionIsEstablishedIsFailedWithConnectTimeoutError() {
663+
let eventLoop = EmbeddedEventLoop()
664+
defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) }
665+
666+
var state = HTTPConnectionPool.StateMachine(
667+
eventLoopGroup: eventLoop,
668+
idGenerator: .init(),
669+
maximumConcurrentHTTP1Connections: 6
670+
)
671+
672+
let mockRequest = MockHTTPRequest(eventLoop: eventLoop.next(), requiresEventLoopForChannel: false)
673+
let request = HTTPConnectionPool.Request(mockRequest)
674+
675+
let executeAction = state.executeRequest(request)
676+
guard case .createConnection(_, on: _) = executeAction.connection else {
677+
return XCTFail("Expected to create a connection")
678+
}
679+
XCTAssertEqual(executeAction.request, .scheduleRequestTimeout(for: request, on: mockRequest.eventLoop))
680+
681+
let timeoutAction = state.timeoutRequest(request.id)
682+
XCTAssertEqual(timeoutAction.request, .failRequest(request, HTTPClientError.connectTimeout, cancelTimeout: false))
683+
XCTAssertEqual(timeoutAction.connection, .none)
684+
}
685+
686+
func testRequestThatTimesOutAfterAConnectionWasEstablishedSuccessfullyTimesOutWithGenericError() {
687+
let elg = EmbeddedEventLoopGroup(loops: 1)
688+
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
689+
690+
var state = HTTPConnectionPool.StateMachine(
691+
eventLoopGroup: elg,
692+
idGenerator: .init(),
693+
maximumConcurrentHTTP1Connections: 6
694+
)
695+
696+
let mockRequest1 = MockHTTPRequest(eventLoop: elg.next(), requiresEventLoopForChannel: false)
697+
let request1 = HTTPConnectionPool.Request(mockRequest1)
698+
699+
let executeAction1 = state.executeRequest(request1)
700+
guard case .createConnection(let connectionID1, on: let connEL1) = executeAction1.connection else {
701+
return XCTFail("Expected to create a connection")
702+
}
703+
XCTAssert(mockRequest1.eventLoop === connEL1)
704+
705+
XCTAssertEqual(executeAction1.request, .scheduleRequestTimeout(for: request1, on: mockRequest1.eventLoop))
706+
707+
let mockRequest2 = MockHTTPRequest(eventLoop: elg.next(), requiresEventLoopForChannel: false)
708+
let request2 = HTTPConnectionPool.Request(mockRequest2)
709+
710+
let executeAction2 = state.executeRequest(request2)
711+
guard case .createConnection(let connectionID2, on: let connEL2) = executeAction2.connection else {
712+
return XCTFail("Expected to create a connection")
713+
}
714+
XCTAssert(mockRequest2.eventLoop === connEL2)
715+
716+
XCTAssertEqual(executeAction2.request, .scheduleRequestTimeout(for: request2, on: connEL1))
717+
718+
let failAction = state.failedToCreateNewConnection(HTTPClientError.httpProxyHandshakeTimeout, connectionID: connectionID1)
719+
guard case .scheduleBackoffTimer(connectionID1, backoff: _, on: let timerEL) = failAction.connection else {
720+
return XCTFail("Expected to create a backoff timer")
721+
}
722+
XCTAssert(timerEL === connEL2)
723+
XCTAssertEqual(failAction.request, .none)
724+
725+
let conn2 = HTTPConnectionPool.Connection.__testOnly_connection(id: connectionID2, eventLoop: connEL2)
726+
let createdAction = state.newHTTP1ConnectionCreated(conn2)
727+
728+
XCTAssertEqual(createdAction.request, .executeRequest(request1, conn2, cancelTimeout: true))
729+
XCTAssertEqual(createdAction.connection, .none)
730+
731+
let timeoutAction = state.timeoutRequest(request2.id)
732+
XCTAssertEqual(timeoutAction.request, .failRequest(request2, HTTPClientError.getConnectionFromPoolTimeout, cancelTimeout: false))
733+
XCTAssertEqual(timeoutAction.connection, .none)
734+
}
629735
}

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPoolTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ class HTTPConnectionPoolTests: XCTestCase {
229229

230230
pool.executeRequest(requestBag)
231231
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
232-
XCTAssertEqual($0 as? HTTPClientError, .getConnectionFromPoolTimeout)
232+
XCTAssertEqual($0 as? HTTPClientError, .proxyAuthenticationRequired)
233233
}
234234
XCTAssertGreaterThanOrEqual(httpBin.createdConnections, 8)
235235
XCTAssertEqual(httpBin.activeConnections, 0)

0 commit comments

Comments
 (0)