Skip to content

Commit 3fcd670

Browse files
Lukasadnadoba
andauthored
Improve errors and testing using NIOTS (#588)
Motivation Currently error reporting with NIO Transport Services is often sub-par. This occurs because the Network.framework connections may enter the waiting state until the network connectivity state changes. We were not watching for the user event that contains the error in that state, so if we timed out in that state we'd just give a generic timeout error, instead of telling the user anything more detailed. Additionally, several of our tests assume that failure will be fast, but in NIO Transport Services we will enter that .waiting state. This is reasonable, as changed network connections may make a connection that was not succeeding suddenly viable. However, it's inconvenient for testing, where we're mostly interested in confirming that the error path works as expected. Modifications - Add an observer of the WaitingForConnectivity event that records it into our state machine for later reporting. - Add support for disabling waiting for connectivity for testing purposes. - Add annotations to several tests to stop them waiting for connectivity. Results Faster tests, better coverage, better errors for our users. Co-authored-by: David Nadoba <[email protected]>
1 parent 2442598 commit 3fcd670

14 files changed

+240
-31
lines changed

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift

+54-17
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ protocol HTTPConnectionRequester {
4747
func http1ConnectionCreated(_: HTTP1Connection)
4848
func http2ConnectionCreated(_: HTTP2Connection, maximumStreams: Int)
4949
func failedToCreateHTTPConnection(_: HTTPConnectionPool.Connection.ID, error: Error)
50+
func waitingForConnectivity(_: HTTPConnectionPool.Connection.ID, error: Error)
5051
}
5152

5253
extension HTTPConnectionPool.ConnectionFactory {
@@ -62,7 +63,7 @@ extension HTTPConnectionPool.ConnectionFactory {
6263
var logger = logger
6364
logger[metadataKey: "ahc-connection-id"] = "\(connectionID)"
6465

65-
self.makeChannel(connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).whenComplete { result in
66+
self.makeChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).whenComplete { result in
6667
switch result {
6768
case .success(.http1_1(let channel)):
6869
do {
@@ -104,13 +105,15 @@ extension HTTPConnectionPool.ConnectionFactory {
104105
case http2(Channel)
105106
}
106107

107-
func makeHTTP1Channel(
108+
func makeHTTP1Channel<Requester: HTTPConnectionRequester>(
109+
requester: Requester,
108110
connectionID: HTTPConnectionPool.Connection.ID,
109111
deadline: NIODeadline,
110112
eventLoop: EventLoop,
111113
logger: Logger
112114
) -> EventLoopFuture<Channel> {
113115
self.makeChannel(
116+
requester: requester,
114117
connectionID: connectionID,
115118
deadline: deadline,
116119
eventLoop: eventLoop,
@@ -137,7 +140,8 @@ extension HTTPConnectionPool.ConnectionFactory {
137140
}
138141
}
139142

140-
func makeChannel(
143+
func makeChannel<Requester: HTTPConnectionRequester>(
144+
requester: Requester,
141145
connectionID: HTTPConnectionPool.Connection.ID,
142146
deadline: NIODeadline,
143147
eventLoop: EventLoop,
@@ -150,6 +154,7 @@ extension HTTPConnectionPool.ConnectionFactory {
150154
case .socks:
151155
channelFuture = self.makeSOCKSProxyChannel(
152156
proxy,
157+
requester: requester,
153158
connectionID: connectionID,
154159
deadline: deadline,
155160
eventLoop: eventLoop,
@@ -158,14 +163,15 @@ extension HTTPConnectionPool.ConnectionFactory {
158163
case .http:
159164
channelFuture = self.makeHTTPProxyChannel(
160165
proxy,
166+
requester: requester,
161167
connectionID: connectionID,
162168
deadline: deadline,
163169
eventLoop: eventLoop,
164170
logger: logger
165171
)
166172
}
167173
} else {
168-
channelFuture = self.makeNonProxiedChannel(deadline: deadline, eventLoop: eventLoop, logger: logger)
174+
channelFuture = self.makeNonProxiedChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger)
169175
}
170176

171177
// let's map `ChannelError.connectTimeout` into a `HTTPClientError.connectTimeout`
@@ -179,30 +185,38 @@ extension HTTPConnectionPool.ConnectionFactory {
179185
}
180186
}
181187

182-
private func makeNonProxiedChannel(
188+
private func makeNonProxiedChannel<Requester: HTTPConnectionRequester>(
189+
requester: Requester,
190+
connectionID: HTTPConnectionPool.Connection.ID,
183191
deadline: NIODeadline,
184192
eventLoop: EventLoop,
185193
logger: Logger
186194
) -> EventLoopFuture<NegotiatedProtocol> {
187195
switch self.key.scheme {
188196
case .http, .httpUnix, .unix:
189-
return self.makePlainChannel(deadline: deadline, eventLoop: eventLoop).map { .http1_1($0) }
197+
return self.makePlainChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop).map { .http1_1($0) }
190198
case .https, .httpsUnix:
191-
return self.makeTLSChannel(deadline: deadline, eventLoop: eventLoop, logger: logger).flatMapThrowing {
199+
return self.makeTLSChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).flatMapThrowing {
192200
channel, negotiated in
193201

194202
try self.matchALPNToHTTPVersion(negotiated, channel: channel)
195203
}
196204
}
197205
}
198206

199-
private func makePlainChannel(deadline: NIODeadline, eventLoop: EventLoop) -> EventLoopFuture<Channel> {
207+
private func makePlainChannel<Requester: HTTPConnectionRequester>(
208+
requester: Requester,
209+
connectionID: HTTPConnectionPool.Connection.ID,
210+
deadline: NIODeadline,
211+
eventLoop: EventLoop
212+
) -> EventLoopFuture<Channel> {
200213
precondition(!self.key.scheme.usesTLS, "Unexpected scheme")
201-
return self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop).connect(target: self.key.connectionTarget)
214+
return self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop).connect(target: self.key.connectionTarget)
202215
}
203216

204-
private func makeHTTPProxyChannel(
217+
private func makeHTTPProxyChannel<Requester: HTTPConnectionRequester>(
205218
_ proxy: HTTPClient.Configuration.Proxy,
219+
requester: Requester,
206220
connectionID: HTTPConnectionPool.Connection.ID,
207221
deadline: NIODeadline,
208222
eventLoop: EventLoop,
@@ -211,7 +225,7 @@ extension HTTPConnectionPool.ConnectionFactory {
211225
// A proxy connection starts with a plain text connection to the proxy server. After
212226
// the connection has been established with the proxy server, the connection might be
213227
// upgraded to TLS before we send our first request.
214-
let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop)
228+
let bootstrap = self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop)
215229
return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in
216230
let encoder = HTTPRequestEncoder()
217231
let decoder = ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .dropBytes))
@@ -243,8 +257,9 @@ extension HTTPConnectionPool.ConnectionFactory {
243257
}
244258
}
245259

246-
private func makeSOCKSProxyChannel(
260+
private func makeSOCKSProxyChannel<Requester: HTTPConnectionRequester>(
247261
_ proxy: HTTPClient.Configuration.Proxy,
262+
requester: Requester,
248263
connectionID: HTTPConnectionPool.Connection.ID,
249264
deadline: NIODeadline,
250265
eventLoop: EventLoop,
@@ -253,7 +268,7 @@ extension HTTPConnectionPool.ConnectionFactory {
253268
// A proxy connection starts with a plain text connection to the proxy server. After
254269
// the connection has been established with the proxy server, the connection might be
255270
// upgraded to TLS before we send our first request.
256-
let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop)
271+
let bootstrap = self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop)
257272
return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in
258273
let socksConnectHandler = SOCKSClientHandler(targetAddress: SOCKSAddress(self.key.connectionTarget))
259274
let socksEventHandler = SOCKSEventsHandler(deadline: deadline)
@@ -331,14 +346,21 @@ extension HTTPConnectionPool.ConnectionFactory {
331346
}
332347
}
333348

334-
private func makePlainBootstrap(deadline: NIODeadline, eventLoop: EventLoop) -> NIOClientTCPBootstrapProtocol {
349+
private func makePlainBootstrap<Requester: HTTPConnectionRequester>(
350+
requester: Requester,
351+
connectionID: HTTPConnectionPool.Connection.ID,
352+
deadline: NIODeadline,
353+
eventLoop: EventLoop
354+
) -> NIOClientTCPBootstrapProtocol {
335355
#if canImport(Network)
336356
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) {
337357
return tsBootstrap
358+
.channelOption(NIOTSChannelOptions.waitForActivity, value: self.clientConfiguration.networkFrameworkWaitForConnectivity)
338359
.connectTimeout(deadline - NIODeadline.now())
339360
.channelInitializer { channel in
340361
do {
341362
try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler())
363+
try channel.pipeline.syncOperations.addHandler(NWWaitingHandler(requester: requester, connectionID: connectionID))
342364
return channel.eventLoop.makeSucceededVoidFuture()
343365
} catch {
344366
return channel.eventLoop.makeFailedFuture(error)
@@ -355,9 +377,17 @@ extension HTTPConnectionPool.ConnectionFactory {
355377
preconditionFailure("No matching bootstrap found")
356378
}
357379

358-
private func makeTLSChannel(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger) -> EventLoopFuture<(Channel, String?)> {
380+
private func makeTLSChannel<Requester: HTTPConnectionRequester>(
381+
requester: Requester,
382+
connectionID: HTTPConnectionPool.Connection.ID,
383+
deadline: NIODeadline,
384+
eventLoop: EventLoop,
385+
logger: Logger
386+
) -> EventLoopFuture<(Channel, String?)> {
359387
precondition(self.key.scheme.usesTLS, "Unexpected scheme")
360388
let bootstrapFuture = self.makeTLSBootstrap(
389+
requester: requester,
390+
connectionID: connectionID,
361391
deadline: deadline,
362392
eventLoop: eventLoop,
363393
logger: logger
@@ -387,8 +417,13 @@ extension HTTPConnectionPool.ConnectionFactory {
387417
return channelFuture
388418
}
389419

390-
private func makeTLSBootstrap(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger)
391-
-> EventLoopFuture<NIOClientTCPBootstrapProtocol> {
420+
private func makeTLSBootstrap<Requester: HTTPConnectionRequester>(
421+
requester: Requester,
422+
connectionID: HTTPConnectionPool.Connection.ID,
423+
deadline: NIODeadline,
424+
eventLoop: EventLoop,
425+
logger: Logger
426+
) -> EventLoopFuture<NIOClientTCPBootstrapProtocol> {
392427
var tlsConfig = self.tlsConfiguration
393428
switch self.clientConfiguration.httpVersion.configuration {
394429
case .automatic:
@@ -408,11 +443,13 @@ extension HTTPConnectionPool.ConnectionFactory {
408443
options -> NIOClientTCPBootstrapProtocol in
409444

410445
tsBootstrap
446+
.channelOption(NIOTSChannelOptions.waitForActivity, value: self.clientConfiguration.networkFrameworkWaitForConnectivity)
411447
.connectTimeout(deadline - NIODeadline.now())
412448
.tlsOptions(options)
413449
.channelInitializer { channel in
414450
do {
415451
try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler())
452+
try channel.pipeline.syncOperations.addHandler(NWWaitingHandler(requester: requester, connectionID: connectionID))
416453
// we don't need to set a TLS deadline for NIOTS connections, since the
417454
// TLS handshake is part of the TS connection bootstrap. If the TLS
418455
// handshake times out the complete connection creation will be failed.

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift

+10
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,16 @@ extension HTTPConnectionPool: HTTPConnectionRequester {
467467
$0.failedToCreateNewConnection(error, connectionID: connectionID)
468468
}
469469
}
470+
471+
func waitingForConnectivity(_ connectionID: HTTPConnectionPool.Connection.ID, error: Error) {
472+
self.logger.debug("waiting for connectivity", metadata: [
473+
"ahc-error": "\(error)",
474+
"ahc-connection-id": "\(connectionID)",
475+
])
476+
self.modifyStateAndRunActions {
477+
$0.waitingForConnectivity(error, connectionID: connectionID)
478+
}
479+
}
470480
}
471481

472482
extension HTTPConnectionPool: HTTP1ConnectionDelegate {

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

+6
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ extension HTTPConnectionPool {
241241
}
242242
}
243243

244+
mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action {
245+
self.lastConnectFailure = error
246+
247+
return .init(request: .none, connection: .none)
248+
}
249+
244250
mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action {
245251
switch self.lifecycleState {
246252
case .running:

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

+5
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ extension HTTPConnectionPool {
406406
return .init(request: .none, connection: .scheduleBackoffTimer(connectionID, backoff: backoff, on: eventLoop))
407407
}
408408

409+
mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action {
410+
self.lastConnectFailure = error
411+
return .init(request: .none, connection: .none)
412+
}
413+
409414
mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action {
410415
// The naming of `failConnection` is a little confusing here. All it does is moving the
411416
// connection state from `.backingOff` to `.closed` here. It also returns the

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

+8
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,14 @@ extension HTTPConnectionPool {
211211
})
212212
}
213213

214+
mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action {
215+
self.state.modify(http1: { http1 in
216+
http1.waitingForConnectivity(error, connectionID: connectionID)
217+
}, http2: { http2 in
218+
http2.waitingForConnectivity(error, connectionID: connectionID)
219+
})
220+
}
221+
214222
mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action {
215223
self.state.modify(http1: { http1 in
216224
http1.connectionCreationBackoffDone(connectionID)

Sources/AsyncHTTPClient/HTTPClient.swift

+5
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,10 @@ public class HTTPClient {
655655
/// is set to `.automatic` by default which will use HTTP/2 if run over https and the server supports it, otherwise HTTP/1
656656
public var httpVersion: HTTPVersion
657657

658+
/// Whether `HTTPClient` will let Network.framework sit in the `.waiting` state awaiting new network changes, or fail immediately. Defaults to `true`,
659+
/// which is the recommended setting. Only set this to `false` when attempting to trigger a particular error path.
660+
public var networkFrameworkWaitForConnectivity: Bool
661+
658662
public init(
659663
tlsConfiguration: TLSConfiguration? = nil,
660664
redirectConfiguration: RedirectConfiguration? = nil,
@@ -671,6 +675,7 @@ public class HTTPClient {
671675
self.proxy = proxy
672676
self.decompression = decompression
673677
self.httpVersion = .automatic
678+
self.networkFrameworkWaitForConnectivity = true
674679
}
675680

676681
public init(tlsConfiguration: TLSConfiguration? = nil,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2022 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if canImport(Network)
16+
import Network
17+
import NIOCore
18+
import NIOHTTP1
19+
import NIOTransportServices
20+
21+
@available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *)
22+
final class NWWaitingHandler<Requester: HTTPConnectionRequester>: ChannelInboundHandler {
23+
typealias InboundIn = Any
24+
typealias InboundOut = Any
25+
26+
private var requester: Requester
27+
private let connectionID: HTTPConnectionPool.Connection.ID
28+
29+
init(requester: Requester, connectionID: HTTPConnectionPool.Connection.ID) {
30+
self.requester = requester
31+
self.connectionID = connectionID
32+
}
33+
34+
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
35+
if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity {
36+
self.requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError))
37+
}
38+
context.fireUserInboundEventTriggered(event)
39+
}
40+
}
41+
#endif

Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift

+4
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,10 @@ extension TestConnectionCreator: HTTPConnectionRequester {
405405
}
406406
wrapper.fail(error)
407407
}
408+
409+
func waitingForConnectivity(_: HTTPConnectionPool.Connection.ID, error: Swift.Error) {
410+
preconditionFailure("TODO")
411+
}
408412
}
409413

410414
class TestHTTP2ConnectionDelegate: HTTP2ConnectionDelegate {

Tests/AsyncHTTPClientTests/HTTPClient+SOCKSTests.swift

+15-4
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ class HTTPClientSOCKSTests: XCTestCase {
9090
}
9191

9292
func testProxySOCKSBogusAddress() throws {
93+
var config = HTTPClient.Configuration(proxy: .socksServer(host: "127.0.."))
94+
config.networkFrameworkWaitForConnectivity = false
9395
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
94-
configuration: .init(proxy: .socksServer(host: "127.0..")))
96+
configuration: config)
9597

9698
defer {
9799
XCTAssertNoThrow(try localClient.syncShutdown())
@@ -102,8 +104,11 @@ class HTTPClientSOCKSTests: XCTestCase {
102104
// there is no socks server, so we should fail
103105
func testProxySOCKSFailureNoServer() throws {
104106
let localHTTPBin = HTTPBin()
107+
var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost", port: localHTTPBin.port))
108+
config.networkFrameworkWaitForConnectivity = false
109+
105110
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
106-
configuration: .init(proxy: .socksServer(host: "localhost", port: localHTTPBin.port)))
111+
configuration: config)
107112
defer {
108113
XCTAssertNoThrow(try localClient.syncShutdown())
109114
XCTAssertNoThrow(try localHTTPBin.shutdown())
@@ -113,8 +118,11 @@ class HTTPClientSOCKSTests: XCTestCase {
113118

114119
// speak to a server that doesn't speak SOCKS
115120
func testProxySOCKSFailureInvalidServer() throws {
121+
var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost"))
122+
config.networkFrameworkWaitForConnectivity = false
123+
116124
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
117-
configuration: .init(proxy: .socksServer(host: "localhost")))
125+
configuration: config)
118126
defer {
119127
XCTAssertNoThrow(try localClient.syncShutdown())
120128
}
@@ -124,8 +132,11 @@ class HTTPClientSOCKSTests: XCTestCase {
124132
// test a handshake failure with a misbehaving server
125133
func testProxySOCKSMisbehavingServer() throws {
126134
let socksBin = try MockSOCKSServer(expectedURL: "/socks/test", expectedResponse: "it works!", misbehave: true)
135+
var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost", port: socksBin.port))
136+
config.networkFrameworkWaitForConnectivity = false
137+
127138
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
128-
configuration: .init(proxy: .socksServer(host: "localhost", port: socksBin.port)))
139+
configuration: config)
129140

130141
defer {
131142
XCTAssertNoThrow(try localClient.syncShutdown())

0 commit comments

Comments
 (0)