Ensure connection is closed immediately upon socket timeout
diff --git a/httpcore5-testing/src/test/java/org/apache/hc/core5/testing/classic/ClassicIntegrationTest.java b/httpcore5-testing/src/test/java/org/apache/hc/core5/testing/classic/ClassicIntegrationTest.java
index 22f2742..73ff66f 100644
--- a/httpcore5-testing/src/test/java/org/apache/hc/core5/testing/classic/ClassicIntegrationTest.java
+++ b/httpcore5-testing/src/test/java/org/apache/hc/core5/testing/classic/ClassicIntegrationTest.java
@@ -32,11 +32,16 @@
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.function.Consumer;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
@@ -52,6 +57,11 @@
import org.apache.hc.core5.http.ProtocolException;
import org.apache.hc.core5.http.URIScheme;
import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.impl.HttpProcessors;
+import org.apache.hc.core5.http.impl.io.DefaultBHttpClientConnectionFactory;
+import org.apache.hc.core5.http.impl.io.HttpRequestExecutor;
+import org.apache.hc.core5.http.io.HttpClientConnection;
+import org.apache.hc.core5.http.io.HttpConnectionFactory;
import org.apache.hc.core5.http.io.entity.AbstractHttpEntity;
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
import org.apache.hc.core5.http.io.entity.EntityUtils;
@@ -62,18 +72,23 @@
import org.apache.hc.core5.http.protocol.DefaultHttpProcessor;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
import org.apache.hc.core5.http.protocol.RequestConnControl;
import org.apache.hc.core5.http.protocol.RequestContent;
import org.apache.hc.core5.http.protocol.RequestExpectContinue;
import org.apache.hc.core5.http.protocol.RequestTE;
import org.apache.hc.core5.http.protocol.RequestTargetHost;
import org.apache.hc.core5.http.protocol.RequestUserAgent;
+import org.apache.hc.core5.net.URIAuthority;
+import org.apache.hc.core5.testing.SSLTestContexts;
import org.apache.hc.core5.testing.extension.classic.ClassicTestResources;
import org.apache.hc.core5.util.Timeout;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import javax.net.ssl.SSLSocket;
+
abstract class ClassicIntegrationTest {
private static final Timeout TIMEOUT = Timeout.ofMinutes(1);
@@ -775,4 +790,86 @@ void testHeaderTooLargePost() throws Exception {
}
}
+ @Test
+ void testImmediateCloseUponSocketTimeout() throws Exception {
+ final int socketTimeoutMillis = 1000;
+ final int serverDelayMillis = 5 * socketTimeoutMillis;
+
+ final ClassicTestServer server = testResources.server();
+
+ final CountDownLatch serverDelayStarted = new CountDownLatch(1);
+
+ // Configure server to delay significantly before responding
+ server.register("*", (request, response, context) -> {
+ serverDelayStarted.countDown();
+ try {
+ // Delay much longer than socket timeout
+ Thread.sleep(serverDelayMillis);
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ response.setCode(HttpStatus.SC_OK);
+ response.setEntity(new StringEntity("Delayed response", ContentType.TEXT_PLAIN));
+ });
+
+ server.start();
+
+ final HttpHost host = new HttpHost(scheme.id, "localhost", server.getPort());
+ final HttpCoreContext context = HttpCoreContext.create();
+ final HttpConnectionFactory<? extends HttpClientConnection> connFactory =
+ DefaultBHttpClientConnectionFactory.builder().build();
+
+ final HttpRequestExecutor requestExecutor = new HttpRequestExecutor();
+ final HttpProcessor processor = HttpProcessors.client();
+
+ final BasicClassicHttpRequest request = new BasicClassicHttpRequest(Method.GET, "/");
+ request.setAuthority(new URIAuthority(host));
+ request.setScheme(host.getSchemeName());
+
+ runWithSocket(host, socketTimeoutMillis, socket -> {
+ try {
+ final HttpClientConnection connection = connFactory.createConnection(socket);
+
+ requestExecutor.preProcess(request, processor, context);
+
+ final long startTime = System.currentTimeMillis();
+ try (final ClassicHttpResponse response = requestExecutor.execute(
+ request, connection, context)) {
+ Assertions.fail("Expected SocketTimeoutException not thrown");
+ } catch (final SocketTimeoutException e) {
+ // Expected due to server delay exceeding socket timeout
+ }
+
+ final long endTime = System.currentTimeMillis();
+ final long durationMillis = endTime - startTime;
+ // Assert that the timeout occurred around the socket timeout duration
+ Assertions.assertTrue(durationMillis >= socketTimeoutMillis && durationMillis < socketTimeoutMillis * 2,
+ String.format("Socket timeout should occur around %dms (took %dms)",
+ socketTimeoutMillis, durationMillis));
+ } catch (final IOException | HttpException e) {
+ Assertions.fail("IOException during request execution: " + e.getMessage());
+ }
+ });
+ }
+
+ private static void runWithSocket(
+ final HttpHost host, final int socketTimeoutMillis, final Consumer<Socket> socketConsumer)
+ throws IOException {
+ try (final Socket clientSocket = new Socket()) {
+ clientSocket.setSoTimeout(socketTimeoutMillis);
+ clientSocket.connect(new InetSocketAddress(host.getHostName(), host.getPort()));
+ if (host.getSchemeName().equalsIgnoreCase("http")) {
+ socketConsumer.accept(clientSocket);
+ return;
+ }
+
+ try (final SSLSocket sslSocket = (SSLSocket) SSLTestContexts.createClientSSLContext()
+ .getSocketFactory()
+ .createSocket(clientSocket, host.getHostName(), -1, true)) {
+ sslSocket.startHandshake();
+
+ socketConsumer.accept(sslSocket);
+ }
+ }
+ }
}
diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/impl/io/HttpRequestExecutor.java b/httpcore5/src/main/java/org/apache/hc/core5/http/impl/io/HttpRequestExecutor.java
index 9bff205..5208c0f 100644
--- a/httpcore5/src/main/java/org/apache/hc/core5/http/impl/io/HttpRequestExecutor.java
+++ b/httpcore5/src/main/java/org/apache/hc/core5/http/impl/io/HttpRequestExecutor.java
@@ -28,6 +28,7 @@
package org.apache.hc.core5.http.impl.io;
import java.io.IOException;
+import java.net.SocketTimeoutException;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.ThreadingBehavior;
@@ -217,6 +218,13 @@ public ClassicHttpResponse execute(
} catch (final HttpException | SSLException ex) {
Closer.closeQuietly(conn);
throw ex;
+ } catch (final SocketTimeoutException ex) {
+ // If the server isn't responsive, we want to close the connection immediately
+ // We set the socket timeout to a minimal value in such a case, because in some cases, the connection
+ // might only have access to an SSLSocket that it will try to close gracefully.
+ conn.setSocketTimeout(Timeout.ONE_MILLISECOND);
+ Closer.close(conn, CloseMode.IMMEDIATE);
+ throw ex;
} catch (final IOException | RuntimeException ex) {
Closer.close(conn, CloseMode.IMMEDIATE);
throw ex;