Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56d3bab
Add RequestSendType.ROUND_ROBIN for per-request IP round-robin
pavel-ptashyts Jun 16, 2026
2e1712c
Merge branch 'main' into requests-round-robin-per-host
pavel-ptashyts Jun 16, 2026
97873f5
Fix test for macOS case
pavel-ptashyts Jun 17, 2026
e9501ff
Some refactor
pavel-ptashyts Jun 17, 2026
0754d3b
Merge branch 'AsyncHttpClient:main' into requests-round-robin-per-host
pavel-ptashyts Jun 23, 2026
de383cc
Add logging for in case of fallback to default RequestSendType type, …
pavel-ptashyts Jun 23, 2026
031ddb1
Merge branch 'requests-round-robin-per-host' of https://github.com/ma…
pavel-ptashyts Jun 23, 2026
8b7a9d8
Add limit for counters in RoundRobinAddressSelector
pavel-ptashyts Jun 23, 2026
b5dd3a4
Add test for RoundRobinAddressSelector for MAX_TRACKED_HOSTS
pavel-ptashyts Jun 23, 2026
2c16c7c
Update logic of round robin for proxy(disabled).
pavel-ptashyts Jun 23, 2026
0b97c0b
Update javadocs for ROUND_ROBIN logic
pavel-ptashyts Jun 23, 2026
7d929a7
Remove unnessary sort in RoundRobinAddressSelector
pavel-ptashyts Jun 23, 2026
23a2a8b
Add comments to NettyRequestSender resolveAddresses
pavel-ptashyts Jun 23, 2026
dde7854
Replase roundRobinHost in NettyResponseFuture to Uri and fix problems…
pavel-ptashyts Jun 26, 2026
6df084a
Update description
pavel-ptashyts Jun 26, 2026
9b40baa
Update LRU approach for RoundRobinAddressSelector
pavel-ptashyts Jun 26, 2026
46dd22a
Fully rename RequestSendType to LoadBalance
pavel-ptashyts Jun 26, 2026
8aef29a
Update docs
pavel-ptashyts Jun 26, 2026
05880a5
Merge branch 'main' into requests-round-robin-per-host
pavel-ptashyts Jun 26, 2026
ca8a32c
Remove
pavel-ptashyts Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ public interface AsyncHttpClientConfig {
*/
int getMaxRequestRetry();

/**
* @return how requests are dispatched to a host that resolves to several IP addresses; never {@code null}.
* @see LoadBalance
*/
default LoadBalance getLoadBalance() {
return LoadBalance.DEFAULT;
}

/**
* @return the disableUrlEncodingForBoundRequests
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultMaxRequestRetry;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultPooledConnectionIdleTimeout;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultReadTimeout;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultLoadBalance;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultRequestTimeout;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultShutdownQuietPeriod;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultShutdownTimeout;
Expand Down Expand Up @@ -131,6 +132,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig {
private final String userAgent;
private final @Nullable Realm realm;
private final int maxRequestRetry;
private final LoadBalance loadBalance;
private final boolean disableUrlEncodingForBoundRequests;
private final boolean useLaxCookieEncoder;
private final boolean disableZeroCopy;
Expand Down Expand Up @@ -232,6 +234,7 @@ private DefaultAsyncHttpClientConfig(// http
String userAgent,
@Nullable Realm realm,
int maxRequestRetry,
LoadBalance loadBalance,
boolean disableUrlEncodingForBoundRequests,
boolean useLaxCookieEncoder,
boolean disableZeroCopy,
Expand Down Expand Up @@ -333,6 +336,7 @@ private DefaultAsyncHttpClientConfig(// http
this.userAgent = userAgent;
this.realm = realm;
this.maxRequestRetry = maxRequestRetry;
this.loadBalance = loadBalance;
this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests;
this.useLaxCookieEncoder = useLaxCookieEncoder;
this.disableZeroCopy = disableZeroCopy;
Expand Down Expand Up @@ -487,6 +491,11 @@ public int getMaxRequestRetry() {
return maxRequestRetry;
}

@Override
public LoadBalance getLoadBalance() {
return loadBalance;
}

@Override
public boolean isDisableUrlEncodingForBoundRequests() {
return disableUrlEncodingForBoundRequests;
Expand Down Expand Up @@ -898,6 +907,7 @@ public static class Builder {
private String userAgent = defaultUserAgent();
private @Nullable Realm realm;
private int maxRequestRetry = defaultMaxRequestRetry();
private LoadBalance loadBalance = defaultLoadBalance();
private boolean disableUrlEncodingForBoundRequests = defaultDisableUrlEncodingForBoundRequests();
private boolean useLaxCookieEncoder = defaultUseLaxCookieEncoder();
private boolean disableZeroCopy = defaultDisableZeroCopy();
Expand Down Expand Up @@ -1002,6 +1012,7 @@ public Builder(AsyncHttpClientConfig config) {
userAgent = config.getUserAgent();
realm = config.getRealm();
maxRequestRetry = config.getMaxRequestRetry();
loadBalance = config.getLoadBalance();
disableUrlEncodingForBoundRequests = config.isDisableUrlEncodingForBoundRequests();
useLaxCookieEncoder = config.isUseLaxCookieEncoder();
disableZeroCopy = config.isDisableZeroCopy();
Expand Down Expand Up @@ -1158,6 +1169,21 @@ public Builder setMaxRequestRetry(int maxRequestRetry) {
return this;
}

/**
* Sets how requests are dispatched to a host that resolves to several IP addresses.
*
* <p>With {@link LoadBalance#ROUND_ROBIN}, consecutive requests to a multi-IP host are
* spread evenly across all of its addresses (TCP failover is preserved, both HTTP/1.1 and
* HTTP/2 are supported). The {@code maxConnectionsPerHost} limit remains per host.
*
* @param loadBalance the dispatch strategy; {@code null} resets to {@link LoadBalance#DEFAULT}
* @return this
*/
public Builder setLoadBalance(LoadBalance loadBalance) {
this.loadBalance = loadBalance == null ? LoadBalance.DEFAULT : loadBalance;
return this;
}

public Builder setDisableUrlEncodingForBoundRequests(boolean disableUrlEncodingForBoundRequests) {
this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests;
return this;
Expand Down Expand Up @@ -1633,6 +1659,7 @@ public DefaultAsyncHttpClientConfig build() {
userAgent,
realm,
maxRequestRetry,
loadBalance,
disableUrlEncodingForBoundRequests,
useLaxCookieEncoder,
disableZeroCopy,
Expand Down
81 changes: 81 additions & 0 deletions client/src/main/java/org/asynchttpclient/LoadBalance.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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;

/**
* Controls how requests are dispatched to a host that resolves to several IP addresses.
*
* <p>Configured globally through {@link AsyncHttpClientConfig#getLoadBalance()}.
*/
public enum LoadBalance {

/**
* Default behavior. The address list returned by DNS is used in its natural order: a new
* connection always targets the first address and only falls back to the next one when a TCP
* connection attempt fails. Combined with connection pooling (keyed by host), this means that
* with keep-alive enabled essentially all traffic to a host stays on the first reachable IP.
*
* <p>To spread traffic across a host's IPs in this mode, configure a resolver that rotates its
* results, such as {@link io.netty.resolver.RoundRobinInetAddressResolver}; the client keeps
* targeting the first address, and the resolver is what varies which IP that is.
*/
DEFAULT,

/**
* Strict per-request round-robin across the host's resolved IPs.
*
* <p>For each request, the client rotates which resolved IP is targeted first (TCP failover to
* the remaining IPs is preserved) and makes connection reuse IP-aware, so that pooled HTTP/1.1
* connections and multiplexed HTTP/2 connections are kept and reused per IP rather than per
* host. The net effect is that consecutive requests to a multi-IP host are spread evenly across
* all of its addresses, even when connections are kept alive.
*
* <p>Notes:
* <ul>
* <li>Has no effect for hosts that resolve to a single address, literal-IP hosts, requests
* with an explicit {@link Request#getAddress() address}, or requests routed through a
* proxy (HTTP or SOCKS) — the socket is established to the proxy, not directly to the
* rotated target IPs. (Round-robin still applies when the proxy is bypassed for the host.)</li>
* <li>Connection limits ({@code maxConnectionsPerHost}) remain per host, not per IP.</li>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing worth stating outright in these notes: in HTTP/2, ROUND_ROBIN deliberately opens more than one connection to the same host and port, one per resolved IP, so streams can be spread across the host's backends. That goes against the recommendation in RFC 9113 (and RFC 7540) Section 9.1 that clients SHOULD NOT open more than one HTTP/2 connection to a given host and port pair. Since this is a SHOULD, not a MUST, and distributing streams across multiple backend endpoints is a legitimate reason to deviate, this is not a protocol conformance issue. However, it is a conscious tradeoff that differs from the default behavior, so it is worth documenting to avoid surprising users who enable this mode expecting HTTP/2's usual single connection per origin.

Suggested change
* <li>Connection limits ({@code maxConnectionsPerHost}) remain per host, not per IP.</li>
* <li>Connection limits ({@code maxConnectionsPerHost}) remain per host, not per IP.</li>
* <li>For HTTP/2, this strategy deliberately opens more than one connection to the same host and port, one per resolved IP, so streams can be spread across the host's backends. This is a conscious deviation from the recommendation in RFC 9113 (and RFC 7540) Section 9.1 that clients SHOULD NOT open more than one HTTP/2 connection to a given host and port pair. {@link #DEFAULT} mode preserves the standard single connection per origin behavior.</li>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* <li>For HTTP/2 this mode deliberately opens more than one connection to the same host and port
* — one per resolved IP — so streams can be spread across the host's backends. That is a
* conscious deviation from RFC 9113 (and RFC 7540) Section 9.1, which recommends that clients
* SHOULD NOT open more than one HTTP/2 connection to a given host and port pair.
* {@link #DEFAULT} mode preserves the standard single-connection-per-origin behavior.</li>
* <li>With HTTP/2 and a finite {@code maxConnectionsPerHost} smaller than the number of resolved
* IPs, a request pinned to an IP that cannot acquire a per-host connection permit will not
* multiplex onto a sibling connection already open to a different IP (HTTP/2 connections are
* pooled per IP in this mode); it fails with {@code TooManyConnectionsPerHostException}
* instead. The default {@code maxConnectionsPerHost} is unlimited, so this only affects
* clients that set a finite per-host limit below the resolved-IP count; {@link #DEFAULT} mode
* would multiplex onto the shared per-host connection.</li>
* <li>The address order comes straight from the configured
* {@link io.netty.resolver.InetNameResolver}; this mode does not re-sort it. For the
* rotation to map consistently across requests, use a resolver that returns the addresses
* in a stable order and does not deliberately reorder them between resolutions (for
* example {@link io.netty.resolver.dns.DnsNameResolver}). Do not pair this mode with a
* resolver that intentionally rotates its results, such as
* {@link io.netty.resolver.RoundRobinInetAddressResolver} — that one is meant for
* {@link #DEFAULT} mode, where it provides the spreading instead.</li>
* <li>Rotation is not health-aware: it always cycles through every IP the resolver returns, so
* a temporarily unreachable IP keeps receiving its share of requests — each then retried
* via TCP failover to a healthy IP — until the resolver stops returning it. Which IPs are
* live is expected to be governed at the DNS/resolver level, as it already is in
* {@link #DEFAULT} mode.</li>
* </ul>
*/
ROUND_ROBIN
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,22 @@
*/
package org.asynchttpclient.config;

import org.asynchttpclient.LoadBalance;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.Arrays;
import java.util.Locale;
import java.util.Properties;

public final class AsyncHttpClientConfigDefaults {

private static final Logger LOGGER = LoggerFactory.getLogger(AsyncHttpClientConfigDefaults.class);

public static final String ASYNC_CLIENT_CONFIG_ROOT = "org.asynchttpclient.";
public static final String THREAD_POOL_NAME_CONFIG = "threadPoolName";
public static final String MAX_CONNECTIONS_CONFIG = "maxConnections";
Expand Down Expand Up @@ -52,6 +59,7 @@ public final class AsyncHttpClientConfigDefaults {
public static final String STRICT_302_HANDLING_CONFIG = "strict302Handling";
public static final String KEEP_ALIVE_CONFIG = "keepAlive";
public static final String MAX_REQUEST_RETRY_CONFIG = "maxRequestRetry";
public static final String LOAD_BALANCE_CONFIG = "loadBalance";
public static final String DISABLE_URL_ENCODING_FOR_BOUND_REQUESTS_CONFIG = "disableUrlEncodingForBoundRequests";
public static final String USE_LAX_COOKIE_ENCODER_CONFIG = "useLaxCookieEncoder";
public static final String USE_OPEN_SSL_CONFIG = "useOpenSsl";
Expand Down Expand Up @@ -373,4 +381,19 @@ public static Duration defaultHttp2PingInterval() {
public static boolean defaultHttp2CleartextEnabled() {
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + HTTP2_CLEARTEXT_ENABLED_CONFIG);
}

public static LoadBalance defaultLoadBalance() {
String value = AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getString(ASYNC_CLIENT_CONFIG_ROOT + LOAD_BALANCE_CONFIG);
if (value == null || value.trim().isEmpty()) {
return LoadBalance.DEFAULT;
}
try {
return LoadBalance.valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quietly falling back to DEFAULT when the value doesn't parse will hide typos in someone's config. A warning log that includes the bad value would make that a lot easier to spot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

LOGGER.warn("Invalid value '{}' for {}{}, falling back to {}. Valid values: {}",
value, ASYNC_CLIENT_CONFIG_ROOT, LOAD_BALANCE_CONFIG,
LoadBalance.DEFAULT, Arrays.toString(LoadBalance.values()));
return LoadBalance.DEFAULT;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.asynchttpclient.netty.channel.ChannelState;
import org.asynchttpclient.netty.channel.Channels;
import org.asynchttpclient.netty.channel.ConnectionSemaphore;
import org.asynchttpclient.netty.channel.RoundRobinPartitionKey;
import org.asynchttpclient.netty.request.NettyRequest;
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
import org.asynchttpclient.proxy.ProxyServer;
Expand All @@ -33,6 +34,9 @@
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
Expand Down Expand Up @@ -127,6 +131,10 @@ public final class NettyResponseFuture<V> implements ListenableFuture<V> {
private boolean allowConnect;
private Realm realm;
private Realm proxyRealm;
// LoadBalance.ROUND_ROBIN overrides; all null in DEFAULT mode
private volatile Object partitionKeyOverride;
private volatile List<InetSocketAddress> roundRobinAddresses;
private volatile Uri roundRobinBaseUri;
private volatile ScramContext scramContext;

public NettyResponseFuture(Request originalRequest,
Expand Down Expand Up @@ -530,16 +538,91 @@ public long getStart() {
}

public Object getPartitionKey() {
Object override = partitionKeyOverride;
if (override != null) {
return override;
}
return basePartitionKey();
}

/**
* The per-host partition key, ignoring any round-robin IP-aware override. Used for the connection
* semaphore so {@code maxConnectionsPerHost} stays per host (not per IP): the permit is taken
* before the target IP is known and the connector may fail over to a different IP than the one
* initially selected.
*/
public Object basePartitionKey() {
return connectionPoolPartitioning.getPartitionKey(targetRequest.getUri(), targetRequest.getVirtualHost(),
proxyServer);
}

/**
* @return the IP-aware partition key set for {@link org.asynchttpclient.LoadBalance#ROUND_ROBIN},
* or {@code null} when not in round-robin mode
*/
public Object getPartitionKeyOverride() {
return partitionKeyOverride;
}

public void setPartitionKeyOverride(Object partitionKeyOverride) {
this.partitionKeyOverride = partitionKeyOverride;
}

/**
* @return the resolved addresses to connect to (round-robin-ordered), or {@code null} to resolve
* lazily as usual
*/
public List<InetSocketAddress> getRoundRobinAddresses() {
return roundRobinAddresses;
}

public void setRoundRobinAddresses(List<InetSocketAddress> roundRobinAddresses) {
this.roundRobinAddresses = roundRobinAddresses;
}

/**
* @return the base URI (scheme, host and port) the round-robin overrides were computed for, used
* to detect base changes on redirects — including same-host scheme/port changes such as an
* HTTP-to-HTTPS upgrade, whose resolved addresses and partition key differ from the cached ones
*/
public Uri getRoundRobinBaseUri() {
return roundRobinBaseUri;
}

public void setRoundRobinBaseUri(Uri roundRobinBaseUri) {
this.roundRobinBaseUri = roundRobinBaseUri;
}

/**
* Drops any round-robin overrides, e.g. when this future is reused for a cross-host redirect whose
* target is not eligible for round-robin.
*/
public void clearRoundRobinOverrides() {
partitionKeyOverride = null;
roundRobinAddresses = null;
roundRobinBaseUri = null;
}

/**
* Re-pins the round-robin partition key to the IP actually connected to. The connector may have
* failed over from the initially selected IP to a later one; keying connection reuse by the real
* peer IP keeps the pool / HTTP/2 registry correct. No-op outside round-robin mode.
*/
public void repinRoundRobinAddress(InetAddress actualAddress) {
Object override = partitionKeyOverride;
if (actualAddress != null && override instanceof RoundRobinPartitionKey) {
partitionKeyOverride = ((RoundRobinPartitionKey) override).withAddress(actualAddress);
}
}

public void acquirePartitionLockLazily() throws IOException {
if (connectionSemaphore == null || partitionKeyLock != null) {
return;
}

Object partitionKey = getPartitionKey();
// Semaphore is keyed per host (base key), not the round-robin per-IP override: the permit is
// taken before the target IP is known and the connector may fail over to another IP.
Object partitionKey = basePartitionKey();
connectionSemaphore.acquireChannelLock(partitionKey);
Object prevKey = PARTITION_KEY_LOCK_FIELD.getAndSet(this, partitionKey);
if (prevKey != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,10 @@ public Channel poll(Uri uri, String virtualHost, ProxyServer proxy, ChannelPoolP
return channelPool.poll(partitionKey);
}

public Channel poll(Object partitionKey) {
return channelPool.poll(partitionKey);
}

public void removeAll(Channel connection) {
channelPool.removeAll(connection);
}
Expand Down
Loading