diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestRequestTimeoutClassic.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestRequestTimeoutClassic.java new file mode 100644 index 0000000000..0dacf65211 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestRequestTimeoutClassic.java @@ -0,0 +1,194 @@ +/* + * ==================================================================== + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +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.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.net.URIBuilder; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestRequestTimeoutClassic { + + private static HttpServer server; + private static HttpHost target; + + private CloseableHttpClient client; + + private static final HttpRequestHandler DELAY_HANDLER = (request, response, context) -> { + int seconds = 1; + final String path = request.getPath(); // e.g. /delay/5 + final int idx = path.lastIndexOf('/'); + if (idx >= 0 && idx + 1 < path.length()) { + try { + seconds = Integer.parseInt(path.substring(idx + 1)); + } catch (final NumberFormatException ignore) { /* default 1s */ } + } + try { + TimeUnit.SECONDS.sleep(seconds); + } catch (final InterruptedException ie) { + Thread.currentThread().interrupt(); + } + response.setCode(200); + response.setEntity(new StringEntity("{\"ok\":true}", ContentType.APPLICATION_JSON)); + }; + + @BeforeAll + static void startServer() throws Exception { + server = ServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .register("/delay/*", DELAY_HANDLER) + .create(); + server.start(); + target = new HttpHost("http", "localhost", server.getLocalPort()); + } + + @AfterAll + static void stopServer() { + if (server != null) { + server.stop(); + } + } + + @BeforeEach + void createClient() { + final PoolingHttpClientConnectionManager cm = + PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(5)) + .setSocketTimeout(Timeout.ofSeconds(5)) + .build()) + .build(); + + client = HttpClients.custom() + .setConnectionManager(cm) + .build(); + } + + @AfterEach + void closeClient() throws IOException { + if (client != null) { + client.close(); + } + } + + @Test + @org.junit.jupiter.api.Timeout(value = 10, unit = TimeUnit.SECONDS) + void timesOutHard() throws Exception { + final HttpGet req = new HttpGet(new URIBuilder() + .setScheme(target.getSchemeName()) + .setHost(target.getHostName()) + .setPort(target.getPort()) + .setPath("/delay/5") + .build()); + req.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(1)) // hard end-to-end deadline + .setConnectionRequestTimeout(Timeout.ofSeconds(2)) // pool lease cap + .build()); + + final IOException ex = assertThrows(IOException.class, + () -> client.execute(req, resp -> resp.getCode())); + assertTrue(ex instanceof java.io.InterruptedIOException, + "Expected InterruptedIOException, got: " + ex.getClass()); + } + + @Test + @org.junit.jupiter.api.Timeout(value = 10, unit = TimeUnit.SECONDS) + void succeedsWithinBudget() throws Exception { + final HttpGet req = new HttpGet(new URIBuilder() + .setScheme(target.getSchemeName()) + .setHost(target.getHostName()) + .setPort(target.getPort()) + .setPath("/delay/1") + .build()); + req.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(5)) // enough for lease+connect+1s delay + .setConnectionRequestTimeout(Timeout.ofSeconds(2)) + .build()); + + final int code = client.execute(req, resp -> resp.getCode()); + assertEquals(200, code); + } + + @Test + @org.junit.jupiter.api.Timeout(value = 10, unit = TimeUnit.SECONDS) + void immediateExpirationFailsBeforeSend() throws Exception { + final HttpGet req = new HttpGet(new URIBuilder() + .setScheme(target.getSchemeName()) + .setHost(target.getHostName()) + .setPort(target.getPort()) + .setPath("/delay/1") + .build()); + req.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofMilliseconds(1)) // near-immediate expiry + .setConnectionRequestTimeout(Timeout.ofSeconds(1)) + .build()); + + assertThrows(java.io.InterruptedIOException.class, + () -> client.execute(req, resp -> resp.getCode())); + } + + @Test + @org.junit.jupiter.api.Timeout(value = 10, unit = TimeUnit.SECONDS) + void largeBudgetStillHonorsPerOpTimeouts() throws Exception { + final HttpGet req = new HttpGet(new URIBuilder() + .setScheme(target.getSchemeName()) + .setHost(target.getHostName()) + .setPort(target.getPort()) + .setPath("/delay/1") + .build()); + req.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(30)) + .setConnectionRequestTimeout(Timeout.ofSeconds(2)) + .build()); + + final int code = client.execute(req, resp -> resp.getCode()); + assertEquals(200, code); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java index a589961307..40639692f6 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java @@ -72,13 +72,16 @@ public class RequestConfig implements Cloneable { */ private final PriorityValue h2Priority; + + private final Timeout requestTimeout; + /** * Intended for CDI compatibility */ protected RequestConfig() { this(false, null, null, false, false, 0, false, null, null, DEFAULT_CONNECTION_REQUEST_TIMEOUT, null, null, DEFAULT_CONN_KEEP_ALIVE, false, false, false, null, - null); + null,null); } RequestConfig( @@ -99,7 +102,8 @@ protected RequestConfig() { final boolean hardCancellationEnabled, final boolean protocolUpgradeEnabled, final Path unixDomainSocket, - final PriorityValue h2Priority) { + final PriorityValue h2Priority, + final Timeout requestTimeout) { super(); this.expectContinueEnabled = expectContinueEnabled; this.proxy = proxy; @@ -119,6 +123,7 @@ protected RequestConfig() { this.protocolUpgradeEnabled = protocolUpgradeEnabled; this.unixDomainSocket = unixDomainSocket; this.h2Priority = h2Priority; + this.requestTimeout = requestTimeout; } /** @@ -252,6 +257,22 @@ public PriorityValue getH2Priority() { return h2Priority; } + /** + * Returns the hard end-to-end request timeout (call timeout / request deadline). + * The entire exchange must complete within this budget or the execution is aborted. + *

+ * This timeout is independent of {@linkplain #getConnectTimeout() connect} and + * {@linkplain #getResponseTimeout() response} timeouts. Pass + * {@link org.apache.hc.core5.util.Timeout#DISABLED} to disable. + *

+ * + * @return the configured request timeout, or {@code null} if not explicitly set + * @since 5.6 + */ + public Timeout getRequestTimeout() { + return requestTimeout; + } + @Override protected RequestConfig clone() throws CloneNotSupportedException { return (RequestConfig) super.clone(); @@ -279,6 +300,7 @@ public String toString() { builder.append(", protocolUpgradeEnabled=").append(protocolUpgradeEnabled); builder.append(", unixDomainSocket=").append(unixDomainSocket); builder.append(", h2Priority=").append(h2Priority); + builder.append(", requestTimeout=").append(requestTimeout); builder.append("]"); return builder.toString(); } @@ -306,7 +328,9 @@ public static RequestConfig.Builder copy(final RequestConfig config) { .setHardCancellationEnabled(config.isHardCancellationEnabled()) .setProtocolUpgradeEnabled(config.isProtocolUpgradeEnabled()) .setUnixDomainSocket(config.getUnixDomainSocket()) - .setH2Priority(config.getH2Priority()); + .setH2Priority(config.getH2Priority()) + .setRequestTimeout(config.getRequestTimeout()); + } public static class Builder { @@ -329,6 +353,7 @@ public static class Builder { private boolean protocolUpgradeEnabled; private Path unixDomainSocket; private PriorityValue h2Priority; + private Timeout requestTimeout; Builder() { super(); @@ -696,6 +721,28 @@ public Builder setH2Priority(final PriorityValue priority) { return this; } + /** + * Sets the hard end-to-end request timeout (also known as call timeout or request + * deadline). When set, the entire request execution — from connection leasing + * through connection establishment, request write, and response processing — + * must complete within this time budget or the execution will be aborted. + *

+ * Pass {@link Timeout#DISABLED} to turn this feature off. If this value is + * left unset ({@code null}), no hard request timeout is applied unless a higher + * layer provides one. A non-positive timeout value is treated as an immediate + * expiry. + *

+ * + * @param requestTimeout the request timeout to apply; use {@code Timeout.DISABLED} to disable + * @return this builder + * @since 5.6 + */ + public Builder setRequestTimeout(final Timeout requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + public RequestConfig build() { return new RequestConfig( expectContinueEnabled, @@ -715,7 +762,8 @@ public RequestConfig build() { hardCancellationEnabled, protocolUpgradeEnabled, unixDomainSocket, - h2Priority); + h2Priority, + requestTimeout); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalHttpAsyncExecRuntime.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalHttpAsyncExecRuntime.java index 28d59abaf4..8bccadbdba 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalHttpAsyncExecRuntime.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/InternalHttpAsyncExecRuntime.java @@ -78,6 +78,8 @@ static class ReUseData { private final AtomicReference endpointRef; private final AtomicReference reuseDataRef; + private volatile long deadlineMillis; + InternalHttpAsyncExecRuntime( final Logger log, final AsyncClientConnectionManager manager, @@ -92,6 +94,7 @@ static class ReUseData { this.tlsConfig = tlsConfig; this.endpointRef = new AtomicReference<>(); this.reuseDataRef = new AtomicReference<>(); + this.deadlineMillis = 0L; } @Override @@ -99,6 +102,29 @@ public boolean isEndpointAcquired() { return endpointRef.get() != null; } + private Timeout resolveTimeout(final Timeout stageTimeout, final HttpClientContext context) throws InterruptedIOException { + final RequestConfig requestConfig = context.getRequestConfigOrDefault(); + final Timeout requestTimeout = requestConfig.getRequestTimeout(); + if (requestTimeout == null || requestTimeout.isDisabled()) { + return stageTimeout; + } + final long now = System.currentTimeMillis(); + long deadline = deadlineMillis; + if (deadline == 0L) { + deadline = now + requestTimeout.toMilliseconds(); + deadlineMillis = deadline; + } + final long remainingMillis = deadline - now; + if (remainingMillis <= 0L) { + throw new InterruptedIOException("Request timeout"); + } + if (stageTimeout == null || stageTimeout.isDisabled()) { + return Timeout.ofMilliseconds(remainingMillis); + } + final long stageMillis = stageTimeout.toMilliseconds(); + return Timeout.ofMilliseconds(Math.min(stageMillis, remainingMillis)); + } + @Override public Cancellable acquireEndpoint( final String id, @@ -108,7 +134,14 @@ public Cancellable acquireEndpoint( final FutureCallback callback) { if (endpointRef.get() == null) { final RequestConfig requestConfig = context.getRequestConfigOrDefault(); - final Timeout connectionRequestTimeout = requestConfig.getConnectionRequestTimeout(); + final Timeout configuredTimeout = requestConfig.getConnectionRequestTimeout(); + final Timeout connectionRequestTimeout; + try { + connectionRequestTimeout = resolveTimeout(configuredTimeout, context); + } catch (final InterruptedIOException ex) { + callback.failed(ex); + return Operations.nonCancellable(); + } if (log.isDebugEnabled()) { log.debug("{} acquiring endpoint ({})", id, connectionRequestTimeout); } @@ -219,7 +252,14 @@ public Cancellable connectEndpoint( } final RequestConfig requestConfig = context.getRequestConfigOrDefault(); @SuppressWarnings("deprecation") - final Timeout connectTimeout = requestConfig.getConnectTimeout(); + final Timeout configuredConnectTimeout = requestConfig.getConnectTimeout(); + final Timeout connectTimeout; + try { + connectTimeout = resolveTimeout(configuredConnectTimeout, context); + } catch (final InterruptedIOException ex) { + callback.failed(ex); + return Operations.nonCancellable(); + } if (log.isDebugEnabled()) { log.debug("{} connecting endpoint ({})", ConnPoolSupport.getId(endpoint), connectTimeout); } @@ -291,8 +331,15 @@ public Cancellable execute( log.debug("{} start execution {}", ConnPoolSupport.getId(endpoint), id); } final RequestConfig requestConfig = context.getRequestConfigOrDefault(); - final Timeout responseTimeout = requestConfig.getResponseTimeout(); - if (responseTimeout != null) { + final Timeout configuredResponseTimeout = requestConfig.getResponseTimeout(); + final Timeout responseTimeout; + try { + responseTimeout = resolveTimeout(configuredResponseTimeout, context); + } catch (final InterruptedIOException ex) { + exchangeHandler.failed(ex); + return Operations.nonCancellable(); + } + if (responseTimeout != null && !responseTimeout.isDisabled()) { endpoint.setSocketTimeout(responseTimeout); } endpoint.execute(id, exchangeHandler, pushHandlerFactory, context); @@ -311,7 +358,15 @@ public void completed(final AsyncExecRuntime runtime) { log.debug("{} start execution {}", ConnPoolSupport.getId(endpoint), id); } try { + final RequestConfig requestConfig = context.getRequestConfigOrDefault(); + final Timeout configuredResponseTimeout = requestConfig.getResponseTimeout(); + final Timeout responseTimeout = resolveTimeout(configuredResponseTimeout, context); + if (responseTimeout != null && !responseTimeout.isDisabled()) { + endpoint.setSocketTimeout(responseTimeout); + } endpoint.execute(id, exchangeHandler, pushHandlerFactory, context); + } catch (final InterruptedIOException ex) { + exchangeHandler.failed(ex); } catch (final RuntimeException ex) { failed(ex); } @@ -344,7 +399,10 @@ public void markConnectionNonReusable() { @Override public AsyncExecRuntime fork() { - return new InternalHttpAsyncExecRuntime(log, manager, connectionInitiator, pushHandlerFactory, tlsConfig); + final InternalHttpAsyncExecRuntime clone = + new InternalHttpAsyncExecRuntime(log, manager, connectionInitiator, pushHandlerFactory, tlsConfig); + clone.deadlineMillis = this.deadlineMillis; + return clone; } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalExecRuntime.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalExecRuntime.java index 7813bc171d..c1b78f36fc 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalExecRuntime.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalExecRuntime.java @@ -28,6 +28,7 @@ package org.apache.hc.client5.http.impl.classic; import java.io.IOException; +import java.io.InterruptedIOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; @@ -68,11 +69,22 @@ class InternalExecRuntime implements ExecRuntime, Cancellable { private volatile Object state; private volatile TimeValue validDuration; + private final long deadlineMillis; + InternalExecRuntime( final Logger log, final HttpClientConnectionManager manager, final HttpRequestExecutor requestExecutor, final CancellableDependency cancellableDependency) { + this(log, manager, requestExecutor, cancellableDependency, null); + } + + InternalExecRuntime( + final Logger log, + final HttpClientConnectionManager manager, + final HttpRequestExecutor requestExecutor, + final CancellableDependency cancellableDependency, + final Timeout requestTimeout) { super(); this.log = log; this.manager = manager; @@ -80,6 +92,12 @@ class InternalExecRuntime implements ExecRuntime, Cancellable { this.cancellableDependency = cancellableDependency; this.endpointRef = new AtomicReference<>(); this.validDuration = TimeValue.NEG_ONE_MILLISECOND; + if (requestTimeout != null && !requestTimeout.isDisabled()) { + final long now = System.currentTimeMillis(); + this.deadlineMillis = now + requestTimeout.toMilliseconds(); + } else { + this.deadlineMillis = 0L; + } } @Override @@ -92,13 +110,30 @@ public boolean isEndpointAcquired() { return endpointRef.get() != null; } + private Timeout resolveTimeout(final Timeout stageTimeout) throws InterruptedIOException { + if (deadlineMillis == 0L) { + return stageTimeout; + } + final long now = System.currentTimeMillis(); + final long remainingMillis = deadlineMillis - now; + if (remainingMillis <= 0L) { + throw new InterruptedIOException("Request timeout"); + } + if (stageTimeout == null || stageTimeout.isDisabled()) { + return Timeout.ofMilliseconds(remainingMillis); + } + final long stageMillis = stageTimeout.toMilliseconds(); + return Timeout.ofMilliseconds(Math.min(stageMillis, remainingMillis)); + } + @Override public void acquireEndpoint( final String id, final HttpRoute route, final Object object, final HttpClientContext context) throws IOException { Args.notNull(route, "Route"); if (endpointRef.get() == null) { final RequestConfig requestConfig = context.getRequestConfigOrDefault(); - final Timeout connectionRequestTimeout = requestConfig.getConnectionRequestTimeout(); + final Timeout configuredTimeout = requestConfig.getConnectionRequestTimeout(); + final Timeout connectionRequestTimeout = resolveTimeout(configuredTimeout); if (log.isDebugEnabled()) { log.debug("{} acquiring endpoint ({})", id, connectionRequestTimeout); } @@ -157,7 +192,8 @@ private void connectEndpoint(final ConnectionEndpoint endpoint, final HttpClient } final RequestConfig requestConfig = context.getRequestConfigOrDefault(); @SuppressWarnings("deprecation") - final Timeout connectTimeout = requestConfig.getConnectTimeout(); + final Timeout configuredConnectTimeout = requestConfig.getConnectTimeout(); + final Timeout connectTimeout = resolveTimeout(configuredConnectTimeout); if (log.isDebugEnabled()) { log.debug("{} connecting endpoint ({})", ConnPoolSupport.getId(endpoint), connectTimeout); } @@ -223,8 +259,9 @@ public ClassicHttpResponse execute( throw new RequestFailedException("Request aborted"); } final RequestConfig requestConfig = context.getRequestConfigOrDefault(); - final Timeout responseTimeout = requestConfig.getResponseTimeout(); - if (responseTimeout != null) { + final Timeout configuredResponseTimeout = requestConfig.getResponseTimeout(); + final Timeout responseTimeout = resolveTimeout(configuredResponseTimeout); + if (responseTimeout != null && !responseTimeout.isDisabled()) { endpoint.setSocketTimeout(responseTimeout); } if (log.isDebugEnabled()) { @@ -305,8 +342,18 @@ public boolean cancel() { } @Override - public ExecRuntime fork(final CancellableDependency cancellableDependency) { - return new InternalExecRuntime(log, manager, requestExecutor, cancellableDependency); + public ExecRuntime fork(final CancellableDependency newDependency) { + final Timeout remainingTimeout; + if (deadlineMillis == 0L) { + remainingTimeout = null; + } else { + final long now = System.currentTimeMillis(); + final long remainingMillis = deadlineMillis - now; + remainingTimeout = remainingMillis > 0L + ? Timeout.ofMilliseconds(remainingMillis) + : Timeout.ZERO_MILLISECONDS; + } + return new InternalExecRuntime(log, manager, requestExecutor, newDependency, remainingTimeout); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalHttpClient.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalHttpClient.java index 07265fcb4b..82918c9c42 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalHttpClient.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/InternalHttpClient.java @@ -29,6 +29,7 @@ import java.io.Closeable; import java.io.IOException; +import java.io.InterruptedIOException; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Function; @@ -56,7 +57,6 @@ import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.impl.io.HttpRequestExecutor; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; @@ -65,18 +65,10 @@ import org.apache.hc.core5.io.ModalCloseable; import org.apache.hc.core5.net.URIAuthority; import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Internal implementation of {@link CloseableHttpClient}. - *

- * Concurrent message exchanges executed by this client will get assigned to - * separate connections leased from the connection pool. - *

- * - * @since 4.3 - */ @Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL) @Internal class InternalHttpClient extends CloseableHttpClient implements Configurable { @@ -95,7 +87,7 @@ class InternalHttpClient extends CloseableHttpClient implements Configurable { private final RequestConfig defaultConfig; private final ConcurrentLinkedQueue closeables; - public InternalHttpClient( + InternalHttpClient( final HttpClientConnectionManager connManager, final HttpRequestExecutor requestExecutor, final ExecChainElement execChain, @@ -121,7 +113,10 @@ public InternalHttpClient( this.closeables = closeables != null ? new ConcurrentLinkedQueue<>(closeables) : null; } - private HttpRoute determineRoute(final HttpHost target, final HttpRequest request, final HttpContext context) throws HttpException { + private HttpRoute determineRoute( + final HttpHost target, + final org.apache.hc.core5.http.HttpRequest request, + final HttpContext context) throws HttpException { return this.routePlanner.determineRoute(target, request, context); } @@ -151,6 +146,7 @@ protected CloseableHttpResponse doExecute( Args.notNull(request, "HTTP request"); try { final HttpClientContext localcontext = contextAdaptor.apply(context); + RequestConfig config = null; if (request instanceof Configurable) { config = ((Configurable) request).getConfig(); @@ -169,21 +165,32 @@ protected CloseableHttpResponse doExecute( request.setAuthority(new URIAuthority(resolvedTarget)); } } - final HttpRoute route = determineRoute( - resolvedTarget, - request, - localcontext); + final HttpRoute route = determineRoute(resolvedTarget, request, localcontext); final String exchangeId = ExecSupport.getNextExchangeId(); localcontext.setExchangeId(exchangeId); if (LOG.isDebugEnabled()) { LOG.debug("{} preparing request execution", exchangeId); } - final ExecRuntime execRuntime = new InternalExecRuntime(LOG, connManager, requestExecutor, - request instanceof CancellableDependency ? (CancellableDependency) request : null); + final RequestConfig effectiveCfg = localcontext.getRequestConfig(); + final Timeout requestTimeout = effectiveCfg != null ? effectiveCfg.getRequestTimeout() : null; + + final ExecRuntime execRuntime = new InternalExecRuntime( + LOG, + connManager, + requestExecutor, + request instanceof CancellableDependency ? (CancellableDependency) request : null, + requestTimeout); + final ExecChain.Scope scope = new ExecChain.Scope(exchangeId, route, request, execRuntime, localcontext); - final ClassicHttpResponse response = this.execChain.execute(ClassicRequestBuilder.copy(request).build(), scope); + + final ClassicHttpResponse response = + this.execChain.execute(ClassicRequestBuilder.copy(request).build(), scope); return CloseableHttpResponse.adapt(response); + + } catch (final InterruptedIOException ioEx) { + // treat our deadline violations as I/O timeouts + throw ioEx; } catch (final HttpException httpException) { throw new ClientProtocolException(httpException.getMessage(), httpException); } @@ -216,5 +223,4 @@ public void close(final CloseMode closeMode) { } } } - } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRequestTimeoutExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRequestTimeoutExample.java new file mode 100644 index 0000000000..4477503c08 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRequestTimeoutExample.java @@ -0,0 +1,145 @@ +/* + * ==================================================================== + * 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.http.examples; + +import java.io.InterruptedIOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.async.methods.SimpleRequestProducer; +import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.message.StatusLine; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; + +/** + * Demonstrates per-request hard end-to-end timeout (query timeout / request deadline). + */ +public class AsyncClientRequestTimeoutExample { + + public static void main(final String[] args) throws Exception { + + // No default requestTimeout at the client level (leave it opt-in per request). + try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom().build()) { + + client.start(); + + final HttpHost host = new HttpHost("https", "httpbin.org"); + + // 1) This one should TIME OUT (server delays ~5s, our requestTimeout is 2s) + final SimpleHttpRequest willTimeout = SimpleRequestBuilder.get() + .setHttpHost(host) + .setPath("/delay/5") + .build(); + willTimeout.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(2)) + .build()); + + System.out.println("Executing (expected timeout): " + willTimeout); + + final Future f1 = client.execute( + SimpleRequestProducer.create(willTimeout), + SimpleResponseConsumer.create(), + new FutureCallback() { + @Override + public void completed(final SimpleHttpResponse response) { + System.out.println(willTimeout + " -> " + new StatusLine(response)); + System.out.println(response.getBodyText()); + } + + @Override + public void failed(final Exception ex) { + System.out.println(willTimeout + " -> FAILED: " + ex); + if (ex instanceof InterruptedIOException) { + System.out.println("As expected: hard request timeout triggered."); + } + } + + @Override + public void cancelled() { + System.out.println(willTimeout + " -> CANCELLED"); + } + }); + + try { + f1.get(); // Will throw ExecutionException wrapping InterruptedIOException + } catch (final ExecutionException ee) { + final Throwable cause = ee.getCause(); + if (cause instanceof InterruptedIOException) { + System.out.println("Future failed with InterruptedIOException (OK): " + cause.getMessage()); + } else { + System.out.println("Unexpected failure type: " + cause); + } + } + + // 2) This one should SUCCEED (server delays ~1s, our requestTimeout is 3s) + final SimpleHttpRequest willSucceed = SimpleRequestBuilder.get() + .setHttpHost(host) + .setPath("/delay/1") + .build(); + willSucceed.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(3)) // <--- longer budget + .build()); + + System.out.println("Executing (expected success): " + willSucceed); + + final Future f2 = client.execute( + SimpleRequestProducer.create(willSucceed), + SimpleResponseConsumer.create(), + new FutureCallback() { + @Override + public void completed(final SimpleHttpResponse response) { + System.out.println(willSucceed + " -> " + new StatusLine(response)); + System.out.println(response.getBodyText()); + } + + @Override + public void failed(final Exception ex) { + System.out.println(willSucceed + " -> FAILED: " + ex); + } + + @Override + public void cancelled() { + System.out.println(willSucceed + " -> CANCELLED"); + } + }); + + f2.get(); // Should complete normally + + System.out.println("Shutting down"); + client.close(CloseMode.GRACEFUL); + } + } +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientCallTimeoutExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientCallTimeoutExample.java new file mode 100644 index 0000000000..75a773316c --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientCallTimeoutExample.java @@ -0,0 +1,91 @@ +/* + * ==================================================================== + * 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.http.examples; + +import java.io.IOException; + +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.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.util.Timeout; + +public class ClassicClientCallTimeoutExample { + + public static void main(final String[] args) throws Exception { + + // Non-deprecated: set connect/socket timeouts via ConnectionConfig + final PoolingHttpClientConnectionManager cm = + PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig( + ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(10)) + .setSocketTimeout(Timeout.ofSeconds(10)) + .build()) + .build(); + + try (final CloseableHttpClient client = HttpClients.custom() + .setConnectionManager(cm) + .build()) { + + // ---- Expected TIMEOUT (hard call deadline) ---- + final HttpGet slow = new HttpGet("https://httpbin.org/delay/5"); + slow.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(2)) // hard end-to-end cap + .setConnectionRequestTimeout(Timeout.ofSeconds(3)) // don't hang on pool lease + .build()); + + final HttpClientResponseHandler handler = (ClassicHttpResponse response) -> { + return response.getCode() + " " + response.getReasonPhrase(); + }; + + System.out.println("Executing (expected timeout): " + slow.getPath()); + try { + client.execute(slow, handler); // will throw by design + System.out.println("UNEXPECTED: completed"); + } catch (final IOException ex) { + System.out.println("As expected: " + ex.getClass().getSimpleName() + " - " + ex.getMessage()); + } + + // ---- Expected SUCCESS within budget (use HTTP to avoid TLS variance) ---- + final HttpGet fast = new HttpGet("http://httpbin.org/delay/1"); // HTTP on purpose + fast.setConfig(RequestConfig.custom() + .setRequestTimeout(Timeout.ofSeconds(8)) // generous end-to-end budget + .setConnectionRequestTimeout(Timeout.ofSeconds(2)) // quick fail if pool stuck + .build()); + + System.out.println("Executing (expected success): " + fast.getPath()); + final String ok = client.execute(fast, handler); + System.out.println("OK: " + ok); + } + } +}