diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java
new file mode 100644
index 0000000000..2a09af5e19
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java
@@ -0,0 +1,104 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.async;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.client5.testing.SSLTestContexts;
+import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.util.TimeValue;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.ExecutionException;
+
+import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TestAsyncTlsHandshakeTimeout {
+ private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500);
+
+ @Timeout(5)
+ @ParameterizedTest
+ @ValueSource(strings = { "false", "true" })
+ void testTimeout(final boolean sendServerHello) throws Exception {
+ final PoolingAsyncClientConnectionManager connMgr = PoolingAsyncClientConnectionManagerBuilder.create()
+ .setDefaultConnectionConfig(ConnectionConfig.custom()
+ .setConnectTimeout(5, SECONDS)
+ .setSocketTimeout(5, SECONDS)
+ .build())
+ .setTlsStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext()))
+ .setDefaultTlsConfig(TlsConfig.custom()
+ .setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS)
+ .build())
+ .build();
+ try (
+ final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello);
+ final CloseableHttpAsyncClient client = HttpAsyncClientBuilder.create()
+ .setIOReactorConfig(IOReactorConfig.custom()
+ .setSelectInterval(TimeValue.ofMilliseconds(50))
+ .build())
+ .setConnectionManager(connMgr)
+ .build()
+ ) {
+ server.start();
+ client.start();
+
+ final SimpleHttpRequest request = SimpleHttpRequest.create("GET", "https://127.0.0.1:" + server.getPort());
+ assertTimeout(request, client);
+ }
+ }
+
+ private static void assertTimeout(final SimpleHttpRequest request, final CloseableHttpAsyncClient client) {
+ final long startTime = System.nanoTime();
+ final Throwable ex = assertThrows(ExecutionException.class,
+ () -> client.execute(request, null).get()).getCause();
+ final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS);
+ assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2,
+ format("Handshake attempt timed out too soon (only %,d out of %,d ms)",
+ actualTime.toMillis(),
+ EXPECTED_TIMEOUT.toMillis()));
+ assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2,
+ format("Handshake attempt timed out too late (%,d out of %,d ms)",
+ actualTime.toMillis(),
+ EXPECTED_TIMEOUT.toMillis()));
+ assertTrue(ex.getMessage().contains(EXPECTED_TIMEOUT.toMillis() + " MILLISECONDS"), ex.getMessage());
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java
new file mode 100644
index 0000000000..9fde43e11b
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java
@@ -0,0 +1,118 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.sync;
+
+import org.apache.hc.client5.http.ConnectTimeoutException;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.client5.testing.SSLTestContexts;
+import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.net.ssl.SSLException;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+
+import static java.lang.String.format;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.hc.core5.util.ReflectionUtils.determineJRELevel;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+public class TestTlsHandshakeTimeout {
+ private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500);
+
+ @Timeout(5)
+ @ParameterizedTest
+ @ValueSource(strings = { "false", "true" })
+ void testTimeout(final boolean sendServerHello) throws Exception {
+ final PoolingHttpClientConnectionManager connMgr = PoolingHttpClientConnectionManagerBuilder.create()
+ .setDefaultConnectionConfig(ConnectionConfig.custom()
+ .setConnectTimeout(5, SECONDS)
+ .setSocketTimeout(5, SECONDS)
+ .build())
+ .setTlsSocketStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext()))
+ .setDefaultTlsConfig(TlsConfig.custom()
+ .setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS)
+ .build())
+ .build();
+ try (
+ final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello);
+ final CloseableHttpClient client = HttpClientBuilder.create()
+ .setConnectionManager(connMgr)
+ .build()
+ ) {
+ server.start();
+
+ final HttpUriRequestBase request = new HttpGet("https://127.0.0.1:" + server.getPort());
+ assertTimeout(request, client);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void assertTimeout(final ClassicHttpRequest request, final HttpClient client) {
+ final long startTime = System.nanoTime();
+ final Exception ex = assertThrows(Exception.class, () -> client.execute(request));
+ final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS);
+
+ if (determineJRELevel() == 8) {
+ assertInstanceOf(SSLException.class, ex);
+ } else {
+ assertInstanceOf(ConnectTimeoutException.class, ex);
+ }
+ assertTrue(ex.getMessage().contains("Read timed out"), ex.getMessage());
+
+ // There is a bug in Java 11: after the handshake times out, the SSLSocket implementation performs a blocking
+ // read on the socket to wait for close_notify or alert. This operation blocks until the read times out,
+ // which means that TLS handshakes take twice as long to time out on Java 11. Without a workaround, the only
+ // option is to skip the timeout duration assertions on Java 11.
+ assumeFalse(determineJRELevel() == 11, "TLS handshake timeouts are buggy on Java 11");
+
+ assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2,
+ format("Handshake attempt timed out too soon (only %,d out of %,d ms)",
+ actualTime.toMillis(),
+ EXPECTED_TIMEOUT.toMillis()));
+ assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2,
+ format("Handshake attempt timed out too late (%,d out of %,d ms)",
+ actualTime.toMillis(),
+ EXPECTED_TIMEOUT.toMillis()));
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java
new file mode 100644
index 0000000000..d77959de81
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java
@@ -0,0 +1,160 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.tls;
+
+import org.apache.hc.client5.testing.SSLTestContexts;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLEngineResult.HandshakeStatus;
+import javax.net.ssl.SSLEngineResult.Status;
+import javax.net.ssl.SSLSession;
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+
+/**
+ * This test server accepts a single TLS connection request, which will then time out. The server can run in two modes.
+ * If {@code sendServerHello} is false, the Client Hello message will be swallowed, and the client will time out while
+ * waiting for the Server Hello record. Else, the server will respond to the Client Hello with a Server Hello, and the
+ * client's connection attempt will subsequently time out while waiting for the Change Cipher Spec record from the
+ * server.
+ */
+public class TlsHandshakeTimeoutServer implements Closeable {
+ private final boolean sendServerHello;
+
+ private volatile int port = -1;
+ private volatile boolean requestReceived = false;
+ private volatile ServerSocketChannel serverSocket;
+ private volatile SocketChannel socket;
+ private volatile Throwable throwable;
+
+ public TlsHandshakeTimeoutServer(final boolean sendServerHello) {
+ this.sendServerHello = sendServerHello;
+ }
+
+ public void start() throws IOException {
+ this.serverSocket = ServerSocketChannel.open();
+ this.serverSocket.bind(new InetSocketAddress("0.0.0.0", 0));
+ this.port = ((InetSocketAddress) this.serverSocket.getLocalAddress()).getPort();
+ new Thread(this::run).start();
+ }
+
+ private void run() {
+ try {
+ socket = serverSocket.accept();
+ requestReceived = true;
+
+ if (sendServerHello) {
+ final SSLEngine sslEngine = initHandshake();
+
+ receiveClientHello(sslEngine);
+ sendServerHello(sslEngine);
+ }
+ } catch (final Throwable t) {
+ this.throwable = t;
+ }
+ }
+
+ private SSLEngine initHandshake() throws Exception {
+ final SSLContext sslContext = SSLTestContexts.createServerSSLContext();
+ final SSLEngine sslEngine = sslContext.createSSLEngine();
+ // TLSv1.2 always uses a four-way handshake, which is what we want
+ sslEngine.setEnabledProtocols(new String[]{ "TLSv1.2" });
+ sslEngine.setUseClientMode(false);
+ sslEngine.setNeedClientAuth(false);
+
+ sslEngine.beginHandshake();
+ return sslEngine;
+ }
+
+ private void receiveClientHello(final SSLEngine sslEngine) throws IOException {
+ final SSLSession session = sslEngine.getSession();
+ final ByteBuffer clientNetData = ByteBuffer.allocate(session.getPacketBufferSize());
+ final ByteBuffer clientAppData = ByteBuffer.allocate(session.getApplicationBufferSize());
+ while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_UNWRAP) {
+ socket.read(clientNetData);
+ clientNetData.flip();
+ final Status status = sslEngine.unwrap(clientNetData, clientAppData).getStatus();
+ if (status != Status.OK) {
+ throw new RuntimeException("Bad status while unwrapping data: " + status);
+ }
+ clientNetData.compact();
+ if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) {
+ sslEngine.getDelegatedTask().run();
+ }
+ }
+ }
+
+ private void sendServerHello(final SSLEngine sslEngine) throws IOException {
+ final SSLSession session = sslEngine.getSession();
+ final ByteBuffer serverAppData = ByteBuffer.allocate(session.getApplicationBufferSize());
+ final ByteBuffer serverNetData = ByteBuffer.allocate(session.getPacketBufferSize());
+ while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_WRAP) {
+ serverAppData.flip();
+ final Status status = sslEngine.wrap(serverAppData, serverNetData).getStatus();
+ if (status != Status.OK) {
+ throw new RuntimeException("Bad status while wrapping data: " + status);
+ }
+ serverNetData.flip();
+ socket.write(serverNetData);
+ serverNetData.compact();
+ if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) {
+ sslEngine.getDelegatedTask().run();
+ }
+ }
+ }
+
+ public int getPort() {
+ if (port == -1) {
+ throw new IllegalStateException("Server has not been started yet");
+ }
+ return port;
+ }
+
+ @Override
+ public void close() {
+ try {
+ if (serverSocket != null) {
+ serverSocket.close();
+ }
+ if (socket != null) {
+ socket.close();
+ }
+ } catch (final IOException ignore) {
+ }
+
+ if (throwable != null) {
+ throw new RuntimeException("Exception thrown while TlsHandshakeTimerOuter was running", throwable);
+ } else if (!requestReceived) {
+ throw new IllegalStateException("Never received a request");
+ }
+ }
+}