diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 7304626083..dedf8ae931 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -270,6 +270,30 @@ default Duration getFailedIpCooldownPeriod() { return Duration.ofSeconds(10); } + /** + * Whether request and read timeouts are armed on an event loop rather than on {@link #getNettyTimer()}. + *
+ * The timer is a hashed wheel: it fires on the first tick at or after the deadline, so a deadline near or + * below {@link #getHashedWheelTimerTickDuration()} is rounded up to it, and one thread carries every expiry + * for the whole client. An event loop instead schedules by deadline and derives its own select timeout from + * the nearest one, so nothing is rounded up, and the loops share the load rather than funnelling it through + * a single thread. Both effects matter most to short deadlines, where a tick is a large fraction of the + * budget and a burst of expiries has no headroom to absorb. + *
+ * The cost is where the expiry runs. On the timer it runs on the timer thread; on an event loop it runs on + * an I/O thread, and so does whatever the caller chained onto the response future, because that future is + * completed from there. Blocking an I/O thread stalls every connection it serves, so a caller enabling this + * should hand its own work off with {@code handleAsync} or an {@code AsyncHandler} that does the same. + * That is why this is opt-in rather than the default. + *
+ * The connection-pool cleaner stays on the timer either way.
+ *
+ * @return {@code true} to arm request and read timeouts on an event loop
+ */
+ default boolean isUseEventLoopTimeouts() {
+ return false;
+ }
+
/**
* @return the disableUrlEncodingForBoundRequests
*/
diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java
index 75aa1bd16a..8ddb0660d8 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.defaultUseEventLoopTimeouts;
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 useEventLoopTimeouts;
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 useEventLoopTimeouts,
Duration failedIpCooldownPeriod,
boolean disableUrlEncodingForBoundRequests,
boolean useLaxCookieEncoder,
@@ -348,6 +351,7 @@ private DefaultAsyncHttpClientConfig(// http
this.maxRequestRetry = maxRequestRetry;
this.loadBalance = loadBalance;
this.failedIpCooldownEnabled = failedIpCooldownEnabled;
+ this.useEventLoopTimeouts = useEventLoopTimeouts;
this.failedIpCooldownPeriod = failedIpCooldownPeriod;
this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests;
this.useLaxCookieEncoder = useLaxCookieEncoder;
@@ -518,6 +522,11 @@ public boolean isFailedIpCooldownEnabled() {
return failedIpCooldownEnabled;
}
+ @Override
+ public boolean isUseEventLoopTimeouts() {
+ return useEventLoopTimeouts;
+ }
+
@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 useEventLoopTimeouts = defaultUseEventLoopTimeouts();
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();
+ useEventLoopTimeouts = config.isUseEventLoopTimeouts();
failedIpCooldownPeriod = config.getFailedIpCooldownPeriod();
disableUrlEncodingForBoundRequests = config.isDisableUrlEncodingForBoundRequests();
useLaxCookieEncoder = config.isUseLaxCookieEncoder();
@@ -1244,6 +1255,17 @@ public Builder setFailedIpCooldownEnabled(boolean failedIpCooldownEnabled) {
return this;
}
+ /**
+ * @param useEventLoopTimeouts whether to arm request and read timeouts on an event loop instead of on
+ * the client's timer; see {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()}
+ * for the trade-off this makes
+ * @return this
+ */
+ public Builder setUseEventLoopTimeouts(boolean useEventLoopTimeouts) {
+ this.useEventLoopTimeouts = useEventLoopTimeouts;
+ 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,
+ useEventLoopTimeouts,
failedIpCooldownPeriod,
disableUrlEncodingForBoundRequests,
useLaxCookieEncoder,
diff --git a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java
index a31fdf2855..56c1ecef74 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_EVENT_LOOP_TIMEOUTS_CONFIG = "useEventLoopTimeouts";
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 defaultUseEventLoopTimeouts() {
+ return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + USE_EVENT_LOOP_TIMEOUTS_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/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
index af3610164d..a7ad7a88be 100755
--- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
+++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
@@ -82,6 +82,7 @@
import org.asynchttpclient.resolver.RequestHostnameResolver;
import org.asynchttpclient.uri.Uri;
import org.asynchttpclient.ws.WebSocketUpgradeHandler;
+import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -404,7 +405,7 @@ private
+ * Timeouts are armed either on the client's {@link Timer} or, when an {@link EventExecutor} is supplied, on
+ * that event loop. The two differ in more than which thread runs the task. A wheel fires on the first tick at
+ * or after the deadline, so a deadline near or below the tick duration is rounded up, and one thread carries
+ * every expiry for the whole client. An event loop schedules by deadline and derives its select timeout from
+ * the nearest one, so nothing is rounded, and the loops share the load. See
+ * {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()} for what that costs.
+ */
public class TimeoutsHolder {
- private final Timeout requestTimeout;
+ private static final Logger LOGGER = LoggerFactory.getLogger(TimeoutsHolder.class);
+
private final AtomicBoolean cancelled = new AtomicBoolean();
private final Timer nettyTimer;
+ private final @Nullable EventExecutor eventExecutor;
private final NettyRequestSender requestSender;
private final long requestTimeoutMillisTime;
private final long readTimeoutValue;
- private volatile Timeout readTimeout;
+ private final @Nullable RequestTimeoutTimerTask requestTimeoutTask;
+ // Whether the request timeout was actually armed. Distinct from requestTimeoutTask being non-null: the
+ // task exists but is left unarmed when there is nothing to arm it on, and the read timeout is then free to
+ // run to its own deadline rather than assuming a request timeout will outrun it.
+ private final boolean requestTimeoutArmed;
+ private volatile @Nullable ReadTimeoutTimerTask readTimeoutTask;
private final NettyResponseFuture> nettyResponseFuture;
private volatile InetSocketAddress remoteAddress;
public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture> nettyResponseFuture, NettyRequestSender requestSender,
AsyncHttpClientConfig config, InetSocketAddress originalRemoteAddress) {
+ this(nettyTimer, null, nettyResponseFuture, requestSender, config, originalRemoteAddress);
+ }
+
+ /**
+ * @param eventExecutor the event loop to arm the timeouts on, or {@code null} to arm them on
+ * {@code nettyTimer}. Pass the loop that owns the exchange's channel when it is known,
+ * so the timeout fires on the thread that will have to close it.
+ */
+ public TimeoutsHolder(Timer nettyTimer, @Nullable EventExecutor eventExecutor, NettyResponseFuture> nettyResponseFuture,
+ NettyRequestSender requestSender, AsyncHttpClientConfig config, InetSocketAddress originalRemoteAddress) {
this.nettyTimer = nettyTimer;
+ this.eventExecutor = eventExecutor;
this.nettyResponseFuture = nettyResponseFuture;
this.requestSender = requestSender;
remoteAddress = originalRemoteAddress;
@@ -60,10 +91,12 @@ public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture> nettyResponseFutu
if (requestTimeoutInMs > -1) {
requestTimeoutMillisTime = unpreciseMillisTime() + requestTimeoutInMs;
- requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs), requestTimeoutInMs);
+ requestTimeoutTask = new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs);
+ requestTimeoutArmed = arm(requestTimeoutTask, requestTimeoutInMs);
} else {
requestTimeoutMillisTime = -1L;
- requestTimeout = null;
+ requestTimeoutTask = null;
+ requestTimeoutArmed = false;
}
}
@@ -81,14 +114,16 @@ public void startReadTimeout() {
}
}
- void startReadTimeout(ReadTimeoutTimerTask task) {
- if (requestTimeout == null || !requestTimeout.isExpired() && readTimeoutValue < requestTimeoutMillisTime - unpreciseMillisTime()) {
+ void startReadTimeout(@Nullable ReadTimeoutTimerTask task) {
+ if (!requestTimeoutArmed
+ || !requestTimeoutTask.isClaimed() && readTimeoutValue < requestTimeoutMillisTime - unpreciseMillisTime()) {
// only schedule a new readTimeout if the requestTimeout doesn't happen first
if (task == null) {
// first call triggered from outside (else is read timeout is re-scheduling itself)
task = new ReadTimeoutTimerTask(nettyResponseFuture, requestSender, this, readTimeoutValue);
}
- readTimeout = newTimeout(task, readTimeoutValue);
+ readTimeoutTask = task;
+ arm(task, readTimeoutValue);
} else if (task != null) {
// read timeout couldn't re-scheduling itself, clean up
@@ -98,24 +133,47 @@ void startReadTimeout(ReadTimeoutTimerTask task) {
public void cancel() {
if (cancelled.compareAndSet(false, true)) {
- if (requestTimeout != null) {
- requestTimeout.cancel();
- ((TimeoutTimerTask) requestTimeout.task()).clean();
- }
- if (readTimeout != null) {
- readTimeout.cancel();
- ((TimeoutTimerTask) readTimeout.task()).clean();
- }
+ release(requestTimeoutTask);
+ release(readTimeoutTask);
}
}
- private Timeout newTimeout(TimerTask task, long delay) {
+ private static void release(@Nullable TimeoutTimerTask task) {
+ if (task != null) {
+ task.cancelArmed();
+ task.clean();
+ }
+ }
+
+ /**
+ * Arms {@code task} to run after {@code delay} milliseconds, recording the scheduled entry on the task so it
+ * can cancel itself later.
+ *
+ * @return whether the task was armed. It is not when the client is shutting down, in which case there is no
+ * timeout to deliver anyway
+ */
+ private boolean arm(TimeoutTimerTask task, long delay) {
// requestSender or nettyTimer might be null in unit tests or in some edge
// cases where a channel's remote address wasn't available. In such cases
// avoid scheduling any timeouts rather than throwing a NPE.
- if (requestSender == null || nettyTimer == null || requestSender.isClosed()) {
- return null;
+ if (requestSender == null || requestSender.isClosed()) {
+ return false;
+ }
+ if (eventExecutor != null && !eventExecutor.isShuttingDown()) {
+ try {
+ task.armedOn(eventExecutor.schedule(task, delay, TimeUnit.MILLISECONDS));
+ return true;
+ } catch (RejectedExecutionException e) {
+ // The loop began shutting down between the check above and here. Losing the timeout entirely
+ // would leave the exchange with nothing to end it, so fall through to the timer, which the
+ // client keeps running until it is itself closed.
+ LOGGER.debug("Event loop rejected a timeout, falling back to the timer", e);
+ }
+ }
+ if (nettyTimer == null) {
+ return false;
}
- return nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS);
+ task.armedOn(nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS));
+ return true;
}
}
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..f7ee6925b9 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.useEventLoopTimeouts=false
org.asynchttpclient.failedIpCooldownPeriod=PT10S
org.asynchttpclient.disableUrlEncodingForBoundRequests=false
org.asynchttpclient.useLaxCookieEncoder=false
diff --git a/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java b/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java
new file mode 100644
index 0000000000..8ed3594d3c
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/EventLoopTimeoutTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.netty.handler.codec.http.DefaultHttpHeaders;
+import io.netty.handler.codec.http.HttpHeaders;
+import org.asynchttpclient.testserver.HttpServer;
+import org.asynchttpclient.testserver.HttpTest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Where a request timeout is delivered from, which is what
+ * {@link AsyncHttpClientConfig#isUseEventLoopTimeouts()} changes. Off, every expiry in the client runs on the
+ * timer's single thread; on, it runs on an event loop. The thread a timeout is delivered on is observable
+ * through {@link AsyncHandler#onThrowable}, so these assert the switch rather than its side effects.
+ */
+public class EventLoopTimeoutTest extends HttpTest {
+
+ private static final String IO_THREAD_POOL = "ahc-timeout-test";
+ // Netty derives the timer's thread names from this, so a timer thread is the one carrying "timer".
+ private static final String TIMER_MARKER = "timer";
+
+ private HttpServer server;
+
+ @BeforeEach
+ public void start() throws Throwable {
+ server = new HttpServer();
+ server.start();
+ }
+
+ @AfterEach
+ public void stop() throws Throwable {
+ server.close();
+ }
+
+ @Test
+ public void byDefaultTheTimeoutIsDeliveredFromTheTimerThread() throws Throwable {
+ String thread = threadDeliveringRequestTimeout(false);
+
+ assertTrue(thread.contains(TIMER_MARKER),
+ "expected the timer thread by default, got " + thread);
+ }
+
+ @Test
+ public void withEventLoopTimeoutsTheTimeoutIsDeliveredFromAnEventLoop() throws Throwable {
+ String thread = threadDeliveringRequestTimeout(true);
+
+ assertFalse(thread.contains(TIMER_MARKER),
+ "expected an event loop, not the timer thread, got " + thread);
+ assertTrue(thread.contains(IO_THREAD_POOL),
+ "expected one of the client's I/O threads, got " + thread);
+ }
+
+ /**
+ * Runs one request against an endpoint that answers well after the request timeout, and returns the name of
+ * the thread {@code onThrowable} was called on.
+ */
+ private String threadDeliveringRequestTimeout(boolean useEventLoopTimeouts) throws Throwable {
+ AtomicReference