diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 7304626083..05f9444ed2 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -109,11 +109,35 @@ public interface AsyncHttpClientConfig { /** * Return the maximum time an {@link AsyncHttpClient} waits until the response is completed. + *

+ * By default this bounds each attempt within an exchange rather than the exchange as a whole: a redirect, a + * retry and an auth replay each start it again, so a chain of n hops may run for n times this value. Set + * {@link #isUseAbsoluteRequestDeadline()} to bound the exchange instead. * * @return the maximum time an {@link AsyncHttpClient} waits until the response is completed. */ Duration getRequestTimeout(); + /** + * Whether {@link #getRequestTimeout()} is a deadline for the whole exchange rather than for each attempt + * within it. + *

+ * A redirect, a retry and an auth replay all continue the same exchange on the same response future, but + * each builds its own timeout state. Anchoring the deadline on that state gives every hop a fresh budget, + * which is why a five-redirect chain can legitimately take six times the configured timeout today. Enabling + * this anchors it on when the exchange was submitted instead, so a later hop gets whatever is left and the + * caller's total wait is bounded by the one value. + *

+ * Off by default because turning it on shortens exchanges that rely on the per-attempt behaviour. A caller + * working to an end-to-end budget wants it on; {@link Request#getUseAbsoluteRequestDeadline()} sets it for a + * single request. + * + * @return {@code true} to treat the request timeout as a deadline for the whole exchange + */ + default boolean isUseAbsoluteRequestDeadline() { + return false; + } + /** * Is HTTP redirect enabled * diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index 75aa1bd16a..f2c44b1cf1 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -63,6 +63,7 @@ import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultEnabledProtocols; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultExpiredCookieEvictionDelay; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownEnabled; +import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseAbsoluteRequestDeadline; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownPeriod; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFilterInsecureCipherSuites; import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFollowRedirect; @@ -138,6 +139,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final int maxRequestRetry; private final LoadBalance loadBalance; private final boolean failedIpCooldownEnabled; + private final boolean useAbsoluteRequestDeadline; private final Duration failedIpCooldownPeriod; private final boolean disableUrlEncodingForBoundRequests; private final boolean useLaxCookieEncoder; @@ -243,6 +245,7 @@ private DefaultAsyncHttpClientConfig(// http int maxRequestRetry, LoadBalance loadBalance, boolean failedIpCooldownEnabled, + boolean useAbsoluteRequestDeadline, Duration failedIpCooldownPeriod, boolean disableUrlEncodingForBoundRequests, boolean useLaxCookieEncoder, @@ -348,6 +351,7 @@ private DefaultAsyncHttpClientConfig(// http this.maxRequestRetry = maxRequestRetry; this.loadBalance = loadBalance; this.failedIpCooldownEnabled = failedIpCooldownEnabled; + this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; this.failedIpCooldownPeriod = failedIpCooldownPeriod; this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests; this.useLaxCookieEncoder = useLaxCookieEncoder; @@ -518,6 +522,11 @@ public boolean isFailedIpCooldownEnabled() { return failedIpCooldownEnabled; } + @Override + public boolean isUseAbsoluteRequestDeadline() { + return useAbsoluteRequestDeadline; + } + @Override public Duration getFailedIpCooldownPeriod() { return failedIpCooldownPeriod; @@ -937,6 +946,7 @@ public static class Builder { private int maxRequestRetry = defaultMaxRequestRetry(); private LoadBalance loadBalance = defaultLoadBalance(); private boolean failedIpCooldownEnabled = defaultFailedIpCooldownEnabled(); + private boolean useAbsoluteRequestDeadline = defaultUseAbsoluteRequestDeadline(); private Duration failedIpCooldownPeriod = defaultFailedIpCooldownPeriod(); private boolean disableUrlEncodingForBoundRequests = defaultDisableUrlEncodingForBoundRequests(); private boolean useLaxCookieEncoder = defaultUseLaxCookieEncoder(); @@ -1045,6 +1055,7 @@ public Builder(AsyncHttpClientConfig config) { maxRequestRetry = config.getMaxRequestRetry(); loadBalance = config.getLoadBalance(); failedIpCooldownEnabled = config.isFailedIpCooldownEnabled(); + useAbsoluteRequestDeadline = config.isUseAbsoluteRequestDeadline(); failedIpCooldownPeriod = config.getFailedIpCooldownPeriod(); disableUrlEncodingForBoundRequests = config.isDisableUrlEncodingForBoundRequests(); useLaxCookieEncoder = config.isUseLaxCookieEncoder(); @@ -1244,6 +1255,17 @@ public Builder setFailedIpCooldownEnabled(boolean failedIpCooldownEnabled) { return this; } + /** + * @param useAbsoluteRequestDeadline whether the request timeout is a deadline for the whole exchange + * rather than for each attempt within it; see + * {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()} + * @return this + */ + public Builder setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) { + this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; + return this; + } + /** * @param failedIpCooldownPeriod how long a failed IP is deprioritized before it is re-probed; * {@code null} resets to the default. Must not be negative; use @@ -1751,6 +1773,7 @@ public DefaultAsyncHttpClientConfig build() { maxRequestRetry, loadBalance, failedIpCooldownEnabled, + useAbsoluteRequestDeadline, failedIpCooldownPeriod, disableUrlEncodingForBoundRequests, useLaxCookieEncoder, diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java index c8e44e338f..269ed9b546 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultRequest.java +++ b/client/src/main/java/org/asynchttpclient/DefaultRequest.java @@ -63,6 +63,7 @@ public class DefaultRequest implements Request { private final @Nullable Realm realm; private final @Nullable File file; private final @Nullable Boolean followRedirect; + private final @Nullable Boolean useAbsoluteRequestDeadline; private final Duration requestTimeout; private final Duration readTimeout; private final long rangeOffset; @@ -99,6 +100,45 @@ public DefaultRequest(String method, @Nullable Charset charset, ChannelPoolPartitioning channelPoolPartitioning, NameResolver nameResolver) { + this(method, uri, address, localAddress, headers, cookies, byteData, compositeByteData, stringData, + byteBufferData, byteBufData, streamData, bodyGenerator, formParams, bodyParts, virtualHost, + proxyServer, realm, file, followRedirect, requestTimeout, readTimeout, rangeOffset, charset, + channelPoolPartitioning, nameResolver, null); + } + + /** + * @param useAbsoluteRequestDeadline whether {@code requestTimeout} bounds the whole exchange rather than + * each attempt within it, or null to defer to the client config. Trailing + * rather than beside {@code followRedirect} so the original signature + * stays intact for callers that build a request without the builder. + */ + public DefaultRequest(String method, + Uri uri, + @Nullable InetAddress address, + @Nullable InetAddress localAddress, + HttpHeaders headers, + List cookies, + byte @Nullable [] byteData, + @Nullable List compositeByteData, + @Nullable String stringData, + @Nullable ByteBuffer byteBufferData, + @Nullable ByteBuf byteBufData, + @Nullable InputStream streamData, + @Nullable BodyGenerator bodyGenerator, + List formParams, + List bodyParts, + @Nullable String virtualHost, + @Nullable ProxyServer proxyServer, + @Nullable Realm realm, + @Nullable File file, + @Nullable Boolean followRedirect, + @Nullable Duration requestTimeout, + @Nullable Duration readTimeout, + long rangeOffset, + @Nullable Charset charset, + ChannelPoolPartitioning channelPoolPartitioning, + NameResolver nameResolver, + @Nullable Boolean useAbsoluteRequestDeadline) { this.method = method; this.uri = uri; this.address = address; @@ -119,6 +159,7 @@ public DefaultRequest(String method, this.realm = realm; this.file = file; this.followRedirect = followRedirect; + this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; this.requestTimeout = requestTimeout == null ? Duration.ZERO : requestTimeout; this.readTimeout = readTimeout == null ? Duration.ZERO : readTimeout; this.rangeOffset = rangeOffset; @@ -232,6 +273,11 @@ public List getBodyParts() { return followRedirect; } + @Override + public @Nullable Boolean getUseAbsoluteRequestDeadline() { + return useAbsoluteRequestDeadline; + } + @Override public Duration getRequestTimeout() { return requestTimeout; diff --git a/client/src/main/java/org/asynchttpclient/Request.java b/client/src/main/java/org/asynchttpclient/Request.java index 1d95016b36..1ec85fd62b 100644 --- a/client/src/main/java/org/asynchttpclient/Request.java +++ b/client/src/main/java/org/asynchttpclient/Request.java @@ -172,6 +172,17 @@ public interface Request { @Nullable Boolean getFollowRedirect(); + /** + * Whether {@link #getRequestTimeout()} is a deadline for the whole exchange rather than for each attempt + * within it. See {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()}. + * + * @return the override, or null to use the config value + */ + @Nullable + default Boolean getUseAbsoluteRequestDeadline() { + return null; + } + /** * @return the request timeout. Non zero values means "override config value". */ diff --git a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java index b185cbdc90..4ed6904fbc 100644 --- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java +++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java @@ -87,6 +87,7 @@ public abstract class RequestBuilderBase> { protected @Nullable Realm realm; protected @Nullable File file; protected @Nullable Boolean followRedirect; + protected @Nullable Boolean useAbsoluteRequestDeadline; protected @Nullable Duration requestTimeout; protected @Nullable Duration readTimeout; protected long rangeOffset; @@ -165,6 +166,7 @@ protected RequestBuilderBase(Request prototype, boolean disableUrlEncoding, bool realm = prototype.getRealm(); file = prototype.getFile(); followRedirect = prototype.getFollowRedirect(); + useAbsoluteRequestDeadline = prototype.getUseAbsoluteRequestDeadline(); requestTimeout = prototype.getRequestTimeout(); readTimeout = prototype.getReadTimeout(); rangeOffset = prototype.getRangeOffset(); @@ -598,6 +600,17 @@ public T setRealm(Realm realm) { return asDerivedType(); } + /** + * @param useAbsoluteRequestDeadline whether this request's timeout is a deadline for the whole exchange + * rather than for each attempt within it, overriding + * {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()} + * @return {@code this} + */ + public T setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) { + this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; + return asDerivedType(); + } + public T setFollowRedirect(boolean followRedirect) { this.followRedirect = followRedirect; return asDerivedType(); @@ -685,6 +698,7 @@ private RequestBuilderBase executeSignatureCalculator() { rb.realm = realm; rb.file = file; rb.followRedirect = followRedirect; + rb.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; rb.requestTimeout = requestTimeout; rb.rangeOffset = rangeOffset; rb.charset = charset; @@ -755,6 +769,7 @@ public Request build() { rb.rangeOffset, rb.charset, rb.channelPoolPartitioning, - rb.nameResolver); + rb.nameResolver, + rb.useAbsoluteRequestDeadline); } } diff --git a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java index a31fdf2855..71508600f6 100644 --- a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java +++ b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java @@ -62,6 +62,7 @@ public final class AsyncHttpClientConfigDefaults { public static final String MAX_REQUEST_RETRY_CONFIG = "maxRequestRetry"; public static final String LOAD_BALANCE_CONFIG = "loadBalance"; public static final String FAILED_IP_COOLDOWN_ENABLED_CONFIG = "failedIpCooldownEnabled"; + public static final String USE_ABSOLUTE_REQUEST_DEADLINE_CONFIG = "useAbsoluteRequestDeadline"; public static final String FAILED_IP_COOLDOWN_PERIOD_CONFIG = "failedIpCooldownPeriod"; public static final String DISABLE_URL_ENCODING_FOR_BOUND_REQUESTS_CONFIG = "disableUrlEncodingForBoundRequests"; public static final String USE_LAX_COOKIE_ENCODER_CONFIG = "useLaxCookieEncoder"; @@ -183,6 +184,10 @@ public static boolean defaultFailedIpCooldownEnabled() { return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + FAILED_IP_COOLDOWN_ENABLED_CONFIG); } + public static boolean defaultUseAbsoluteRequestDeadline() { + return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + USE_ABSOLUTE_REQUEST_DEADLINE_CONFIG); + } + public static Duration defaultFailedIpCooldownPeriod() { return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getDuration(ASYNC_CLIENT_CONFIG_ROOT + FAILED_IP_COOLDOWN_PERIOD_CONFIG); } diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java index 86d312617e..e1ac837eac 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java @@ -154,6 +154,9 @@ public final class NettyResponseFuture implements ListenableFuture { // future no longer takes, which is how a connection through a proxy comes to be offered as a direct one. // Volatile: the mutators run on the redirect and replay paths while reads happen on other threads. private volatile Object basePartitionKeyCache; + // Read when a TimeoutsHolder is built, which happens on the caller thread, an event loop or the timer + // thread depending on the path, so it is published rather than plain. + private volatile boolean useAbsoluteRequestDeadline; public NettyResponseFuture(Request originalRequest, AsyncHandler asyncHandler, @@ -726,6 +729,21 @@ public void acquirePartitionLockLazily(boolean nonBlocking) throws IOException { } } + /** + * Whether this exchange's request timeout is a deadline for the exchange as a whole. Resolved once, from the + * request the caller submitted and the client config, and then kept here rather than re-read per hop: a + * redirect rebuilds the request from a hand-picked set of fields, so anything carried only on the request + * would silently revert to the config value partway through the exchange, which is exactly the case this + * setting exists for. + */ + public boolean isUseAbsoluteRequestDeadline() { + return useAbsoluteRequestDeadline; + } + + public void setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) { + this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline; + } + public Realm getRealm() { return realm; } diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index af3610164d..e5ce08c63f 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -110,6 +110,7 @@ import static org.asynchttpclient.util.HttpUtils.GZIP_DEFLATE; import static org.asynchttpclient.util.HttpUtils.GZIP_DEFLATE_HPACK; import static org.asynchttpclient.util.HttpUtils.hostHeader; +import static org.asynchttpclient.util.HttpUtils.useAbsoluteRequestDeadline; import static org.asynchttpclient.util.MiscUtils.getCause; import static org.asynchttpclient.util.ProxyUtils.getProxyServer; @@ -629,6 +630,8 @@ private NettyResponseFuture newNettyResponseFuture(Request request, Async connectionSemaphore, proxyServer); + future.setUseAbsoluteRequestDeadline(useAbsoluteRequestDeadline(config, request)); + String expectHeader = request.getHeaders().get(EXPECT); if (HttpHeaderValues.CONTINUE.contentEqualsIgnoreCase(expectHeader)) { future.setDontWriteBodyBecauseExpectContinue(true); diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java index 93f6b26a26..7b4efc1fc8 100755 --- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java +++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java @@ -59,8 +59,16 @@ public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture nettyResponseFutu } if (requestTimeoutInMs > -1) { - requestTimeoutMillisTime = unpreciseMillisTime() + requestTimeoutInMs; - requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs), requestTimeoutInMs); + // A redirect, a retry or an auth replay builds a new holder for the same future. Anchoring the + // deadline here hands each of those hops a fresh budget, so a chain of n hops runs for n times the + // configured timeout; anchoring it on the future bounds the exchange as a whole instead. Which one + // applies is the caller's choice, per request or per client. + requestTimeoutMillisTime = (nettyResponseFuture.isUseAbsoluteRequestDeadline() + ? nettyResponseFuture.getStart() : unpreciseMillisTime()) + requestTimeoutInMs; + // A deadline already behind us is scheduled at zero rather than negative, so the task still runs and + // still cancels its read-timeout sibling, which is bookkeeping only it does. + requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs), + Math.max(requestTimeoutMillisTime - unpreciseMillisTime(), 0L)); } else { requestTimeoutMillisTime = -1L; requestTimeout = null; diff --git a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java index 4e8d802575..2b970595be 100644 --- a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java @@ -142,6 +142,11 @@ public static boolean followRedirect(AsyncHttpClientConfig config, Request reque return request.getFollowRedirect() != null ? request.getFollowRedirect() : config.isFollowRedirect(); } + public static boolean useAbsoluteRequestDeadline(AsyncHttpClientConfig config, Request request) { + Boolean override = request.getUseAbsoluteRequestDeadline(); + return override != null ? override : config.isUseAbsoluteRequestDeadline(); + } + public static ByteBuffer urlEncodeFormParams(List params, Charset charset) { return StringUtils.charSequence2ByteBuffer(urlEncodeFormParams0(params, charset), US_ASCII); } diff --git a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties index 6bf4e0f7b2..b7bf74a5b1 100644 --- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties +++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties @@ -26,6 +26,7 @@ org.asynchttpclient.keepAlive=true org.asynchttpclient.maxRequestRetry=5 org.asynchttpclient.loadBalance=DEFAULT org.asynchttpclient.failedIpCooldownEnabled=true +org.asynchttpclient.useAbsoluteRequestDeadline=false org.asynchttpclient.failedIpCooldownPeriod=PT10S org.asynchttpclient.disableUrlEncodingForBoundRequests=false org.asynchttpclient.useLaxCookieEncoder=false diff --git a/client/src/test/java/org/asynchttpclient/AbsoluteRequestDeadlineTest.java b/client/src/test/java/org/asynchttpclient/AbsoluteRequestDeadlineTest.java new file mode 100644 index 0000000000..89eeb47609 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/AbsoluteRequestDeadlineTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * 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 org.asynchttpclient; + +import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.handler.codec.http.HttpHeaderNames; +import org.asynchttpclient.testserver.HttpServer; +import org.asynchttpclient.testserver.HttpTest; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import static org.asynchttpclient.Dsl.config; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()} decides whether the request timeout bounds the + * whole exchange or each attempt within it. A redirect builds a fresh {@code TimeoutsHolder} for the same + * future, so with the deadline anchored on the holder each hop gets a budget of its own, and with it anchored + * on the future a later hop gets only what is left. + *

+ * Timing-based, so repeated: the margins are wide (a 600 ms budget against hops of 400 ms) but a loaded CI box + * can still miss one. + */ +public class AbsoluteRequestDeadlineTest extends HttpTest { + + private static final Duration BUDGET = Duration.ofMillis(600); + private static final long HOP_DELAY_MS = 400; + + private HttpServer server; + + @BeforeEach + public void start() throws Throwable { + server = new HttpServer(); + server.start(); + } + + @AfterEach + public void stop() throws Throwable { + server.close(); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void byDefaultEachHopGetsItsOwnBudget() throws Throwable { + // Two hops of 400 ms against a 600 ms budget. Each hop on its own fits, the pair does not, so with a + // per-attempt timeout the exchange completes. + enqueueTwoDelayedHops(); + + Throwable cause = runAndAwait(baseConfig(), null); + + assertNull(cause, "per-attempt timeouts should let both hops run, got " + cause); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void withAnAbsoluteDeadlineTheChainCannotOutrunTheBudget() throws Throwable { + enqueueTwoDelayedHops(); + + Throwable cause = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), null); + + assertNotNull(cause, "the exchange should have run out of budget across the two hops"); + assertEquals(TimeoutException.class, cause.getClass(), "expected a request timeout, got " + cause); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void aRequestCanAskForAnAbsoluteDeadlineOnAPerAttemptClient() throws Throwable { + enqueueTwoDelayedHops(); + + Throwable cause = runAndAwait(baseConfig(), Boolean.TRUE); + + assertNotNull(cause, "the request-level override should have bounded the exchange"); + assertEquals(TimeoutException.class, cause.getClass(), "expected a request timeout, got " + cause); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void aRequestCanOptOutOfAnAbsoluteDeadlineClient() throws Throwable { + enqueueTwoDelayedHops(); + + Throwable cause = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), Boolean.FALSE); + + assertNull(cause, "the request-level override should have restored per-attempt timeouts, got " + cause); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void aSingleHopStillGetsTheWholeBudget() throws Throwable { + // Guards the other direction: with a deadline, the first hop must not be handed a shortened budget. + enqueueDelayed(HOP_DELAY_MS, 200, null); + + Throwable cause = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), null); + + assertNull(cause, "a single hop well inside the budget should not time out, got " + cause); + } + + private DefaultAsyncHttpClientConfig.Builder baseConfig() { + return config().setRequestTimeout(BUDGET).setFollowRedirect(true).setMaxRedirects(5); + } + + private void enqueueTwoDelayedHops() { + enqueueDelayed(HOP_DELAY_MS, 302, "/foo/bar2"); + enqueueDelayed(HOP_DELAY_MS, 200, null); + } + + /** + * Answers after {@code delayMs}, so the hop consumes a known slice of the budget before the client sees a + * status at all. + */ + private void enqueueDelayed(long delayMs, int status, @Nullable String location) { + server.enqueueResponse(response -> { + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + response.setStatus(status); + if (location != null) { + response.setHeader(HttpHeaderNames.LOCATION.toString(), location); + } + }); + } + + /** + * @return the throwable the exchange was aborted with, or null when it completed + */ + private Throwable runAndAwait(DefaultAsyncHttpClientConfig.Builder builder, + @Nullable Boolean perRequestOverride) throws Throwable { + AtomicReference cause = new AtomicReference<>(); + CountDownLatch settled = new CountDownLatch(1); + + withClient(builder).run(client -> withServer(server).run(server -> { + BoundRequestBuilder request = client.prepareGet(server.getHttpUrl() + "/foo/bar"); + if (perRequestOverride != null) { + request.setUseAbsoluteRequestDeadline(perRequestOverride); + } + request.execute(new AsyncCompletionHandler() { + @Override + public Void onCompleted(Response response) { + settled.countDown(); + return null; + } + + @Override + public void onThrowable(Throwable t) { + cause.set(t); + settled.countDown(); + } + }); + + assertTrue(settled.await(30, TimeUnit.SECONDS), "the exchange neither completed nor failed"); + })); + + return cause.get(); + } +}