From 7fb19ca1328189b5a4ada29ae23306a375d4831f Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Tue, 31 May 2022 17:42:59 +0100 Subject: [PATCH 1/9] Improve errors and testing using NIOTS 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. --- .../HTTPConnectionPool+Factory.swift | 71 ++++++++++++++----- .../ConnectionPool/HTTPConnectionPool.swift | 10 +++ ...HTTPConnectionPool+HTTP1StateMachine.swift | 6 ++ ...HTTPConnectionPool+HTTP2StateMachine.swift | 5 ++ .../HTTPConnectionPool+StateMachine.swift | 8 +++ Sources/AsyncHTTPClient/HTTPClient.swift | 5 ++ .../NWWaitingHandler.swift | 49 +++++++++++++ .../HTTP2ConnectionTests.swift | 4 ++ .../HTTPClient+SOCKSTests.swift | 19 +++-- .../HTTPClientInternalTests.swift | 4 +- .../HTTPClientNIOTSTests.swift | 14 ++-- .../HTTPClientTests.swift | 36 ++++++++-- .../HTTPConnectionPool+FactoryTests.swift | 23 ++++++ 13 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 4a3338697..1444df9bb 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -47,6 +47,7 @@ protocol HTTPConnectionRequester { func http1ConnectionCreated(_: HTTP1Connection) func http2ConnectionCreated(_: HTTP2Connection, maximumStreams: Int) func failedToCreateHTTPConnection(_: HTTPConnectionPool.Connection.ID, error: Error) + func waitingForConnectivity(_: HTTPConnectionPool.Connection.ID, error: Error) } extension HTTPConnectionPool.ConnectionFactory { @@ -62,7 +63,7 @@ extension HTTPConnectionPool.ConnectionFactory { var logger = logger logger[metadataKey: "ahc-connection-id"] = "\(connectionID)" - self.makeChannel(connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).whenComplete { result in + self.makeChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).whenComplete { result in switch result { case .success(.http1_1(let channel)): do { @@ -104,13 +105,15 @@ extension HTTPConnectionPool.ConnectionFactory { case http2(Channel) } - func makeHTTP1Channel( + func makeHTTP1Channel( + requester: Requester, connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop, logger: Logger ) -> EventLoopFuture { self.makeChannel( + requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, @@ -137,7 +140,8 @@ extension HTTPConnectionPool.ConnectionFactory { } } - func makeChannel( + func makeChannel( + requester: Requester, connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop, @@ -150,6 +154,7 @@ extension HTTPConnectionPool.ConnectionFactory { case .socks: channelFuture = self.makeSOCKSProxyChannel( proxy, + requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, @@ -158,6 +163,7 @@ extension HTTPConnectionPool.ConnectionFactory { case .http: channelFuture = self.makeHTTPProxyChannel( proxy, + requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, @@ -165,7 +171,7 @@ extension HTTPConnectionPool.ConnectionFactory { ) } } else { - channelFuture = self.makeNonProxiedChannel(deadline: deadline, eventLoop: eventLoop, logger: logger) + channelFuture = self.makeNonProxiedChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger) } // let's map `ChannelError.connectTimeout` into a `HTTPClientError.connectTimeout` @@ -179,16 +185,18 @@ extension HTTPConnectionPool.ConnectionFactory { } } - private func makeNonProxiedChannel( + private func makeNonProxiedChannel( + requester: Requester, + connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop, logger: Logger ) -> EventLoopFuture { switch self.key.scheme { case .http, .httpUnix, .unix: - return self.makePlainChannel(deadline: deadline, eventLoop: eventLoop).map { .http1_1($0) } + return self.makePlainChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop).map { .http1_1($0) } case .https, .httpsUnix: - return self.makeTLSChannel(deadline: deadline, eventLoop: eventLoop, logger: logger).flatMapThrowing { + return self.makeTLSChannel(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger).flatMapThrowing { channel, negotiated in try self.matchALPNToHTTPVersion(negotiated, channel: channel) @@ -196,13 +204,19 @@ extension HTTPConnectionPool.ConnectionFactory { } } - private func makePlainChannel(deadline: NIODeadline, eventLoop: EventLoop) -> EventLoopFuture { + private func makePlainChannel( + requester: Requester, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop + ) -> EventLoopFuture { precondition(!self.key.scheme.usesTLS, "Unexpected scheme") - return self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop).connect(target: self.key.connectionTarget) + return self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop).connect(target: self.key.connectionTarget) } - private func makeHTTPProxyChannel( + private func makeHTTPProxyChannel( _ proxy: HTTPClient.Configuration.Proxy, + requester: Requester, connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop, @@ -211,7 +225,7 @@ extension HTTPConnectionPool.ConnectionFactory { // A proxy connection starts with a plain text connection to the proxy server. After // the connection has been established with the proxy server, the connection might be // upgraded to TLS before we send our first request. - let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop) + let bootstrap = self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop) return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in let encoder = HTTPRequestEncoder() let decoder = ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .dropBytes)) @@ -243,8 +257,9 @@ extension HTTPConnectionPool.ConnectionFactory { } } - private func makeSOCKSProxyChannel( + private func makeSOCKSProxyChannel( _ proxy: HTTPClient.Configuration.Proxy, + requester: Requester, connectionID: HTTPConnectionPool.Connection.ID, deadline: NIODeadline, eventLoop: EventLoop, @@ -253,7 +268,7 @@ extension HTTPConnectionPool.ConnectionFactory { // A proxy connection starts with a plain text connection to the proxy server. After // the connection has been established with the proxy server, the connection might be // upgraded to TLS before we send our first request. - let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop) + let bootstrap = self.makePlainBootstrap(requester: requester, connectionID: connectionID, deadline: deadline, eventLoop: eventLoop) return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in let socksConnectHandler = SOCKSClientHandler(targetAddress: SOCKSAddress(self.key.connectionTarget)) let socksEventHandler = SOCKSEventsHandler(deadline: deadline) @@ -331,14 +346,21 @@ extension HTTPConnectionPool.ConnectionFactory { } } - private func makePlainBootstrap(deadline: NIODeadline, eventLoop: EventLoop) -> NIOClientTCPBootstrapProtocol { + private func makePlainBootstrap( + requester: Requester, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop + ) -> NIOClientTCPBootstrapProtocol { #if canImport(Network) if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) { return tsBootstrap + .channelOption(NIOTSChannelOptions.waitForActivity, value: self.clientConfiguration.networkFrameworkWaitForConnectivity) .connectTimeout(deadline - NIODeadline.now()) .channelInitializer { channel in do { try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler()) + try channel.pipeline.syncOperations.addHandler(NWWaitingHandler(requester: requester, connectionID: connectionID)) return channel.eventLoop.makeSucceededVoidFuture() } catch { return channel.eventLoop.makeFailedFuture(error) @@ -355,9 +377,17 @@ extension HTTPConnectionPool.ConnectionFactory { preconditionFailure("No matching bootstrap found") } - private func makeTLSChannel(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger) -> EventLoopFuture<(Channel, String?)> { + private func makeTLSChannel( + requester: Requester, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture<(Channel, String?)> { precondition(self.key.scheme.usesTLS, "Unexpected scheme") let bootstrapFuture = self.makeTLSBootstrap( + requester: requester, + connectionID: connectionID, deadline: deadline, eventLoop: eventLoop, logger: logger @@ -387,8 +417,13 @@ extension HTTPConnectionPool.ConnectionFactory { return channelFuture } - private func makeTLSBootstrap(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger) - -> EventLoopFuture { + private func makeTLSBootstrap( + requester: Requester, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture { var tlsConfig = self.tlsConfiguration switch self.clientConfiguration.httpVersion.configuration { case .automatic: @@ -408,11 +443,13 @@ extension HTTPConnectionPool.ConnectionFactory { options -> NIOClientTCPBootstrapProtocol in tsBootstrap + .channelOption(NIOTSChannelOptions.waitForActivity, value: self.clientConfiguration.networkFrameworkWaitForConnectivity) .connectTimeout(deadline - NIODeadline.now()) .tlsOptions(options) .channelInitializer { channel in do { try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler()) + try channel.pipeline.syncOperations.addHandler(NWWaitingHandler(requester: requester, connectionID: connectionID)) // we don't need to set a TLS deadline for NIOTS connections, since the // TLS handshake is part of the TS connection bootstrap. If the TLS // handshake times out the complete connection creation will be failed. diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift index 764ad2093..49e755733 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift @@ -467,6 +467,16 @@ extension HTTPConnectionPool: HTTPConnectionRequester { $0.failedToCreateNewConnection(error, connectionID: connectionID) } } + + func waitingForConnectivity(_ connectionID: HTTPConnectionPool.Connection.ID, error: Error) { + self.logger.debug("waiting for connectivity", metadata: [ + "ahc-error": "\(error)", + "ahc-connection-id": "\(connectionID)", + ]) + self.modifyStateAndRunActions { + $0.waitingForConnectivity(error, connectionID: connectionID) + } + } } extension HTTPConnectionPool: HTTP1ConnectionDelegate { diff --git a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift index 6b3f7352e..d654f5a87 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift @@ -241,6 +241,12 @@ extension HTTPConnectionPool { } } + mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action { + self.lastConnectFailure = error + + return .init(request: .none, connection: .none) + } + mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action { switch self.lifecycleState { case .running: diff --git a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift index d3e6fbdcd..06fc36ad0 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP2StateMachine.swift @@ -406,6 +406,11 @@ extension HTTPConnectionPool { return .init(request: .none, connection: .scheduleBackoffTimer(connectionID, backoff: backoff, on: eventLoop)) } + mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action { + self.lastConnectFailure = error + return .init(request: .none, connection: .none) + } + mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action { // The naming of `failConnection` is a little confusing here. All it does is moving the // connection state from `.backingOff` to `.closed` here. It also returns the diff --git a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+StateMachine.swift b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+StateMachine.swift index 4d912633c..61e57941a 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+StateMachine.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+StateMachine.swift @@ -211,6 +211,14 @@ extension HTTPConnectionPool { }) } + mutating func waitingForConnectivity(_ error: Error, connectionID: Connection.ID) -> Action { + self.state.modify(http1: { http1 in + http1.waitingForConnectivity(error, connectionID: connectionID) + }, http2: { http2 in + http2.waitingForConnectivity(error, connectionID: connectionID) + }) + } + mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action { self.state.modify(http1: { http1 in http1.connectionCreationBackoffDone(connectionID) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index a6ee8956a..45b2ce0ff 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -655,6 +655,10 @@ public class HTTPClient { /// is set to `.automatic` by default which will use HTTP/2 if run over https and the server supports it, otherwise HTTP/1 public var httpVersion: HTTPVersion + /// Whether `HTTPClient` will let Network.framework sit in the `.waiting` state awaiting new network changes, or fail immediately. Defaults to `true`, + /// which is the recommended setting. Only set this to `false` when attempting to trigger a particular error path. + public var networkFrameworkWaitForConnectivity: Bool + public init( tlsConfiguration: TLSConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, @@ -671,6 +675,7 @@ public class HTTPClient { self.proxy = proxy self.decompression = decompression self.httpVersion = .automatic + self.networkFrameworkWaitForConnectivity = true } public init(tlsConfiguration: TLSConfiguration? = nil, diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift new file mode 100644 index 000000000..e05b4c37c --- /dev/null +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2022 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Network) +import Network +#endif +import NIOCore +import NIOHTTP1 +import NIOTransportServices + +final class NWWaitingHandler: ChannelInboundHandler { + typealias InboundIn = Any + typealias InboundOut = Any + + private var requester: Requester? + private let connectionID: HTTPConnectionPool.Connection.ID + + init(requester: Requester, connectionID: HTTPConnectionPool.Connection.ID) { + self.requester = requester + self.connectionID = connectionID + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + #if canImport(Network) + if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) { + if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity, let requester = self.requester { + requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) + self.requester = nil + } + } + #endif + context.fireUserInboundEventTriggered(event) + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.requester = nil + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift b/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift index fab866867..bcdaf1af2 100644 --- a/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTP2ConnectionTests.swift @@ -405,6 +405,10 @@ extension TestConnectionCreator: HTTPConnectionRequester { } wrapper.fail(error) } + + func waitingForConnectivity(_: HTTPConnectionPool.Connection.ID, error: Swift.Error) { + preconditionFailure("TODO") + } } class TestHTTP2ConnectionDelegate: HTTP2ConnectionDelegate { diff --git a/Tests/AsyncHTTPClientTests/HTTPClient+SOCKSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClient+SOCKSTests.swift index 5fdc5ac61..7cef6b58a 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClient+SOCKSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClient+SOCKSTests.swift @@ -90,8 +90,10 @@ class HTTPClientSOCKSTests: XCTestCase { } func testProxySOCKSBogusAddress() throws { + var config = HTTPClient.Configuration(proxy: .socksServer(host: "127.0..")) + config.networkFrameworkWaitForConnectivity = false let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), - configuration: .init(proxy: .socksServer(host: "127.0.."))) + configuration: config) defer { XCTAssertNoThrow(try localClient.syncShutdown()) @@ -102,8 +104,11 @@ class HTTPClientSOCKSTests: XCTestCase { // there is no socks server, so we should fail func testProxySOCKSFailureNoServer() throws { let localHTTPBin = HTTPBin() + var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost", port: localHTTPBin.port)) + config.networkFrameworkWaitForConnectivity = false + let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), - configuration: .init(proxy: .socksServer(host: "localhost", port: localHTTPBin.port))) + configuration: config) defer { XCTAssertNoThrow(try localClient.syncShutdown()) XCTAssertNoThrow(try localHTTPBin.shutdown()) @@ -113,8 +118,11 @@ class HTTPClientSOCKSTests: XCTestCase { // speak to a server that doesn't speak SOCKS func testProxySOCKSFailureInvalidServer() throws { + var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost")) + config.networkFrameworkWaitForConnectivity = false + let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), - configuration: .init(proxy: .socksServer(host: "localhost"))) + configuration: config) defer { XCTAssertNoThrow(try localClient.syncShutdown()) } @@ -124,8 +132,11 @@ class HTTPClientSOCKSTests: XCTestCase { // test a handshake failure with a misbehaving server func testProxySOCKSMisbehavingServer() throws { let socksBin = try MockSOCKSServer(expectedURL: "/socks/test", expectedResponse: "it works!", misbehave: true) + var config = HTTPClient.Configuration(proxy: .socksServer(host: "localhost", port: socksBin.port)) + config.networkFrameworkWaitForConnectivity = false + let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), - configuration: .init(proxy: .socksServer(host: "localhost", port: socksBin.port))) + configuration: config) defer { XCTAssertNoThrow(try localClient.syncShutdown()) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift index eb8d523bb..492bb4c35 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift @@ -429,7 +429,9 @@ class HTTPClientInternalTests: XCTestCase { let el2 = elg.next() let httpBin = HTTPBin(.refuse) - let client = HTTPClient(eventLoopGroupProvider: .shared(elg)) + var config = HTTPClient.Configuration() + config.networkFrameworkWaitForConnectivity = false + let client = HTTPClient(eventLoopGroupProvider: .shared(elg), configuration: config) defer { XCTAssertNoThrow(try client.syncShutdown()) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 172ee89ba..842889a2c 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -54,7 +54,10 @@ class HTTPClientNIOTSTests: XCTestCase { guard isTestingNIOTS() else { return } let httpBin = HTTPBin(.http1_1(ssl: true)) - let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup)) + var config = HTTPClient.Configuration() + config.networkFrameworkWaitForConnectivity = false + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), + configuration: config) defer { XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true)) XCTAssertNoThrow(try httpBin.shutdown()) @@ -77,7 +80,7 @@ class HTTPClientNIOTSTests: XCTestCase { func testConnectionFailError() { guard isTestingNIOTS() else { return } - let httpBin = HTTPBin(.http1_1(ssl: true)) + let httpBin = HTTPBin(.http1_1(ssl: false)) let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: .init(timeout: .init(connect: .milliseconds(100), read: .milliseconds(100)))) @@ -89,7 +92,7 @@ class HTTPClientNIOTSTests: XCTestCase { let port = httpBin.port XCTAssertNoThrow(try httpBin.shutdown()) - XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(port)/get").wait()) { + XCTAssertThrowsError(try httpClient.get(url: "http://localhost:\(port)/get").wait()) { XCTAssertEqual($0 as? HTTPClientError, .connectTimeout) } } @@ -102,9 +105,12 @@ class HTTPClientNIOTSTests: XCTestCase { tlsConfig.certificateVerification = .none tlsConfig.minimumTLSVersion = .tlsv11 tlsConfig.maximumTLSVersion = .tlsv1 + + var clientConfig = HTTPClient.Configuration(tlsConfiguration: tlsConfig) + clientConfig.networkFrameworkWaitForConnectivity = false let httpClient = HTTPClient( eventLoopGroupProvider: .shared(self.clientGroup), - configuration: .init(tlsConfiguration: tlsConfig) + configuration: clientConfig ) defer { XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true)) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index a6eff950a..572d5a5d2 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -1157,12 +1157,21 @@ class HTTPClientTests: XCTestCase { } func testStressGetHttpsSSLError() throws { + var config = HTTPClient.Configuration() + config.networkFrameworkWaitForConnectivity = false + + let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), + configuration: config) + defer { + XCTAssertNoThrow(try localClient.syncShutdown()) + } + let request = try Request(url: "https://localhost:\(self.defaultHTTPBin.port)/wait", method: .GET) let tasks = (1...100).map { _ -> HTTPClient.Task in - self.defaultClient.execute(request: request, delegate: TestHTTPDelegate()) + localClient.execute(request: request, delegate: TestHTTPDelegate()) } - let results = try EventLoopFuture.whenAllComplete(tasks.map { $0.futureResult }, on: self.defaultClient.eventLoopGroup.next()).wait() + let results = try EventLoopFuture.whenAllComplete(tasks.map { $0.futureResult }, on: localClient.eventLoopGroup.next()).wait() for result in results { switch result { @@ -1786,7 +1795,12 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try localClient.get(url: "http://localhost:\(port)").wait()) { error in if isTestingNIOTS() { - XCTAssertEqual(error as? HTTPClientError, .connectTimeout) + #if canImport(Network) + // We can't be more specific than this. + XCTAssertTrue(error is HTTPClient.NWTLSError || error is HTTPClient.NWPOSIXError) + #else + XCTFail("Impossible condition") + #endif } else { XCTAssert(error is NIOConnectionError, "Unexpected error: \(error)") } @@ -2749,6 +2763,8 @@ class HTTPClientTests: XCTestCase { if isTestingNIOTS() { // If we are using Network.framework, we set the connect timeout down very low here // because on NIOTS a failing TLS handshake manifests as a connect timeout. + // Note that we do this here to prove that we correctly manifest the underlying error: DO NOT + // CHANGE THIS TO DISABLE WAITING FOR CONNECTIVITY. timeout.connect = .milliseconds(100) } @@ -2763,7 +2779,12 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try task.wait()) { error in if isTestingNIOTS() { - XCTAssertEqual(error as? HTTPClientError, .connectTimeout) + #if canImport(Network) + // We can't be more specific than this. + XCTAssertTrue(error is HTTPClient.NWTLSError) + #else + XCTFail("Impossible condition") + #endif } else { switch error as? NIOSSLError { case .some(.handshakeFailed(.sslError(_))): break @@ -2815,7 +2836,12 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try task.wait()) { error in if isTestingNIOTS() { - XCTAssertEqual(error as? HTTPClientError, .connectTimeout) + #if canImport(Network) + // We can't be more specific than this. + XCTAssertTrue(error is HTTPClient.NWTLSError) + #else + XCTFail("Impossible condition") + #endif } else { switch error as? NIOSSLError { case .some(.handshakeFailed(.sslError(_))): break diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift index b13ff3d18..3cfb25e03 100644 --- a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift @@ -46,6 +46,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { ) XCTAssertThrowsError(try factory.makeChannel( + requester: ExplodingRequester(), connectionID: 1, deadline: .now() - .seconds(1), eventLoop: group.next(), @@ -81,6 +82,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { ) XCTAssertThrowsError(try factory.makeChannel( + requester: ExplodingRequester(), connectionID: 1, deadline: .now() + .seconds(1), eventLoop: group.next(), @@ -116,6 +118,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { ) XCTAssertThrowsError(try factory.makeChannel( + requester: ExplodingRequester(), connectionID: 1, deadline: .now() + .seconds(1), eventLoop: group.next(), @@ -153,6 +156,7 @@ class HTTPConnectionPool_FactoryTests: XCTestCase { ) XCTAssertThrowsError(try factory.makeChannel( + requester: ExplodingRequester(), connectionID: 1, deadline: .now() + .seconds(1), eventLoop: group.next(), @@ -171,3 +175,22 @@ class NeverrespondServerHandler: ChannelInboundHandler { // do nothing } } + +/// A `HTTPConnectionRequester` that will fail a test if any of its methods are ever called. +final class ExplodingRequester: HTTPConnectionRequester { + func http1ConnectionCreated(_: HTTP1Connection) { + XCTFail("http1ConnectionCreated called unexpectedly") + } + + func http2ConnectionCreated(_: HTTP2Connection, maximumStreams: Int) { + XCTFail("http2ConnectionCreated called unexpectedly") + } + + func failedToCreateHTTPConnection(_: HTTPConnectionPool.Connection.ID, error: Error) { + XCTFail("failedToCreateHTTPConnection called unexpectedly") + } + + func waitingForConnectivity(_: HTTPConnectionPool.Connection.ID, error: Error) { + XCTFail("waitingForConnectivity called unexpectedly") + } +} From 0760d587d905d94d3e6d46e32eefadb065912d12 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 09:38:25 +0100 Subject: [PATCH 2/9] Update Tests/AsyncHTTPClientTests/HTTPClientTests.swift Co-authored-by: David Nadoba --- Tests/AsyncHTTPClientTests/HTTPClientTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 572d5a5d2..eb7ea3ecc 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -2763,8 +2763,8 @@ class HTTPClientTests: XCTestCase { if isTestingNIOTS() { // If we are using Network.framework, we set the connect timeout down very low here // because on NIOTS a failing TLS handshake manifests as a connect timeout. - // Note that we do this here to prove that we correctly manifest the underlying error: DO NOT - // CHANGE THIS TO DISABLE WAITING FOR CONNECTIVITY. + // Note that we do this here to prove that we correctly manifest the underlying error: + // DO NOT CHANGE THIS TO DISABLE WAITING FOR CONNECTIVITY. timeout.connect = .milliseconds(100) } From 6df8d505430db9dcfdf091fd3b080187c207a43c Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 09:44:20 +0100 Subject: [PATCH 3/9] Change availability of NWWaitingHandler --- .../NIOTransportServices/NWWaitingHandler.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift index e05b4c37c..8b260bb38 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift @@ -14,11 +14,11 @@ #if canImport(Network) import Network -#endif import NIOCore import NIOHTTP1 import NIOTransportServices +@available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) final class NWWaitingHandler: ChannelInboundHandler { typealias InboundIn = Any typealias InboundOut = Any @@ -32,14 +32,10 @@ final class NWWaitingHandler: ChannelInbound } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - #if canImport(Network) - if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) { - if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity, let requester = self.requester { - requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) - self.requester = nil - } + if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity, let requester = self.requester { + requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) + self.requester = nil } - #endif context.fireUserInboundEventTriggered(event) } @@ -47,3 +43,4 @@ final class NWWaitingHandler: ChannelInbound self.requester = nil } } +#endif From 47d6c950e0003dbf12696f35dac8cf33af849325 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 09:55:36 +0100 Subject: [PATCH 4/9] Clean up trailing space --- Tests/AsyncHTTPClientTests/HTTPClientTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index eb7ea3ecc..de110fdb5 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -2763,7 +2763,7 @@ class HTTPClientTests: XCTestCase { if isTestingNIOTS() { // If we are using Network.framework, we set the connect timeout down very low here // because on NIOTS a failing TLS handshake manifests as a connect timeout. - // Note that we do this here to prove that we correctly manifest the underlying error: + // Note that we do this here to prove that we correctly manifest the underlying error: // DO NOT CHANGE THIS TO DISABLE WAITING FOR CONNECTIVITY. timeout.connect = .milliseconds(100) } From 8eef3efed5281656a9e654126504ada5a91914f1 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 11:42:36 +0100 Subject: [PATCH 5/9] Add more testing, remove some unnecessary code --- .../NWWaitingHandler.swift | 11 +++------ .../HTTPClientNIOTSTests.swift | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift index 8b260bb38..dc19b4b44 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift @@ -23,7 +23,7 @@ final class NWWaitingHandler: ChannelInbound typealias InboundIn = Any typealias InboundOut = Any - private var requester: Requester? + private var requester: Requester private let connectionID: HTTPConnectionPool.Connection.ID init(requester: Requester, connectionID: HTTPConnectionPool.Connection.ID) { @@ -32,15 +32,10 @@ final class NWWaitingHandler: ChannelInbound } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity, let requester = self.requester { - requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) - self.requester = nil + if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity{ + self.requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) } context.fireUserInboundEventTriggered(event) } - - func handlerRemoved(context: ChannelHandlerContext) { - self.requester = nil - } } #endif diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 842889a2c..5a8a52e64 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -16,11 +16,13 @@ #if canImport(Network) import Network #endif +import NIOConcurrencyHelpers import NIOCore import NIOPosix import NIOSSL import NIOTransportServices import XCTest +import NIOEmbedded class HTTPClientNIOTSTests: XCTestCase { var clientGroup: EventLoopGroup! @@ -78,6 +80,27 @@ class HTTPClientNIOTSTests: XCTestCase { #endif } + func testConnectionFailsFastError() { + guard isTestingNIOTS() else { return } + let httpBin = HTTPBin(.http1_1(ssl: false)) + var config = HTTPClient.Configuration() + config.networkFrameworkWaitForConnectivity = false + + let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), + configuration: config) + + defer { + XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true)) + } + + let port = httpBin.port + XCTAssertNoThrow(try httpBin.shutdown()) + + XCTAssertThrowsError(try httpClient.get(url: "http://localhost:\(port)/get").wait()) { + XCTAssertTrue($0 is NWError) + } + } + func testConnectionFailError() { guard isTestingNIOTS() else { return } let httpBin = HTTPBin(.http1_1(ssl: false)) From c14c11efc8aba9130a4291d470858317b48c067c Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 12:46:52 +0100 Subject: [PATCH 6/9] Add missing Linux tests --- Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests+XCTest.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests+XCTest.swift index cc33f6aee..77f4298ba 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests+XCTest.swift @@ -27,6 +27,7 @@ extension HTTPClientNIOTSTests { return [ ("testCorrectEventLoopGroup", testCorrectEventLoopGroup), ("testTLSFailError", testTLSFailError), + ("testConnectionFailsFastError", testConnectionFailsFastError), ("testConnectionFailError", testConnectionFailError), ("testTLSVersionError", testTLSVersionError), ("testTrustRootCertificateLoadFail", testTrustRootCertificateLoadFail), From 03dad007e74bed7e8fa313a6e2128c62a8241624 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 13:09:34 +0100 Subject: [PATCH 7/9] Clean up spacing --- .../AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift index dc19b4b44..3474a8821 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift @@ -32,7 +32,7 @@ final class NWWaitingHandler: ChannelInbound } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity{ + if let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity { self.requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) } context.fireUserInboundEventTriggered(event) From 6e357ac263f8dac4a7e06c8c67c92d27a030d9d4 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 13:17:03 +0100 Subject: [PATCH 8/9] Remove unnecessary import --- Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 5a8a52e64..595f5c2a8 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -22,7 +22,6 @@ import NIOPosix import NIOSSL import NIOTransportServices import XCTest -import NIOEmbedded class HTTPClientNIOTSTests: XCTestCase { var clientGroup: EventLoopGroup! From 19c8464f9f4699c6018ed389c32794c678ffc080 Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Wed, 1 Jun 2022 14:05:14 +0100 Subject: [PATCH 9/9] Compile on Linux --- Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 595f5c2a8..cc114cb9a 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -81,6 +81,7 @@ class HTTPClientNIOTSTests: XCTestCase { func testConnectionFailsFastError() { guard isTestingNIOTS() else { return } + #if canImport(Network) let httpBin = HTTPBin(.http1_1(ssl: false)) var config = HTTPClient.Configuration() config.networkFrameworkWaitForConnectivity = false @@ -98,6 +99,7 @@ class HTTPClientNIOTSTests: XCTestCase { XCTAssertThrowsError(try httpClient.get(url: "http://localhost:\(port)/get").wait()) { XCTAssertTrue($0 is NWError) } + #endif } func testConnectionFailError() {