From 828a4aac9031db4582ce1b87874dc74629285af5 Mon Sep 17 00:00:00 2001 From: Ryan Schmitt Date: Mon, 9 Jun 2025 09:55:40 -0700 Subject: [PATCH] Add socket timeout integration tests This change adds integration test coverage for various types of read timeouts. The test coverage includes HTTP, HTTPS, and HTTP over UDS if available. The `/random` request handlers have been augmented with delay support, so that the clients can read the response headers and then time out while reading the entity. The tests make use of `ConnectionConfig`, `ResponseTimeout`, and (on the sync client only) `SocketConfig`, and demonstrate the precedence among these config options when more than one is supplied. One issue uncovered by these tests is that graceful closure of the async client does not work when UDS socket timeouts have fired. To work around this until the issue can be root caused, the async UDS tests use `CloseMode.IMMEDIATE`. --- .../testing/async/AsyncRandomHandler.java | 38 +++- .../testing/classic/RandomHandler.java | 101 ++++++++--- .../async/AbstractIntegrationTestBase.java | 24 +++ .../testing/async/TestAsyncSocketTimeout.java | 166 ++++++++++++++++++ .../async/StandardTestClientBuilder.java | 12 ++ .../async/TestAsyncClientBuilder.java | 5 + .../extension/async/TestAsyncResources.java | 14 ++ .../extension/async/TestAsyncServer.java | 10 +- .../sync/AbstractIntegrationTestBase.java | 8 +- .../testing/sync/TestSocketTimeout.java | 142 +++++++++++++++ 10 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncSocketTimeout.java create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSocketTimeout.java diff --git a/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/async/AsyncRandomHandler.java b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/async/AsyncRandomHandler.java index de144e3ad1..27370ee8ef 100644 --- a/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/async/AsyncRandomHandler.java +++ b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/async/AsyncRandomHandler.java @@ -43,6 +43,7 @@ import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.MethodNotSupportedException; +import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.message.BasicHttpResponse; import org.apache.hc.core5.http.nio.AsyncEntityProducer; @@ -53,8 +54,11 @@ import org.apache.hc.core5.http.nio.StreamChannel; import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityProducer; import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.net.WWWFormCodec; import org.apache.hc.core5.util.Asserts; +import static java.nio.charset.StandardCharsets.UTF_8; + /** * A handler that generates random data. */ @@ -93,6 +97,22 @@ public void handleRequest( } catch (final URISyntaxException ex) { throw new ProtocolException(ex.getMessage(), ex); } + final String query = uri.getQuery(); + int delayMs = 0; + boolean drip = false; + if (query != null) { + final List params = WWWFormCodec.parse(query, UTF_8); + for (final NameValuePair param : params) { + final String name = param.getName(); + final String value = param.getValue(); + if ("delay".equals(name)) { + delayMs = Integer.parseInt(value); + } else if ("drip".equals(name)) { + drip = "1".equals(value); + } + } + } + final String path = uri.getPath(); final int slash = path.lastIndexOf('/'); if (slash != -1) { @@ -109,7 +129,7 @@ public void handleRequest( n = 1 + (int)(Math.random() * 79.0); } final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK); - final AsyncEntityProducer entityProducer = new RandomBinAsyncEntityProducer(n); + final AsyncEntityProducer entityProducer = new RandomBinAsyncEntityProducer(n, delayMs, drip); entityProducerRef.set(entityProducer); responseChannel.sendResponse(response, entityProducer, context); } else { @@ -162,12 +182,22 @@ public static class RandomBinAsyncEntityProducer extends AbstractBinAsyncEntityP private final long length; private long remaining; private final ByteBuffer buffer; + private final int delayMs; + private final boolean drip; + private volatile long deadline; public RandomBinAsyncEntityProducer(final long len) { + this(len, 0, false); + } + + public RandomBinAsyncEntityProducer(final long len, final int delayMs, final boolean drip) { super(512, ContentType.DEFAULT_TEXT); length = len; remaining = len; buffer = ByteBuffer.allocate(1024); + this.delayMs = delayMs; + this.deadline = System.currentTimeMillis() + (drip ? 0 : delayMs); + this.drip = drip; } @Override @@ -192,6 +222,10 @@ public int availableData() { @Override protected void produceData(final StreamChannel channel) throws IOException { + if (System.currentTimeMillis() < deadline) { + return; + } + final int chunk = Math.min((int) (remaining < Integer.MAX_VALUE ? remaining : Integer.MAX_VALUE), buffer.remaining()); for (int i = 0; i < chunk; i++) { final byte b = RANGE[(int) (Math.random() * RANGE.length)]; @@ -205,6 +239,8 @@ protected void produceData(final StreamChannel channel) throws IOExc if (remaining <= 0 && buffer.position() == 0) { channel.endStream(); + } else if (drip) { + deadline = System.currentTimeMillis() + delayMs; } } diff --git a/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/classic/RandomHandler.java b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/classic/RandomHandler.java index 5c157dd6b7..38715f75f6 100644 --- a/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/classic/RandomHandler.java +++ b/httpclient5-testing/src/main/java/org/apache/hc/client5/testing/classic/RandomHandler.java @@ -29,10 +29,12 @@ import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.util.List; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; @@ -40,10 +42,14 @@ import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.MethodNotSupportedException; +import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.io.HttpRequestHandler; import org.apache.hc.core5.http.io.entity.AbstractHttpEntity; import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.net.WWWFormCodec; + +import static java.nio.charset.StandardCharsets.UTF_8; /** * A handler that generates random data. @@ -84,6 +90,22 @@ public void handle(final ClassicHttpRequest request, } catch (final URISyntaxException ex) { throw new ProtocolException(ex.getMessage(), ex); } + final String query = uri.getQuery(); + int delayMs = 0; + boolean drip = false; + if (query != null) { + final List params = WWWFormCodec.parse(query, UTF_8); + for (final NameValuePair param : params) { + final String name = param.getName(); + final String value = param.getValue(); + if ("delay".equals(name)) { + delayMs = Integer.parseInt(value); + } else if ("drip".equals(name)) { + drip = "1".equals(value); + } + } + } + final String path = uri.getPath(); final int slash = path.lastIndexOf('/'); if (slash != -1) { @@ -100,7 +122,7 @@ public void handle(final ClassicHttpRequest request, n = 1 + (int)(Math.random() * 79.0); } response.setCode(HttpStatus.SC_OK); - response.setEntity(new RandomEntity(n)); + response.setEntity(new RandomEntity(n, delayMs, drip)); } else { throw new ProtocolException("Invalid request path: " + path); } @@ -120,6 +142,12 @@ public static class RandomEntity extends AbstractHttpEntity { /** The length of the random data to generate. */ protected final long length; + /** The duration of the delay before sending the response entity. */ + protected final int delayMs; + + /** Whether to delay after each chunk sent. If {@code false}, + * will only delay at the start of the response entity. */ + protected final boolean drip; /** * Creates a new entity generating the given amount of data. @@ -128,8 +156,22 @@ public static class RandomEntity extends AbstractHttpEntity { * 0 to maxint */ public RandomEntity(final long len) { + this(len, 0, false); + } + + /** + * Creates a new entity generating the given amount of data. + * + * @param len the number of random bytes to generate, + * 0 to maxint + * @param delayMs how long to wait before sending the first byte + * @param drip whether to repeat the delay after each byte + */ + public RandomEntity(final long len, final int delayMs, final boolean drip) { super((ContentType) null, null); - length = len; + this.length = len; + this.delayMs = delayMs; + this.drip = drip; } /** @@ -185,31 +227,48 @@ public InputStream getContent() { @Override public void writeTo(final OutputStream out) throws IOException { - final int blocksize = 2048; - int remaining = (int) length; // range checked in constructor - final byte[] data = new byte[Math.min(remaining, blocksize)]; + try { + final int blocksize = 2048; + int remaining = (int) length; // range checked in constructor + final byte[] data = new byte[Math.min(remaining, blocksize)]; - while (remaining > 0) { - final int end = Math.min(remaining, data.length); + out.flush(); + if (!drip) { + delay(); + } + while (remaining > 0) { + final int end = Math.min(remaining, data.length); - double value = 0.0; - for (int i = 0; i < end; i++) { - // we get 5 random characters out of one random value - if (i % 5 == 0) { - value = Math.random(); + double value = 0.0; + for (int i = 0; i < end; i++) { + // we get 5 random characters out of one random value + if (i % 5 == 0) { + value = Math.random(); + } + value = value * RANGE.length; + final int d = (int) value; + value = value - d; + data[i] = RANGE[d]; } - value = value * RANGE.length; - final int d = (int) value; - value = value - d; - data[i] = RANGE[d]; - } - out.write(data, 0, end); - out.flush(); + out.write(data, 0, end); + out.flush(); - remaining = remaining - end; + remaining = remaining - end; + if (drip) { + delay(); + } + } + } finally { + out.close(); } - out.close(); + } + private void delay() throws IOException { + try { + Thread.sleep(delayMs); + } catch (final InterruptedException ex) { + throw new InterruptedIOException(); + } } @Override diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/AbstractIntegrationTestBase.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/AbstractIntegrationTestBase.java index afafb21594..f6affbb046 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/AbstractIntegrationTestBase.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/AbstractIntegrationTestBase.java @@ -28,6 +28,7 @@ package org.apache.hc.client5.testing.async; import java.net.InetSocketAddress; +import java.nio.file.Path; import java.util.function.Consumer; import org.apache.hc.client5.testing.extension.async.ClientProtocolLevel; @@ -48,9 +49,17 @@ abstract class AbstractIntegrationTestBase { @RegisterExtension private final TestAsyncResources testResources; + private final boolean useUnixDomainSocket; protected AbstractIntegrationTestBase(final URIScheme scheme, final ClientProtocolLevel clientProtocolLevel, final ServerProtocolLevel serverProtocolLevel) { + this(scheme, clientProtocolLevel, serverProtocolLevel, false); + } + + protected AbstractIntegrationTestBase( + final URIScheme scheme, final ClientProtocolLevel clientProtocolLevel, + final ServerProtocolLevel serverProtocolLevel, final boolean useUnixDomainSocket) { this.testResources = new TestAsyncResources(scheme, clientProtocolLevel, serverProtocolLevel, TIMEOUT); + this.useUnixDomainSocket = useUnixDomainSocket; } public URIScheme scheme() { @@ -72,6 +81,9 @@ public void configureServer(final Consumer serverCusto public HttpHost startServer() throws Exception { final TestAsyncServer server = testResources.server(); final InetSocketAddress inetSocketAddress = server.start(); + if (useUnixDomainSocket) { + testResources.udsProxy().start(); + } return new HttpHost(testResources.scheme().id, "localhost", inetSocketAddress.getPort()); } @@ -80,9 +92,21 @@ public void configureClient(final Consumer clientCustomi } public TestAsyncClient startClient() throws Exception { + if (useUnixDomainSocket) { + final Path socketPath = getUnixDomainSocket(); + testResources.configureClient(builder -> { + builder.setUnixDomainSocket(socketPath); + }); + } final TestAsyncClient client = testResources.client(); client.start(); return client; } + public Path getUnixDomainSocket() throws Exception { + if (useUnixDomainSocket) { + return testResources.udsProxy().getSocketPath(); + } + return null; + } } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncSocketTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncSocketTimeout.java new file mode 100644 index 0000000000..4911cbec5b --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncSocketTimeout.java @@ -0,0 +1,166 @@ +/* + * ==================================================================== + * 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.RequestConfig; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.testing.extension.async.ClientProtocolLevel; +import org.apache.hc.client5.testing.extension.async.ServerProtocolLevel; +import org.apache.hc.client5.testing.extension.async.TestAsyncClient; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.Method; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.VersionInfo; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.SocketTimeoutException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +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; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +abstract class AbstractTestSocketTimeout extends AbstractIntegrationTestBase { + protected AbstractTestSocketTimeout(final URIScheme scheme, final ClientProtocolLevel clientProtocolLevel, + final ServerProtocolLevel serverProtocolLevel, final boolean useUnixDomainSocket) { + super(scheme, clientProtocolLevel, serverProtocolLevel, useUnixDomainSocket); + } + + @Timeout(5) + @ParameterizedTest + @ValueSource(strings = { + "150,0,150,false", + "0,150,150,false", + "150,0,150,true", + "0,150,150,true", + // ResponseTimeout overrides socket timeout + "2000,150,150,false", + "2000,150,150,true" + }) + void testReadTimeouts(final String param) throws Throwable { + checkAssumptions(); + final String[] params = param.split(","); + final int connConfigTimeout = Integer.parseInt(params[0]); + final long responseTimeout = Integer.parseInt(params[1]); + final long expectedDelayMs = Long.parseLong(params[2]); + final boolean drip = Boolean.parseBoolean(params[3]); + configureServer(bootstrap -> bootstrap + .register("/random/*", AsyncRandomHandler::new)); + final HttpHost target = startServer(); + + final TestAsyncClient client = startClient(); + final PoolingAsyncClientConnectionManager connManager = client.getConnectionManager(); + if (connConfigTimeout > 0) { + connManager.setDefaultConnectionConfig(ConnectionConfig.custom() + .setSocketTimeout(connConfigTimeout, MILLISECONDS) + .build()); + } + final SimpleHttpRequest request = SimpleHttpRequest.create(Method.GET, target, + "/random/10240?delay=500&drip=" + (drip ? 1 : 0)); + if (responseTimeout > 0) { + request.setConfig(RequestConfig.custom() + .setUnixDomainSocket(getUnixDomainSocket()) + .setResponseTimeout(responseTimeout, MILLISECONDS).build()); + } + + final long startTime = System.nanoTime(); + final Throwable cause = assertThrows(ExecutionException.class, () -> client.execute(request, null).get()) + .getCause(); + final long actualDelayMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime); + + assertInstanceOf(SocketTimeoutException.class, cause); + assertTrue(actualDelayMs > expectedDelayMs / 2, + format("Socket read timed out too soon (only %,d out of %,d ms)", actualDelayMs, expectedDelayMs)); + assertTrue(actualDelayMs < expectedDelayMs * 2, + format("Socket read timed out too late (%,d out of %,d ms)", actualDelayMs, expectedDelayMs)); + + closeClient(client); + } + + void checkAssumptions() { + } + + void closeClient(final TestAsyncClient client) { + client.close(CloseMode.GRACEFUL); + } +} + +public class TestAsyncSocketTimeout { + @Nested + class Http extends AbstractTestSocketTimeout { + public Http() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD, ServerProtocolLevel.STANDARD, false); + } + } + + @Nested + class Https extends AbstractTestSocketTimeout { + public Https() { + super(URIScheme.HTTPS, ClientProtocolLevel.STANDARD, ServerProtocolLevel.STANDARD, false); + } + } + + @Nested + class Uds extends AbstractTestSocketTimeout { + public Uds() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD, ServerProtocolLevel.STANDARD, true); + } + + @Override + void checkAssumptions() { + assumeTrue(determineJRELevel() >= 16, "Async UDS requires Java 16+"); + final String[] components = VersionInfo + .loadVersionInfo("org.apache.hc.core5", getClass().getClassLoader()) + .getRelease() + .split("[-.]"); + final int majorVersion = Integer.parseInt(components[0]); + final int minorVersion = Integer.parseInt(components[1]); + assumeFalse(majorVersion <= 5 && minorVersion <= 3, "Async UDS requires HttpCore 5.4+"); + } + + @Override + void closeClient(final TestAsyncClient client) { + // TODO: Why does `CloseMode.GRACEFUL` (the test client default) + // block until the IOReactor shutdown timeout when using UDS? + client.close(CloseMode.IMMEDIATE); + } + } +} + diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java index 1df164a8c6..efaa6d1081 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.testing.extension.async; +import java.nio.file.Path; import java.util.Collection; import org.apache.hc.client5.http.AuthenticationStrategy; @@ -35,6 +36,7 @@ import org.apache.hc.client5.http.auth.AuthSchemeFactory; import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; 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; @@ -50,6 +52,7 @@ import org.apache.hc.core5.http.nio.ssl.TlsStrategy; import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; final class StandardTestClientBuilder implements TestAsyncClientBuilder { @@ -166,6 +169,14 @@ public TestAsyncClientBuilder setDefaultCredentialsProvider(final CredentialsPro return this; } + @Override + public TestAsyncClientBuilder setUnixDomainSocket(final Path unixDomainSocket) { + this.clientBuilder.setDefaultRequestConfig(RequestConfig.custom() + .setUnixDomainSocket(unixDomainSocket) + .build()); + return this; + } + @Override public TestAsyncClient build() throws Exception { final PoolingAsyncClientConnectionManager connectionManager = connectionManagerBuilder @@ -177,6 +188,7 @@ public TestAsyncClient build() throws Exception { .build(); final CloseableHttpAsyncClient client = clientBuilder .setIOReactorConfig(IOReactorConfig.custom() + .setSelectInterval(TimeValue.ofMilliseconds(10)) .setSoTimeout(timeout) .build()) .setConnectionManager(connectionManager) diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncClientBuilder.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncClientBuilder.java index 3f12b77cf0..4107a4cc43 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncClientBuilder.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncClientBuilder.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.testing.extension.async; +import java.nio.file.Path; import java.util.Collection; import org.apache.hc.client5.http.AuthenticationStrategy; @@ -110,6 +111,10 @@ default TestAsyncClientBuilder setDefaultCredentialsProvider(CredentialsProvider throw new UnsupportedOperationException("Operation not supported by " + getProtocolLevel()); } + default TestAsyncClientBuilder setUnixDomainSocket(Path unixDomainSocket) { + throw new UnsupportedOperationException("Operation not supported by " + getProtocolLevel()); + } + TestAsyncClient build() throws Exception; } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncResources.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncResources.java index 0c470fbdf9..6cb512c347 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncResources.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncResources.java @@ -30,6 +30,7 @@ import java.util.function.Consumer; import org.apache.hc.client5.testing.extension.sync.TestClientResources; +import org.apache.hc.client5.testing.extension.sync.UnixDomainProxyServer; import org.apache.hc.core5.http.URIScheme; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.util.Asserts; @@ -52,6 +53,7 @@ public class TestAsyncResources implements AfterEachCallback { private TestAsyncServer server; private TestAsyncClient client; + private UnixDomainProxyServer udsProxy; public TestAsyncResources(final URIScheme scheme, final ClientProtocolLevel clientProtocolLevel, final ServerProtocolLevel serverProtocolLevel, final Timeout timeout) { this.scheme = scheme != null ? scheme : URIScheme.HTTP; @@ -84,11 +86,23 @@ public void afterEach(final ExtensionContext extensionContext) { if (client != null) { client.close(CloseMode.GRACEFUL); } + if (udsProxy != null) { + udsProxy.close(); + } if (server != null) { server.shutdown(TimeValue.ofSeconds(5)); } } + public UnixDomainProxyServer udsProxy() throws Exception { + if (udsProxy == null) { + final TestAsyncServer testServer = server(); + final int port = testServer.getServerAddress().getPort(); + udsProxy = new UnixDomainProxyServer(port); + } + return udsProxy; + } + public URIScheme scheme() { return this.scheme; } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncServer.java index e75ef11b6e..96e3e4d381 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncServer.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/TestAsyncServer.java @@ -48,6 +48,7 @@ public class TestAsyncServer { private final Http1Config http1Config; private final HttpProcessor httpProcessor; private final Decorator exchangeHandlerDecorator; + private volatile InetSocketAddress serverAddress; TestAsyncServer( final H2TestServer server, @@ -98,7 +99,14 @@ public InetSocketAddress start() throws Exception { } server.configure(exchangeHandlerDecorator); server.configure(httpProcessor); - return server.start(); + serverAddress = server.start(); + return serverAddress; } + public InetSocketAddress getServerAddress() { + if (serverAddress == null) { + throw new IllegalStateException("Server has not been started"); + } + return serverAddress; + } } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/AbstractIntegrationTestBase.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/AbstractIntegrationTestBase.java index 6e6b24b893..3071a38da0 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/AbstractIntegrationTestBase.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/AbstractIntegrationTestBase.java @@ -87,7 +87,7 @@ public void configureClient(final Consumer clientCustomizer) public TestClient client() throws Exception { if (useUnixDomainSocket) { - final Path socketPath = testResources.udsProxy().getSocketPath(); + final Path socketPath = getUnixDomainSocket(); testResources.configureClient(builder -> { builder.setUnixDomainSocket(socketPath); }); @@ -95,4 +95,10 @@ public TestClient client() throws Exception { return testResources.client(); } + public Path getUnixDomainSocket() throws Exception { + if (useUnixDomainSocket) { + return testResources.udsProxy().getSocketPath(); + } + return null; + } } diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSocketTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSocketTimeout.java new file mode 100644 index 0000000000..2c8194231f --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestSocketTimeout.java @@ -0,0 +1,142 @@ +/* + * ==================================================================== + * 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.classic.methods.HttpGet; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.BasicHttpClientResponseHandler; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.testing.classic.RandomHandler; +import org.apache.hc.client5.testing.extension.sync.ClientProtocolLevel; +import org.apache.hc.client5.testing.extension.sync.TestClient; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.io.SocketConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.SocketTimeoutException; +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +abstract class AbstractTestSocketTimeout extends AbstractIntegrationTestBase { + protected AbstractTestSocketTimeout(final URIScheme scheme, final ClientProtocolLevel clientProtocolLevel, + final boolean useUnixDomainSocket) { + super(scheme, clientProtocolLevel, useUnixDomainSocket); + } + + @Timeout(5) + @ParameterizedTest + @ValueSource(strings = { + "150,0,0,150,false", + "0,150,0,150,false", + "150,0,0,150,true", + "0,150,0,150,true", + // ConnectionConfig overrides SocketConfig + "50,150,0,150,false", + "1000,150,0,150,false", + "50,150,0,150,true", + "1000,150,0,150,true", + // ResponseTimeout overrides socket timeout + "2000,2000,150,150,false", + "2000,2000,150,150,true" + }) + void testReadTimeouts(final String param) throws Exception { + final String[] params = param.split(","); + final int socketConfigTimeout = Integer.parseInt(params[0]); + final int connConfigTimeout = Integer.parseInt(params[1]); + final long responseTimeout = Integer.parseInt(params[2]); + final long expectedDelayMs = Long.parseLong(params[3]); + final boolean drip = Boolean.parseBoolean(params[4]); + configureServer(bootstrap -> bootstrap + .register("/random/*", new RandomHandler())); + final HttpHost target = startServer(); + + final TestClient client = client(); + final PoolingHttpClientConnectionManager connManager = client.getConnectionManager(); + if (socketConfigTimeout > 0) { + connManager.setDefaultSocketConfig(SocketConfig.custom() + .setSoTimeout(socketConfigTimeout, MILLISECONDS) + .build()); + } + if (connConfigTimeout > 0) { + connManager.setDefaultConnectionConfig(ConnectionConfig.custom() + .setSocketTimeout(connConfigTimeout, MILLISECONDS) + .build()); + } + final HttpGet request = new HttpGet(new URI("/random/10240?delay=1000&drip=" + (drip ? 1 : 0))); + if (responseTimeout > 0) { + request.setConfig(RequestConfig.custom() + .setUnixDomainSocket(getUnixDomainSocket()) + .setResponseTimeout(responseTimeout, MILLISECONDS) + .build()); + } + + final long startTime = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> + client.execute(target, request, new BasicHttpClientResponseHandler())); + final long actualDelayMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime); + + assertTrue(actualDelayMs > expectedDelayMs / 2, + format("Socket read timed out too soon (only %,d out of %,d ms)", actualDelayMs, expectedDelayMs)); + assertTrue(actualDelayMs < expectedDelayMs * 2, + format("Socket read timed out too late (%,d out of %,d ms)", actualDelayMs, expectedDelayMs)); + } +} + +public class TestSocketTimeout { + @Nested + class Http extends AbstractTestSocketTimeout { + public Http() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD, false); + } + } + + @Nested + class Https extends AbstractTestSocketTimeout { + public Https() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD, false); + } + } + + @Nested + class Uds extends AbstractTestSocketTimeout { + public Uds() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD, true); + } + } +} +