diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index b29ce5306a..3adc7a30b2 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -109,6 +109,10 @@ 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. */ @@ -142,6 +146,30 @@ default boolean isUseEventLoopTimeouts() { return false; } + /** + * 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. + *
+ * As with every option on this interface, the {@code org.asynchttpclient.useAbsoluteRequestDeadline}
+ * property is read by {@link DefaultAsyncHttpClientConfig.Builder}, not here: an implementation of this
+ * interface that does not override this method gets {@code false} whatever the property says.
+ *
+ * @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 a1eed3cc97..7095a4cbca 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;
@@ -139,6 +140,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;
@@ -245,6 +247,7 @@ private DefaultAsyncHttpClientConfig(// http
int maxRequestRetry,
LoadBalance loadBalance,
boolean failedIpCooldownEnabled,
+ boolean useAbsoluteRequestDeadline,
Duration failedIpCooldownPeriod,
boolean disableUrlEncodingForBoundRequests,
boolean useLaxCookieEncoder,
@@ -351,6 +354,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;
@@ -522,6 +526,11 @@ public boolean isFailedIpCooldownEnabled() {
return failedIpCooldownEnabled;
}
+ @Override
+ public boolean isUseAbsoluteRequestDeadline() {
+ return useAbsoluteRequestDeadline;
+ }
+
@Override
public Duration getFailedIpCooldownPeriod() {
return failedIpCooldownPeriod;
@@ -946,6 +955,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();
@@ -1055,6 +1065,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();
@@ -1255,6 +1266,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
@@ -1773,6 +1795,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..3a4885601f 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,48 @@ public DefaultRequest(String method,
@Nullable Charset charset,
ChannelPoolPartitioning channelPoolPartitioning,
NameResolver
+ * Measured from the future's monotonic start rather than by comparing wall clocks across hops, so a clock
+ * correction landing mid-chain cannot move the deadline. Static, and asked of the future rather than of a
+ * holder, because a caller deciding whether a request is still worth sending has the future in hand before
+ * any holder exists for the attempt it is about to make.
+ *
+ * @see org.asynchttpclient.AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()
+ */
+ public static long remainingBudget(AsyncHttpClientConfig config, NettyResponseFuture> nettyResponseFuture) {
+ if (!nettyResponseFuture.isUseAbsoluteRequestDeadline()) {
+ return Long.MAX_VALUE;
+ }
+ return remainingBudget(requestTimeout(config, nettyResponseFuture.getTargetRequest()), nettyResponseFuture);
+ }
+
+ private static long remainingBudget(long requestTimeoutInMs, NettyResponseFuture> nettyResponseFuture) {
+ if (requestTimeoutInMs <= -1) {
+ return Long.MAX_VALUE;
+ }
+ long spent = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nettyResponseFuture.getStartNanos());
+ return requestTimeoutInMs - spent;
+ }
+
+ /**
+ * The request timeout in force for {@code request}: its own, or the client's when it does not carry one.
+ */
+ private static long requestTimeout(AsyncHttpClientConfig config, Request request) {
+ long requestTimeoutInMs = request.getRequestTimeout().toMillis();
+ return requestTimeoutInMs == 0 ? config.getRequestTimeout().toMillis() : requestTimeoutInMs;
+ }
+
+ // Visible for testing: the instant this holder's request timeout is due, as a wall-clock reading.
+ long requestTimeoutMillisTime() {
+ return requestTimeoutMillisTime;
+ }
+
/**
* Moves this exchange's timeouts onto {@code executor}, the loop of the channel it turned out to run on. The
* connect path arms the request timeout before there is a channel -- deliberately, since it bounds address
@@ -178,8 +226,8 @@ private static void release(@Nullable TimeoutTimerTask task) {
}
private long remainingRequestTimeout() {
- // A deadline already behind us is armed at zero rather than negative, so the task still runs and still
- // cancels its read-timeout sibling, which is bookkeeping only it does.
+ // Floored at zero rather than passed on negative: a scheduler has no use for a negative delay, and the
+ // task has to run either way, since running is what fails the exchange.
return Math.max(requestTimeoutMillisTime - unpreciseMillisTime(), 0L);
}
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 34fb663803..1179088cab 100644
--- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties
+++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties
@@ -27,6 +27,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..b292c76247
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/AbsoluteRequestDeadlineTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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 io.netty.util.HashedWheelTimer;
+import io.netty.util.concurrent.DefaultThreadFactory;
+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.AtomicBoolean;
+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.assertFalse;
+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 static final String FIRST_HOP = "/foo/bar";
+ private static final String SECOND_HOP = "/foo/bar2";
+
+ private HttpServer server;
+ // Coarse on purpose, for the one case that needs the request timeout not to fire: a wheel answers a
+ // deadline on its first tick at or after it, so at this granularity nothing expires inside a test.
+ private HashedWheelTimer stalledTimer;
+
+ @BeforeEach
+ public void start() throws Throwable {
+ server = new HttpServer();
+ server.start();
+ stalledTimer = new HashedWheelTimer(new DefaultThreadFactory("ahc-stalled-timer", true),
+ 30, TimeUnit.SECONDS, 512, false);
+ }
+
+ @AfterEach
+ public void stop() throws Throwable {
+ server.close();
+ stalledTimer.stop();
+ }
+
+ @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();
+
+ Outcome outcome = runAndAwait(baseConfig(), null);
+
+ outcome.assertReachedTheSecondHop();
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void withAnAbsoluteDeadlineTheChainCannotOutrunTheBudget() throws Throwable {
+ enqueueTwoDelayedHops();
+
+ Outcome outcome = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), null);
+
+ outcome.assertTimedOut();
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void aRequestCanAskForAnAbsoluteDeadlineOnAPerAttemptClient() throws Throwable {
+ enqueueTwoDelayedHops();
+
+ Outcome outcome = runAndAwait(baseConfig(), Boolean.TRUE);
+
+ outcome.assertTimedOut();
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void aRequestCanOptOutOfAnAbsoluteDeadlineClient() throws Throwable {
+ enqueueTwoDelayedHops();
+
+ Outcome outcome = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), Boolean.FALSE);
+
+ outcome.assertReachedTheSecondHop();
+ }
+
+ @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);
+
+ Outcome outcome = runAndAwait(baseConfig().setUseAbsoluteRequestDeadline(true), null);
+
+ outcome.assertCompletedAt(FIRST_HOP);
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void aHopWithNothingLeftToSpendIsNeverSent() throws Throwable {
+ // The first hop answers after the budget is gone, and the timer is too coarse to have expired the
+ // exchange in the meantime. That is the window in which a redirect used to be written anyway: a permit
+ // taken, a connection taken, the body on the wire, and only then a TimeoutException that reads to the
+ // caller as though nothing had been sent.
+ AtomicBoolean secondHopServed = new AtomicBoolean();
+ enqueueDelayed(BUDGET.toMillis() + HOP_DELAY_MS, 302, SECOND_HOP);
+ server.enqueueResponse(response -> {
+ secondHopServed.set(true);
+ response.setStatus(200);
+ });
+
+ Outcome outcome = runAndAwait(baseConfig()
+ .setNettyTimer(stalledTimer)
+ .setUseAbsoluteRequestDeadline(true), null);
+
+ outcome.assertTimedOutBeforeSending();
+ assertFalse(secondHopServed.get(), "the redirect target was sent a request with no budget left");
+ }
+
+ private DefaultAsyncHttpClientConfig.Builder baseConfig() {
+ return config().setRequestTimeout(BUDGET).setFollowRedirect(true).setMaxRedirects(5);
+ }
+
+ private void enqueueTwoDelayedHops() {
+ enqueueDelayed(HOP_DELAY_MS, 302, SECOND_HOP);
+ enqueueDelayed(HOP_DELAY_MS, 200, null);
+ }
+
+ /**
+ * What the exchange ended as. The passing cases assert where it ended and not merely that nothing was
+ * thrown: a dropped {@code Location} header, or redirects turned off, would satisfy "no exception" having
+ * run one hop, which is the opposite of what they are for.
+ */
+ private static final class Outcome {
+
+ private final @Nullable Throwable cause;
+ private final @Nullable Response response;
+
+ private Outcome(@Nullable Throwable cause, @Nullable Response response) {
+ this.cause = cause;
+ this.response = response;
+ }
+
+ void assertCompletedAt(String path) {
+ assertNull(cause, "the exchange was not meant to fail, got " + cause);
+ assertNotNull(response, "the exchange neither failed nor produced a response");
+ assertEquals(200, response.getStatusCode(), "expected the final 200");
+ assertEquals(path, response.getUri().getPath(), "the exchange ended on the wrong hop");
+ }
+
+ void assertReachedTheSecondHop() {
+ assertCompletedAt(SECOND_HOP);
+ }
+
+ void assertTimedOut() {
+ assertNotNull(cause, "the exchange should have run out of budget");
+ assertEquals(TimeoutException.class, cause.getClass(), "expected a request timeout, got " + cause);
+ }
+
+ /**
+ * That the exchange was failed by the check before the request was written, rather than by a timeout
+ * armed at zero expiring once it had been. The message is the only thing that tells the two apart.
+ */
+ void assertTimedOutBeforeSending() {
+ assertTimedOut();
+ assertTrue(cause.getMessage().contains("before the request was sent"),
+ "expected the deadline to be caught before the write, got " + cause.getMessage());
+ }
+ }
+
+ /**
+ * 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);
+ }
+ });
+ }
+
+ private Outcome runAndAwait(DefaultAsyncHttpClientConfig.Builder builder,
+ @Nullable Boolean perRequestOverride) throws Throwable {
+ AtomicReference
+ * No timer and no request sender: without them the holder computes its deadline and arms nothing, which is
+ * exactly the part worth testing directly rather than through a request.
+ */
+public class TimeoutsHolderTest {
+
+ private static final Duration BUDGET = Duration.ofMillis(600);
+ private static final long ELAPSED_MS = 100;
+ // The deadline is a wall-clock reading and the budget is netted off in whole milliseconds, so an anchored
+ // deadline lands within a few milliseconds of itself rather than exactly on it.
+ private static final long TOLERANCE_MS = 30;
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void anAbsoluteDeadlineStaysWhereTheExchangeStarted() throws Exception {
+ NettyResponseFuture> future = exchange(true);
+
+ long firstHop = deadlineOf(future, BUDGET);
+ Thread.sleep(ELAPSED_MS);
+ long secondHop = deadlineOf(future, BUDGET);
+
+ assertTrue(Math.abs(secondHop - firstHop) <= TOLERANCE_MS,
+ "the second hop moved the deadline by " + (secondHop - firstHop) + " ms");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void aPerAttemptTimeoutGivesTheSecondHopItsOwnBudget() throws Exception {
+ NettyResponseFuture> future = exchange(false);
+
+ long firstHop = deadlineOf(future, BUDGET);
+ Thread.sleep(ELAPSED_MS);
+ long secondHop = deadlineOf(future, BUDGET);
+
+ assertTrue(secondHop - firstHop >= ELAPSED_MS / 2,
+ "the second hop should have started a budget of its own, moved by only "
+ + (secondHop - firstHop) + " ms");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void anExchangeThatOutranItsDeadlineHasNothingLeft() throws Exception {
+ // A budget this small is spent by the time the sleep is over, so the next hop has nothing to run in.
+ NettyResponseFuture> future = exchange(true);
+ Thread.sleep(ELAPSED_MS);
+
+ assertTrue(TimeoutsHolder.remainingBudget(config(Duration.ofMillis(1)), future) <= 0,
+ "a spent deadline should leave nothing to send a further hop with");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void aPerAttemptExchangeIsNotBoundedAsAWhole() throws Exception {
+ // Asserted on the deadline the holder computes rather than on the budget: per attempt there is no
+ // exchange-wide budget to run out of, so the arithmetic is not what the answer rests on.
+ NettyResponseFuture> future = exchange(false);
+ Thread.sleep(ELAPSED_MS);
+
+ long deadline = deadlineOf(future, BUDGET);
+
+ assertTrue(deadline - System.currentTimeMillis() >= BUDGET.toMillis() - TOLERANCE_MS,
+ "a hop should be given the configured timeout of its own however long the exchange has run, got "
+ + (deadline - System.currentTimeMillis()) + " ms");
+ }
+
+ private static long deadlineOf(NettyResponseFuture> future, Duration requestTimeout) {
+ return holder(future, requestTimeout).requestTimeoutMillisTime();
+ }
+
+ private static TimeoutsHolder holder(NettyResponseFuture> future, Duration requestTimeout) {
+ return new TimeoutsHolder(null, future, null, config(requestTimeout), null);
+ }
+
+ private static AsyncHttpClientConfig config(Duration requestTimeout) {
+ return new DefaultAsyncHttpClientConfig.Builder().setRequestTimeout(requestTimeout).build();
+ }
+
+ private static NettyResponseFuture> exchange(boolean useAbsoluteRequestDeadline) {
+ Request request = new RequestBuilder().setUrl("http://example.com:12345").build();
+ NettyResponseFuture> future = new NettyResponseFuture<>(request, new AsyncCompletionHandler> resolveAddresses(Request request, Pr
if (proxy != null && !proxy.isIgnoredForHost(uri.getHost()) && proxy.getProxyType().isHttp()) {
int port = ProxyType.HTTPS.equals(proxy.getProxyType()) || uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort();
InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(proxy.getHost(), port);
- if (scheduleTimeout) {
- scheduleRequestTimeout(future, unresolvedRemoteAddress);
+ if (scheduleTimeout && !scheduleRequestTimeout(future, unresolvedRemoteAddress)) {
+ return abortedResolution(future);
}
return resolveHostname(request, unresolvedRemoteAddress, asyncHandler);
} else {
int port = uri.getExplicitPort();
InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(uri.getHost(), port);
- if (scheduleTimeout) {
- scheduleRequestTimeout(future, unresolvedRemoteAddress);
+ if (scheduleTimeout && !scheduleRequestTimeout(future, unresolvedRemoteAddress)) {
+ return abortedResolution(future);
}
if (request.getAddress() != null) {
@@ -632,6 +639,8 @@ private
> abortedResolution(NettyResponseFuture