[java-shib-shared] branch main updated: JSSH-55: Implement HttpClient support for an overall request timeout

Brent Putman putmanb at georgetown.edu
Thu Nov 7 23:06:57 UTC 2024


This is an automated email from the git hooks/post-receive script.

putmanb pushed a commit to branch main
in repository java-shib-shared.

View the commit online:
http://git.shibboleth.net/view/?p=java-shib-shared.git;a=commit;h=1008004b231d8159778e747d29bcde09c0016182

The following commit(s) were added to refs/heads/main by this push:
     new 1008004b JSSH-55: Implement HttpClient support for an overall request timeout
1008004b is described below

commit 1008004b231d8159778e747d29bcde09c0016182
Author: Brent Putman <putmanb at georgetown.edu>
AuthorDate: Fri Oct 18 11:14:30 2024 -0400

    JSSH-55: Implement HttpClient support for an overall request timeout
    
    Initial implementation with tests.
---
 shib-networking/pom.xml                            |  16 ++
 .../shared/httpclient/HttpClientBuilder.java       |  83 +++++-
 .../shared/httpclient/HttpClientSupport.java       |  16 ++
 .../httpclient/RequestTimeLimitingHttpClient.java  | 169 +++++++++++
 .../RequestTimeoutExceededException.java           |  65 +++++
 .../RequestTimeLimitingHttpClientTest.java         | 314 +++++++++++++++++++++
 .../src/test/resources/logback-test.xml            |  17 ++
 7 files changed, 679 insertions(+), 1 deletion(-)

diff --git a/shib-networking/pom.xml b/shib-networking/pom.xml
index bd6c7a84..e63b2490 100644
--- a/shib-networking/pom.xml
+++ b/shib-networking/pom.xml
@@ -15,6 +15,7 @@
     <packaging>jar</packaging>
 
     <properties>
+        <jetty.version>11.0.9</jetty.version>
         <automatic.module.name>net.shibboleth.networking</automatic.module.name>
         <checkstyle.configLocation>${project.basedir}/../resources/checkstyle/checkstyle.xml</checkstyle.configLocation>
     </properties>
@@ -66,6 +67,21 @@
             <artifactId>spring-web</artifactId>
             <scope>test</scope>
         </dependency>
+        
+        <dependency>
+            <groupId>${jetty.groupId}</groupId>
+            <artifactId>jetty-server</artifactId>
+            <version>${jetty.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${jetty.groupId}</groupId>
+            <artifactId>jetty-util</artifactId>
+            <version>${jetty.version}</version>
+            <scope>test</scope>
+        </dependency>
+        
+        
     </dependencies>
 
 </project>
diff --git a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientBuilder.java b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientBuilder.java
index 65d2b11d..a2bb076a 100644
--- a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientBuilder.java
+++ b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientBuilder.java
@@ -20,6 +20,8 @@ import java.net.UnknownHostException;
 import java.nio.charset.Charset;
 import java.time.Duration;
 import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -38,6 +40,7 @@ import org.apache.hc.client5.http.impl.io.ManagedHttpClientConnectionFactory;
 import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
 import org.apache.hc.client5.http.io.HttpClientConnectionManager;
 import org.apache.hc.client5.http.io.ManagedHttpClientConnection;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
 import org.apache.hc.client5.http.routing.HttpRoutePlanner;
 import org.apache.hc.client5.http.socket.LayeredConnectionSocketFactory;
 import org.apache.hc.core5.http.HttpHost;
@@ -113,6 +116,14 @@ public class HttpClientBuilder {
      * Default value: (60 seconds) */
     @Nonnull private Duration responseTimeout;
     
+    /** Maximum allowed length of time for the entire request/response operation to complete.
+     *  Default value: null */
+    @Nullable private Duration requestTimeout;
+
+    /** Size of the {@link ScheduledExecutorService} used to implement requestTimeout handling. 
+     *  Default value: 100 */
+    private int requestTimeoutThreadPoolSize;
+
     /**
      * Max total simultaneous connections allowed by the pooling connection manager.
      */
@@ -247,6 +258,9 @@ public class HttpClientBuilder {
         retryStrategy = null;
         schemePortResolver = null;
         
+        requestTimeout = null;
+        requestTimeoutThreadPoolSize = 100;
+        
         disableAuthCaching = false;
         disableAutomaticRetries = false;
         disableConnectionState = false;
@@ -292,6 +306,9 @@ public class HttpClientBuilder {
         retryStrategy = null;
         schemePortResolver = null;
         
+        requestTimeout = null;
+        requestTimeoutThreadPoolSize = 100;
+        
         disableAuthCaching = false;
         disableAutomaticRetries = false;
         disableConnectionState = false;
@@ -397,6 +414,60 @@ public class HttpClientBuilder {
         responseTimeout = timeout;
     }
 
+    /**
+     * Gets the maximum allowed length of time for the entire request/response operation to complete.
+     * 
+     * <p>
+     * A value of null means no statically-configured timeout. A timeout may also be supplied on a
+     * per-request basis via {@link HttpClientContext}. See
+     * {@link HttpClientSupport#addRequestTimeout(HttpClientContext, Duration)}.
+     * </p>
+     * 
+     * @return the request timeout
+     */
+    @Nullable public Duration getRequestTimeout() {
+        return requestTimeout;
+    }
+
+    /**
+     * Sets the maximum allowed length of time for the entire request/response operation to complete.
+     * 
+     * <p>
+     * A value of null means no statically-configured timeout. A timeout may also be supplied on a
+     * per-request basis via {@link HttpClientContext}. See
+     * {@link HttpClientSupport#addRequestTimeout(HttpClientContext, Duration)}.
+     * </p>
+     * 
+     * @param timeout the request timeout
+     */
+    public void setRequestTimeout(@Nullable final Duration timeout) {
+        if (timeout != null) {
+            Constraint.isLessThanOrEqual(Integer.MAX_VALUE, timeout.toMillis(), "Timeout too large");
+        }
+
+        requestTimeout = timeout;
+    }
+
+    /**
+     * Gets the size of the {@link ScheduledExecutorService} used to implement requestTimeout handling. 
+     * 
+     * @return the request timeout
+     */
+    public int getRequestTimeoutThreadPoolSize() {
+        return requestTimeoutThreadPoolSize;
+    }
+
+    /**
+     * Sets the size of the {@link ScheduledExecutorService} used to implement requestTimeout handling. 
+     * 
+     * @param size the request timeout
+     */
+    public void setRequestTimeoutThreadPoolSize(final int size) {
+        Constraint.isGreaterThanOrEqual(1, size, "Thread pool size is too small");
+
+        requestTimeoutThreadPoolSize = size;
+    }
+
     /**
      * Gets the maximum period inactivity between two consecutive data packets. A value of less than 1 ms
      * indicates no timeout.
@@ -1035,7 +1106,17 @@ public class HttpClientBuilder {
      */
     @Nonnull public HttpClient buildClient() throws Exception {
         decorateApacheBuilder();
-        return new ContextHandlingHttpClient(getApacheBuilder().build(), getStaticContextHandlers());
+
+        final HttpClient apacheClient = getApacheBuilder().build();
+        assert apacheClient != null;
+
+        final HttpClient contextHandlingClient = new ContextHandlingHttpClient(apacheClient,
+                getStaticContextHandlers());
+
+        final ScheduledExecutorService executorService =
+                Executors.newScheduledThreadPool(getRequestTimeoutThreadPoolSize());
+        assert executorService != null;
+        return new RequestTimeLimitingHttpClient(contextHandlingClient, executorService, getRequestTimeout());
     }
 
     /**
diff --git a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientSupport.java b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientSupport.java
index 3660573a..cca70040 100644
--- a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientSupport.java
+++ b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/HttpClientSupport.java
@@ -23,6 +23,7 @@ import java.nio.charset.Charset;
 import java.nio.charset.UnsupportedCharsetException;
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -58,6 +59,10 @@ public final class HttpClientSupport {
     @Nonnull @NotEmpty
     public static final String CONTEXT_KEY_DYNAMIC_CONTEXT_HANDLERS = "java-support.DynamicContextHandlers";
 
+    /** Context key for an overall request timeout. Must be an instance of {@link Duration}. **/
+    @Nonnull @NotEmpty
+    public static final String CONTEXT_KEY_REQUEST_TIMEOUT = "java-support.RequestTimeout";
+
     /** Constructor to prevent instantiation. */
     private HttpClientSupport() { }
     
@@ -219,6 +224,17 @@ public final class HttpClientSupport {
         }
         list.add(handler);
     }
+    
+    /**
+     * Add overall request timeout, as implemented by {@link RequestTimeLimitingHttpClient}.
+     * 
+     * @param context the client context
+     * @param timeout the request timeout
+     */
+    public static void addRequestTimeout(@Nonnull final HttpClientContext context, @Nullable final Duration timeout) {
+        Constraint.isNotNull(context, "HttpClientContext was null");
+        context.setAttribute(CONTEXT_KEY_REQUEST_TIMEOUT, timeout);
+    }
 
 // Checkstyle: CyclomaticComplexity OFF
     /**
diff --git a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClient.java b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClient.java
new file mode 100644
index 00000000..cc06dbad
--- /dev/null
+++ b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClient.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed 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.
+ */
+
+package net.shibboleth.shared.httpclient;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.impl.classic.RequestFailedException;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.Cancellable;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.io.ModalCloseable;
+import org.slf4j.Logger;
+
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An {@link HttpClient} implementation which implements an overall request timeout for a wrapped client instance.
+ * 
+ * <p>
+ * The request timeout {@link Duration} may be supplied as either a statically-configured value on the client instance,
+ * or via the passed {@link HttpContext} in the attribute {@link HttpClientSupport#CONTEXT_KEY_REQUEST_TIMEOUT}.
+ * A non-null context value will override the static client value.
+ * </p>
+ * 
+ * <p>
+ * Requests that exceed the effective timeout will throw {@link RequestTimeoutExceededException}.
+ * </p>
+ */
+public class RequestTimeLimitingHttpClient extends AbstractHttpClient {
+    
+    /** Logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(RequestTimeLimitingHttpClient.class);
+    
+    /** The wrapped HttpClient instance. */
+    @Nonnull private HttpClient httpClient;
+    
+    /** Executor service which implements the timeout handling. */
+    @Nonnull private ScheduledExecutorService executorService;
+
+    /** Client-level request timeout. */
+    @Nullable private Duration timeout;
+    
+    /**
+     * Constructor.
+     *
+     * @param client the wrapped HttpClient instance
+     * @param executor executor service which implements the timeout handling
+     * @param requestTimeout client-level request timeout
+     */
+    public RequestTimeLimitingHttpClient(@Nonnull final HttpClient client,
+            @Nonnull final ScheduledExecutorService executor,
+            @Nullable Duration requestTimeout) {
+        super();
+        httpClient = Constraint.isNotNull(client, "HttpClient was null");
+        executorService = Constraint.isNotNull(executor, "ScheduledExecutorService was null");
+        timeout = requestTimeout;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected ClassicHttpResponse doExecute(@Nullable final HttpHost target,
+            @Nonnull final ClassicHttpRequest request,
+            @Nullable final HttpContext context) throws IOException {
+        
+        ScheduledFuture<?> future = null;
+        Duration effectiveTimeout = null;
+        try {
+            if (request instanceof Cancellable cancellableRequest) {
+                effectiveTimeout = resolveEffectiveTimeout(context);
+                if (effectiveTimeout != null) {
+                    log.debug("Scheduling request timeout of duration: {}", effectiveTimeout);
+                    future = executorService.schedule(cancellableRequest::cancel,
+                            effectiveTimeout.toMillis(), TimeUnit.MILLISECONDS);
+                }
+            }
+            return httpClient.executeOpen(target, request, context);
+        } catch (RequestFailedException e) {
+            // If we can match on the message, throw a nicer exception. But if it doesn't match, not a big deal,
+            // we'll just get the original RequestFailedException.
+            final String message = e.getMessage();
+            if (message.contains("aborted") || message.contains("cancelled")) {
+                throw new RequestTimeoutExceededException(String.format("Request to '%s'exceeded timeout '%s'",
+                        request.getRequestUri(), effectiveTimeout), e);
+            }
+            throw e;
+        } finally {
+            if (future != null) {
+                future.cancel(true);
+            }
+        }
+    }
+    
+    /**
+     * Resolve the effective request timeout to use for the current request.
+     * 
+     * @param context the client context
+     * @return the effective timeout, may be null
+     */
+    @Nullable protected Duration resolveEffectiveTimeout(@Nullable final HttpContext context) {
+        if (context != null) {
+            final HttpClientContext clientContext = HttpClientContext.adapt(context);
+            final Duration contextTimeout = clientContext.getAttribute(HttpClientSupport.CONTEXT_KEY_REQUEST_TIMEOUT,
+                    Duration.class);
+            if (contextTimeout != null) {
+                log.debug("Resolved effective request timeout from client context: {}", contextTimeout);
+                return contextTimeout;
+            }
+        }
+        if (timeout != null) {
+            log.debug("Resolved effective request timeout from statically-configured client value: {}", timeout);
+        } else {
+            log.debug("No effective request timeout was resolved");
+        }
+        return timeout;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void close() throws IOException {
+        // Use try/finally here just in case the client #close() throws a RuntimeException or Error
+        try {
+            if (Closeable.class.isInstance(httpClient)) {
+                Closeable.class.cast(httpClient).close();
+            }
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void close(final CloseMode closeMode) {
+        // Use try/finally here just in case the client #close() throws a RuntimeException or Error
+        try {
+            if (ModalCloseable.class.isInstance(httpClient)) {
+                ModalCloseable.class.cast(httpClient).close(closeMode);
+            }
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+
+}
diff --git a/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeoutExceededException.java b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeoutExceededException.java
new file mode 100644
index 00000000..dfc317d5
--- /dev/null
+++ b/shib-networking/src/main/java/net/shibboleth/shared/httpclient/RequestTimeoutExceededException.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed 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.
+ */
+
+package net.shibboleth.shared.httpclient;
+
+import java.io.IOException;
+
+import javax.annotation.Nullable;
+
+/**
+ * Exception that indicates that an HTTP client request exceeded a request timeout.
+ * 
+ * @see RequestTimeLimitingHttpClient
+ */
+public class RequestTimeoutExceededException extends IOException {
+
+    private static final long serialVersionUID = -4315409154151655702L;
+
+    /**
+     * Constructor.
+     */
+    public RequestTimeoutExceededException() {
+        super();
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param message exception message
+     * @param cause exception cause
+     */
+    public RequestTimeoutExceededException(@Nullable final String message, @Nullable final Throwable cause) {
+        super(message, cause);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param message exception message
+     */
+    public RequestTimeoutExceededException(@Nullable final String message) {
+        super(message);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param cause exception cause
+     */
+    public RequestTimeoutExceededException(@Nullable final Throwable cause) {
+        super(cause);
+    }
+
+}
diff --git a/shib-networking/src/test/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClientTest.java b/shib-networking/src/test/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClientTest.java
new file mode 100644
index 00000000..83edf3fe
--- /dev/null
+++ b/shib-networking/src/test/java/net/shibboleth/shared/httpclient/RequestTimeLimitingHttpClientTest.java
@@ -0,0 +1,314 @@
+/*
+ * Licensed 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.
+ */
+
+package net.shibboleth.shared.httpclient;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InterfaceAddress;
+import java.net.NetworkInterface;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.SystemDefaultDnsResolver;
+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.HttpUriRequest;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.collection.Pair;
+import net.shibboleth.shared.net.URLBuilder;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ *
+ */
+public class RequestTimeLimitingHttpClientTest {
+
+    private static final String REQUEST_BASE = "http://localhost:8080/sleep";
+    
+    private HttpClientContext context;
+    
+    private HttpClientBuilder clientBuilder;
+    
+    private HttpClient client;
+    
+    private URLBuilder urlBuilder;
+    
+    private List<InetAddress> jettyListenAddrs;
+    
+    private Server server;
+    
+    @BeforeClass
+    public void setUpClass() throws Exception {
+        resolveListenAddresses();
+        server = startServer(new MappingHandler());
+    }
+
+    @BeforeMethod
+    public void setUpMethod() throws Exception {
+        context = HttpClientContext.create();
+        clientBuilder = new HttpClientBuilder();
+        urlBuilder = new URLBuilder(REQUEST_BASE);
+    }
+    
+    @AfterMethod
+    public void tearDownMethod() throws Exception {
+        if (client instanceof Closeable closeableClient) {
+            closeableClient.close();
+        }
+    }
+
+    @AfterClass
+    public void tearDownClass() throws Exception {
+        if (server != null) {
+            server.stop();
+        }
+    }
+    
+    @Test
+    public void noTimeout() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "1"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        ClassicHttpResponse response = client.executeOpen(null, request, context);
+        Assert.assertNotNull(response);
+    }
+
+    @Test
+    public void staticTimeoutMeets() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        clientBuilder.setRequestTimeout(Duration.ofSeconds(5));
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        ClassicHttpResponse response = client.executeOpen(null, request, context);
+        Assert.assertNotNull(response);
+    }
+
+    @Test(expectedExceptions = RequestTimeoutExceededException.class)
+    public void staticTimeoutExceeds() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        clientBuilder.setRequestTimeout(Duration.ofSeconds(2));
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        client.executeOpen(null, request, context);
+    }
+
+    @Test
+    public void perRequestTimeoutMeets() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        HttpClientSupport.addRequestTimeout(context, Duration.ofSeconds(5));
+
+        ClassicHttpResponse response = client.executeOpen(null, request, context);
+        Assert.assertNotNull(response);
+    }
+
+    @Test(expectedExceptions = RequestTimeoutExceededException.class)
+    public void perRequestTimeoutExceeds() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        HttpClientSupport.addRequestTimeout(context, Duration.ofSeconds(2));
+
+        client.executeOpen(null, request, context);
+    }
+
+    @Test
+    public void staticAndPerRequestTimeoutMeets() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        clientBuilder.setRequestTimeout(Duration.ofSeconds(5));
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        HttpClientSupport.addRequestTimeout(context, Duration.ofSeconds(4));
+
+        ClassicHttpResponse response = client.executeOpen(null, request, context);
+        Assert.assertNotNull(response);
+    }
+
+    @Test(expectedExceptions = RequestTimeoutExceededException.class)
+    public void staticAndPerRequestTimeoutExceeds() throws Exception {
+        urlBuilder.getQueryParams().add(new Pair<>("seconds", "3"));
+        HttpUriRequest request = new HttpGet(urlBuilder.buildURL());
+
+        clientBuilder.setRequestTimeout(Duration.ofSeconds(5));
+        client = clientBuilder.buildClient();
+        Assert.assertTrue(RequestTimeLimitingHttpClient.class.isInstance(client));
+
+        HttpClientSupport.addRequestTimeout(context, Duration.ofSeconds(2));
+
+        client.executeOpen(null, request, context);
+    }
+
+
+    //
+    // Test Helpers
+    //
+
+    @Nonnull private Server startServer(final Handler handler) {
+        final Server server = new Server();
+        
+        ArrayList<ServerConnector> connectors = new ArrayList<>();
+        jettyListenAddrs.forEach(addr ->  {
+            final ServerConnector connector = new ServerConnector(server);
+            connector.setHost(addr.getHostAddress());
+            connector.setPort(8080);
+            connectors.add(connector);
+        });
+        server.setConnectors(connectors.toArray(new Connector[] {}));
+
+        server.setHandler(handler);
+        try {
+            server.start();
+        } catch (Exception e) {
+            try {
+                server.stop();
+            } catch (Exception e2) {}
+            throw new RuntimeException("Jetty startup failed", e);
+        }
+        final Thread serverRunner = new Thread(new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    server.join();
+                } catch (InterruptedException e) {}
+            }
+        });
+        serverRunner.start();
+        return server;
+    }
+
+    private static class MappingHandler extends AbstractHandler {
+        
+        private Map<String, Handler> handlers;
+        
+        public MappingHandler() {
+            handlers = new HashMap<>();
+            
+            handlers.put("/sleep", new SleepHandler());
+        }
+
+        public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
+                throws IOException, ServletException {
+
+            if (handlers.containsKey(target)) {
+                handlers.get(target).handle(target, baseRequest, request, response);
+            } else {
+                throw new ServletException("Unmapped target: " + target);
+            }
+        }
+        
+    }
+
+    private static class SleepHandler extends AbstractHandler {
+        public void handle(
+                final String target,
+                final Request request,
+                final HttpServletRequest servletRequest,
+                final HttpServletResponse servletResponse) throws IOException, ServletException {
+            
+            int seconds = 10; // default
+            String secondsStr = StringSupport.trimOrNull(servletRequest.getParameter("seconds"));
+            if (secondsStr != null) {
+                seconds = Integer.parseInt(secondsStr);
+            }
+            
+            try {
+                Thread.sleep(seconds * 1000);
+            } catch (InterruptedException e) {
+                throw new ServletException("Error during Thread.sleep()", e);
+            }
+
+            servletResponse.setContentType("text/plain;charset=utf-8");
+            servletResponse.setStatus(200);
+            request.setHandled(true);
+            servletResponse.getWriter().println(String.format("Ok, I just slept for %d seconds", seconds));
+            servletResponse.getWriter().flush();
+        }
+    }
+    
+    public void resolveListenAddresses() throws Exception {
+        // As of HttpClient 5.x, we need Jetty to listen on all localhost IPv4 and IPv6 addresses,
+        // b/c on connection failure (e.g. TLS handshake failure) HC will try them all,
+        // so they all have to respond similarly for the tests that expect failure via a specified exception type.
+        // This is an attempt to get them portably depending on whether IPv4 and/or IPv6 is enabled.
+
+        // Resolve the 'localhost' addrs that will be resolved and used by HttpClient
+        final List<InetAddress> localhostAddrs = CollectionSupport.listOf(SystemDefaultDnsResolver.INSTANCE.resolve("localhost"));
+
+        // Resolve available loopback and link-local interfaces
+        // Using ByteBuffer just to get hashcode() and equals() for byte[] for filtering using the Set.
+       final Set<ByteBuffer> interfaceAddrs = Collections.list(NetworkInterface.getNetworkInterfaces()).stream()
+               .map(NetworkInterface::getInterfaceAddresses)
+               .flatMap(Collection::stream)
+               .map(InterfaceAddress::getAddress)
+               .filter(addr -> addr.isLoopbackAddress() || addr.isLinkLocalAddress() )
+               .map(InetAddress::getAddress)
+               .map(ByteBuffer::wrap)
+               .collect(Collectors.toSet());
+       
+       // Retain for listening those 'localhost' addrs which correspond to enabled interfaces
+       jettyListenAddrs = localhostAddrs.stream()
+               .filter(addr -> interfaceAddrs.contains(ByteBuffer.wrap(addr.getAddress())))
+               .collect(Collectors.toList());
+    }
+
+}
diff --git a/shib-networking/src/test/resources/logback-test.xml b/shib-networking/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..ca9e2a81
--- /dev/null
+++ b/shib-networking/src/test/resources/logback-test.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <logger name="org.eclipse.jetty" level="INFO"/>
+
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <charset>UTF-8</charset>
+            <Pattern>%date{HH:mm:ss.SSS} - %level [%logger:%line] - %msg%n</Pattern>
+        </encoder>
+    </appender>
+
+    <root level="WARN">
+        <appender-ref ref="CONSOLE"/>
+    </root>
+
+</configuration>

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list