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..3474a8821 --- /dev/null +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWWaitingHandler.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// 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 +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 + + 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 let waitingEvent = event as? NIOTSNetworkEvents.WaitingForConnectivity { + self.requester.waitingForConnectivity(self.connectionID, error: HTTPClient.NWErrorHandler.translateError(waitingEvent.transientError)) + } + context.fireUserInboundEventTriggered(event) + } +} +#endif 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+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), diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 172ee89ba..cc114cb9a 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -16,6 +16,7 @@ #if canImport(Network) import Network #endif +import NIOConcurrencyHelpers import NIOCore import NIOPosix import NIOSSL @@ -54,7 +55,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()) @@ -75,9 +79,32 @@ class HTTPClientNIOTSTests: XCTestCase { #endif } + func testConnectionFailsFastError() { + guard isTestingNIOTS() else { return } + #if canImport(Network) + 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) + } + #endif + } + 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 +116,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 +129,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..de110fdb5 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") + } +}