Skip to content

Commit 35c5056

Browse files
aldexisok2c
authored andcommitted
Ensure connection is closed immediately upon socket timeout
1 parent 031c622 commit 35c5056

File tree

2 files changed

+105
-0
lines changed

2 files changed

+105
-0
lines changed

httpcore5-testing/src/test/java/org/apache/hc/core5/testing/classic/ClassicIntegrationTest.java

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@
3232
import java.io.InputStream;
3333
import java.io.InputStreamReader;
3434
import java.io.OutputStream;
35+
import java.net.InetSocketAddress;
36+
import java.net.Socket;
37+
import java.net.SocketTimeoutException;
3538
import java.nio.charset.Charset;
3639
import java.nio.charset.StandardCharsets;
3740
import java.util.ArrayList;
3841
import java.util.List;
3942
import java.util.Random;
43+
import java.util.concurrent.CountDownLatch;
44+
import java.util.function.Consumer;
4045

4146
import org.apache.hc.core5.http.ClassicHttpRequest;
4247
import org.apache.hc.core5.http.ClassicHttpResponse;
@@ -52,6 +57,11 @@
5257
import org.apache.hc.core5.http.ProtocolException;
5358
import org.apache.hc.core5.http.URIScheme;
5459
import org.apache.hc.core5.http.config.Http1Config;
60+
import org.apache.hc.core5.http.impl.HttpProcessors;
61+
import org.apache.hc.core5.http.impl.io.DefaultBHttpClientConnectionFactory;
62+
import org.apache.hc.core5.http.impl.io.HttpRequestExecutor;
63+
import org.apache.hc.core5.http.io.HttpClientConnection;
64+
import org.apache.hc.core5.http.io.HttpConnectionFactory;
5565
import org.apache.hc.core5.http.io.entity.AbstractHttpEntity;
5666
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
5767
import org.apache.hc.core5.http.io.entity.EntityUtils;
@@ -62,17 +72,22 @@
6272
import org.apache.hc.core5.http.protocol.DefaultHttpProcessor;
6373
import org.apache.hc.core5.http.protocol.HttpContext;
6474
import org.apache.hc.core5.http.protocol.HttpCoreContext;
75+
import org.apache.hc.core5.http.protocol.HttpProcessor;
6576
import org.apache.hc.core5.http.protocol.RequestConnControl;
6677
import org.apache.hc.core5.http.protocol.RequestContent;
6778
import org.apache.hc.core5.http.protocol.RequestExpectContinue;
6879
import org.apache.hc.core5.http.protocol.RequestTargetHost;
6980
import org.apache.hc.core5.http.protocol.RequestUserAgent;
81+
import org.apache.hc.core5.net.URIAuthority;
82+
import org.apache.hc.core5.testing.SSLTestContexts;
7083
import org.apache.hc.core5.testing.extension.classic.ClassicTestResources;
7184
import org.apache.hc.core5.util.Timeout;
7285
import org.junit.jupiter.api.Assertions;
7386
import org.junit.jupiter.api.Test;
7487
import org.junit.jupiter.api.extension.RegisterExtension;
7588

89+
import javax.net.ssl.SSLSocket;
90+
7691
abstract class ClassicIntegrationTest {
7792

7893
private static final Timeout TIMEOUT = Timeout.ofMinutes(1);
@@ -773,4 +788,86 @@ void testHeaderTooLargePost() throws Exception {
773788
}
774789
}
775790

791+
@Test
792+
void testImmediateCloseUponSocketTimeout() throws Exception {
793+
final int socketTimeoutMillis = 1000;
794+
final int serverDelayMillis = 5 * socketTimeoutMillis;
795+
796+
final ClassicTestServer server = testResources.server();
797+
798+
final CountDownLatch serverDelayStarted = new CountDownLatch(1);
799+
800+
// Configure server to delay significantly before responding
801+
server.register("*", (request, response, context) -> {
802+
serverDelayStarted.countDown();
803+
try {
804+
// Delay much longer than socket timeout
805+
Thread.sleep(serverDelayMillis);
806+
} catch (final InterruptedException ex) {
807+
Thread.currentThread().interrupt();
808+
}
809+
response.setCode(HttpStatus.SC_OK);
810+
response.setEntity(new StringEntity("Delayed response", ContentType.TEXT_PLAIN));
811+
});
812+
813+
server.start();
814+
815+
final HttpHost host = new HttpHost(scheme.id, "localhost", server.getPort());
816+
final HttpCoreContext context = HttpCoreContext.create();
817+
final HttpConnectionFactory<? extends HttpClientConnection> connFactory =
818+
DefaultBHttpClientConnectionFactory.builder().build();
819+
820+
final HttpRequestExecutor requestExecutor = new HttpRequestExecutor();
821+
final HttpProcessor processor = HttpProcessors.client();
822+
823+
final BasicClassicHttpRequest request = new BasicClassicHttpRequest(Method.GET, "/");
824+
request.setAuthority(new URIAuthority(host));
825+
request.setScheme(host.getSchemeName());
826+
827+
runWithSocket(host, socketTimeoutMillis, socket -> {
828+
try {
829+
final HttpClientConnection connection = connFactory.createConnection(socket);
830+
831+
requestExecutor.preProcess(request, processor, context);
832+
833+
final long startTime = System.currentTimeMillis();
834+
try (final ClassicHttpResponse response = requestExecutor.execute(
835+
request, connection, context)) {
836+
Assertions.fail("Expected SocketTimeoutException not thrown");
837+
} catch (final SocketTimeoutException e) {
838+
// Expected due to server delay exceeding socket timeout
839+
}
840+
841+
final long endTime = System.currentTimeMillis();
842+
final long durationMillis = endTime - startTime;
843+
// Assert that the timeout occurred around the socket timeout duration
844+
Assertions.assertTrue(durationMillis >= socketTimeoutMillis && durationMillis < socketTimeoutMillis * 2,
845+
String.format("Socket timeout should occur around %dms (took %dms)",
846+
socketTimeoutMillis, durationMillis));
847+
} catch (final IOException | HttpException e) {
848+
Assertions.fail("IOException during request execution: " + e.getMessage());
849+
}
850+
});
851+
}
852+
853+
private static void runWithSocket(
854+
final HttpHost host, final int socketTimeoutMillis, final Consumer<Socket> socketConsumer)
855+
throws IOException {
856+
try (final Socket clientSocket = new Socket()) {
857+
clientSocket.setSoTimeout(socketTimeoutMillis);
858+
clientSocket.connect(new InetSocketAddress(host.getHostName(), host.getPort()));
859+
if (host.getSchemeName().equalsIgnoreCase("http")) {
860+
socketConsumer.accept(clientSocket);
861+
return;
862+
}
863+
864+
try (final SSLSocket sslSocket = (SSLSocket) SSLTestContexts.createClientSSLContext()
865+
.getSocketFactory()
866+
.createSocket(clientSocket, host.getHostName(), -1, true)) {
867+
sslSocket.startHandshake();
868+
869+
socketConsumer.accept(sslSocket);
870+
}
871+
}
872+
}
776873
}

httpcore5/src/main/java/org/apache/hc/core5/http/impl/io/HttpRequestExecutor.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
package org.apache.hc.core5.http.impl.io;
2929

3030
import java.io.IOException;
31+
import java.net.SocketTimeoutException;
3132

3233
import org.apache.hc.core5.annotation.Contract;
3334
import org.apache.hc.core5.annotation.ThreadingBehavior;
@@ -217,6 +218,13 @@ public ClassicHttpResponse execute(
217218
} catch (final HttpException | SSLException ex) {
218219
Closer.closeQuietly(conn);
219220
throw ex;
221+
} catch (final SocketTimeoutException ex) {
222+
// If the server isn't responsive, we want to close the connection immediately
223+
// We set the socket timeout to a minimal value in such a case, because in some cases, the connection
224+
// might only have access to an SSLSocket that it will try to close gracefully.
225+
conn.setSocketTimeout(Timeout.ONE_MILLISECOND);
226+
Closer.close(conn, CloseMode.IMMEDIATE);
227+
throw ex;
220228
} catch (final IOException | RuntimeException ex) {
221229
Closer.close(conn, CloseMode.IMMEDIATE);
222230
throw ex;

0 commit comments

Comments
 (0)