Skip to content

Commit f17a47e

Browse files
authored
Allow immediate request failure on connection error (#625)
1 parent 9937d87 commit f17a47e

12 files changed

+283
-29
lines changed

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ final class HTTPConnectionPool {
7070

7171
self._state = StateMachine(
7272
idGenerator: idGenerator,
73-
maximumConcurrentHTTP1Connections: clientConfiguration.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit
73+
maximumConcurrentHTTP1Connections: clientConfiguration.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit,
74+
retryConnectionEstablishment: clientConfiguration.connectionPool.retryConnectionEstablishment
7475
)
7576
}
7677

Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import NIOCore
1717
extension HTTPConnectionPool {
1818
struct HTTP1StateMachine {
1919
typealias Action = HTTPConnectionPool.StateMachine.Action
20+
typealias RequestAction = HTTPConnectionPool.StateMachine.RequestAction
2021
typealias ConnectionMigrationAction = HTTPConnectionPool.StateMachine.ConnectionMigrationAction
2122
typealias EstablishedAction = HTTPConnectionPool.StateMachine.EstablishedAction
2223
typealias EstablishedConnectionAction = HTTPConnectionPool.StateMachine.EstablishedConnectionAction
@@ -29,16 +30,21 @@ extension HTTPConnectionPool {
2930

3031
private(set) var requests: RequestQueue
3132
private(set) var lifecycleState: StateMachine.LifecycleState
33+
/// The property was introduced to fail fast during testing.
34+
/// Otherwise this should always be true and not turned off.
35+
private let retryConnectionEstablishment: Bool
3236

3337
init(
3438
idGenerator: Connection.ID.Generator,
3539
maximumConcurrentConnections: Int,
40+
retryConnectionEstablishment: Bool,
3641
lifecycleState: StateMachine.LifecycleState
3742
) {
3843
self.connections = HTTP1Connections(
3944
maximumConcurrentConnections: maximumConcurrentConnections,
4045
generator: idGenerator
4146
)
47+
self.retryConnectionEstablishment = retryConnectionEstablishment
4248

4349
self.requests = RequestQueue()
4450
self.lifecycleState = lifecycleState
@@ -219,6 +225,17 @@ extension HTTPConnectionPool {
219225

220226
switch self.lifecycleState {
221227
case .running:
228+
guard self.retryConnectionEstablishment else {
229+
guard let (index, _) = self.connections.failConnection(connectionID) else {
230+
preconditionFailure("A connection attempt failed, that the state machine knows nothing about. Somewhere state was lost.")
231+
}
232+
self.connections.removeConnection(at: index)
233+
234+
return .init(
235+
request: self.failAllRequests(reason: error),
236+
connection: .none
237+
)
238+
}
222239
// We don't care how many waiting requests we have at this point, we will schedule a
223240
// retry. More tasks, may appear until the backoff has completed. The final
224241
// decision about the retry will be made in `connectionCreationBackoffDone(_:)`
@@ -523,6 +540,14 @@ extension HTTPConnectionPool {
523540
return .none
524541
}
525542

543+
private mutating func failAllRequests(reason error: Error) -> RequestAction {
544+
let allRequests = self.requests.removeAll()
545+
guard !allRequests.isEmpty else {
546+
return .none
547+
}
548+
return .failRequestsAndCancelTimeouts(allRequests, error)
549+
}
550+
526551
// MARK: HTTP2
527552

528553
mutating func newHTTP2MaxConcurrentStreamsReceived(_ connectionID: Connection.ID, newMaxStreams: Int) -> Action {

Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import NIOHTTP2
1818
extension HTTPConnectionPool {
1919
struct HTTP2StateMachine {
2020
typealias Action = HTTPConnectionPool.StateMachine.Action
21+
typealias RequestAction = HTTPConnectionPool.StateMachine.RequestAction
2122
typealias ConnectionMigrationAction = HTTPConnectionPool.StateMachine.ConnectionMigrationAction
2223
typealias EstablishedAction = HTTPConnectionPool.StateMachine.EstablishedAction
2324
typealias EstablishedConnectionAction = HTTPConnectionPool.StateMachine.EstablishedConnectionAction
@@ -33,16 +34,21 @@ extension HTTPConnectionPool {
3334
private let idGenerator: Connection.ID.Generator
3435

3536
private(set) var lifecycleState: StateMachine.LifecycleState
37+
/// The property was introduced to fail fast during testing.
38+
/// Otherwise this should always be true and not turned off.
39+
private let retryConnectionEstablishment: Bool
3640

3741
init(
3842
idGenerator: Connection.ID.Generator,
43+
retryConnectionEstablishment: Bool,
3944
lifecycleState: StateMachine.LifecycleState
4045
) {
4146
self.idGenerator = idGenerator
4247
self.requests = RequestQueue()
4348

4449
self.connections = HTTP2Connections(generator: idGenerator)
4550
self.lifecycleState = lifecycleState
51+
self.retryConnectionEstablishment = retryConnectionEstablishment
4652
}
4753

4854
mutating func migrateFromHTTP1(
@@ -398,16 +404,30 @@ extension HTTPConnectionPool {
398404
}
399405

400406
mutating func failedToCreateNewConnection(_ error: Error, connectionID: Connection.ID) -> Action {
407+
// TODO: switch over state https://github.com/swift-server/async-http-client/issues/638
401408
self.failedConsecutiveConnectionAttempts += 1
402409
self.lastConnectFailure = error
403410

411+
guard self.retryConnectionEstablishment else {
412+
guard let (index, _) = self.connections.failConnection(connectionID) else {
413+
preconditionFailure("A connection attempt failed, that the state machine knows nothing about. Somewhere state was lost.")
414+
}
415+
self.connections.removeConnection(at: index)
416+
417+
return .init(
418+
request: self.failAllRequests(reason: error),
419+
connection: .none
420+
)
421+
}
422+
404423
let eventLoop = self.connections.backoffNextConnectionAttempt(connectionID)
405424
let backoff = calculateBackoff(failedAttempt: self.failedConsecutiveConnectionAttempts)
406425
return .init(request: .none, connection: .scheduleBackoffTimer(connectionID, backoff: backoff, on: eventLoop))
407426
}
408427

409428
mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action {
410429
self.lastConnectFailure = error
430+
411431
return .init(request: .none, connection: .none)
412432
}
413433

@@ -421,6 +441,14 @@ extension HTTPConnectionPool {
421441
return self.nextActionForFailedConnection(at: index, on: context.eventLoop)
422442
}
423443

444+
private mutating func failAllRequests(reason error: Error) -> RequestAction {
445+
let allRequests = self.requests.removeAll()
446+
guard !allRequests.isEmpty else {
447+
return .none
448+
}
449+
return .failRequestsAndCancelTimeouts(allRequests, error)
450+
}
451+
424452
mutating func timeoutRequest(_ requestID: Request.ID) -> Action {
425453
// 1. check requests in queue
426454
if let request = self.requests.remove(requestID) {

Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+StateMachine.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,22 @@ extension HTTPConnectionPool {
9696

9797
let idGenerator: Connection.ID.Generator
9898
let maximumConcurrentHTTP1Connections: Int
99-
100-
init(idGenerator: Connection.ID.Generator, maximumConcurrentHTTP1Connections: Int) {
99+
/// The property was introduced to fail fast during testing.
100+
/// Otherwise this should always be true and not turned off.
101+
private let retryConnectionEstablishment: Bool
102+
103+
init(
104+
idGenerator: Connection.ID.Generator,
105+
maximumConcurrentHTTP1Connections: Int,
106+
retryConnectionEstablishment: Bool
107+
) {
101108
self.maximumConcurrentHTTP1Connections = maximumConcurrentHTTP1Connections
109+
self.retryConnectionEstablishment = retryConnectionEstablishment
102110
self.idGenerator = idGenerator
103111
let http1State = HTTP1StateMachine(
104112
idGenerator: idGenerator,
105113
maximumConcurrentConnections: maximumConcurrentHTTP1Connections,
114+
retryConnectionEstablishment: retryConnectionEstablishment,
106115
lifecycleState: .running
107116
)
108117
self.state = .http1(http1State)
@@ -127,6 +136,7 @@ extension HTTPConnectionPool {
127136
var http1StateMachine = HTTP1StateMachine(
128137
idGenerator: self.idGenerator,
129138
maximumConcurrentConnections: self.maximumConcurrentHTTP1Connections,
139+
retryConnectionEstablishment: self.retryConnectionEstablishment,
130140
lifecycleState: http2StateMachine.lifecycleState
131141
)
132142

@@ -147,6 +157,7 @@ extension HTTPConnectionPool {
147157

148158
var http2StateMachine = HTTP2StateMachine(
149159
idGenerator: self.idGenerator,
160+
retryConnectionEstablishment: self.retryConnectionEstablishment,
150161
lifecycleState: http1StateMachine.lifecycleState
151162
)
152163
let migrationAction = http2StateMachine.migrateFromHTTP1(

Sources/AsyncHTTPClient/HTTPClient.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,13 +975,23 @@ extension HTTPClient.Configuration {
975975
/// an explicit eventLoopRequirement are sent, this number might be exceeded due to overflow connections.
976976
public var concurrentHTTP1ConnectionsPerHostSoftLimit: Int
977977

978+
/// If true, ``HTTPClient`` will try to create new connections on connection failure with an exponential backoff.
979+
/// Requests will only fail after the ``HTTPClient/Configuration/Timeout-swift.struct/connect`` timeout exceeded.
980+
/// If false, all requests that have no assigned connection will fail immediately after a connection could not be established.
981+
/// Defaults to `true`.
982+
/// - warning: We highly recommend leaving this on.
983+
/// It is very common that connections establishment is flaky at scale.
984+
/// ``HTTPClient`` will automatically mitigate these kind of issues if this flag is turned on.
985+
var retryConnectionEstablishment: Bool
986+
978987
public init(idleTimeout: TimeAmount = .seconds(60)) {
979988
self.init(idleTimeout: idleTimeout, concurrentHTTP1ConnectionsPerHostSoftLimit: 8)
980989
}
981990

982991
public init(idleTimeout: TimeAmount, concurrentHTTP1ConnectionsPerHostSoftLimit: Int) {
983992
self.idleTimeout = idleTimeout
984993
self.concurrentHTTP1ConnectionsPerHostSoftLimit = concurrentHTTP1ConnectionsPerHostSoftLimit
994+
self.retryConnectionEstablishment = true
985995
}
986996
}
987997

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
409409
return XCTFail("unexpected error \(error)")
410410
}
411411
// a race between deadline and connect timer can result in either error
412-
XCTAssertTrue([.deadlineExceeded, .connectTimeout].contains(error))
412+
XCTAssertTrue([.deadlineExceeded, .connectTimeout].contains(error), "unexpected error \(error)")
413413
}
414414
}
415415
#endif

Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class HTTPClientNIOTSTests: XCTestCase {
5757
let httpBin = HTTPBin(.http1_1(ssl: true))
5858
var config = HTTPClient.Configuration()
5959
config.networkFrameworkWaitForConnectivity = false
60+
config.connectionPool.retryConnectionEstablishment = false
6061
let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
6162
configuration: config)
6263
defer {
@@ -85,6 +86,7 @@ class HTTPClientNIOTSTests: XCTestCase {
8586
let httpBin = HTTPBin(.http1_1(ssl: false))
8687
var config = HTTPClient.Configuration()
8788
config.networkFrameworkWaitForConnectivity = false
89+
config.connectionPool.retryConnectionEstablishment = false
8890

8991
let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
9092
configuration: config)
@@ -140,6 +142,7 @@ class HTTPClientNIOTSTests: XCTestCase {
140142

141143
var clientConfig = HTTPClient.Configuration(tlsConfiguration: tlsConfig)
142144
clientConfig.networkFrameworkWaitForConnectivity = false
145+
clientConfig.connectionPool.retryConnectionEstablishment = false
143146
let httpClient = HTTPClient(
144147
eventLoopGroupProvider: .shared(self.clientGroup),
145148
configuration: clientConfig

Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP1StateTests+XCTest.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ extension HTTPConnectionPool_HTTP1StateMachineTests {
2626
static var allTests: [(String, (HTTPConnectionPool_HTTP1StateMachineTests) -> () throws -> Void)] {
2727
return [
2828
("testCreatingAndFailingConnections", testCreatingAndFailingConnections),
29+
("testCreatingAndFailingConnectionsWithoutRetry", testCreatingAndFailingConnectionsWithoutRetry),
2930
("testConnectionFailureBackoff", testConnectionFailureBackoff),
3031
("testCancelRequestWorks", testCancelRequestWorks),
3132
("testExecuteOnShuttingDownPool", testExecuteOnShuttingDownPool),

0 commit comments

Comments
 (0)