arr) {
+ return arr.stream()
+ .map(AddressSelectingDnsResolver::addr)
+ .collect(Collectors.toList());
+ }
+
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ConnectionConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ConnectionConfig.java
index 5c2f654985..efce4fa07f 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ConnectionConfig.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ConnectionConfig.java
@@ -43,6 +43,16 @@
public class ConnectionConfig implements Cloneable {
private static final Timeout DEFAULT_CONNECT_TIMEOUT = Timeout.ofMinutes(3);
+ private static final long MIN_ATTEMPT_DELAY_MS = 10;
+
+ /**
+ * @since 5.6
+ */
+ private static final TimeValue DEFAULT_HE_ATTEMPT_DELAY = TimeValue.ofMilliseconds(250);
+ /**
+ * @since 5.6
+ */
+ private static final TimeValue DEFAULT_HE_OTHER_FAMILY_DELAY = TimeValue.ofMilliseconds(50);
public static final ConnectionConfig DEFAULT = new Builder().build();
@@ -52,11 +62,28 @@ public class ConnectionConfig implements Cloneable {
private final TimeValue validateAfterInactivity;
private final TimeValue timeToLive;
+ /**
+ * @since 5.6
+ */
+ private final boolean happyEyeballsEnabled;
+ /**
+ * @since 5.6
+ */
+ private final TimeValue happyEyeballsAttemptDelay;
+ /**
+ * @since 5.6
+ */
+ private final TimeValue happyEyeballsOtherFamilyDelay;
+ /**
+ * @since 5.6
+ */
+ private final ProtocolFamilyPreference protocolFamilyPreference;
+
/**
* Intended for CDI compatibility
*/
protected ConnectionConfig() {
- this(DEFAULT_CONNECT_TIMEOUT, null, null, null, null);
+ this(DEFAULT_CONNECT_TIMEOUT, null, null, null, null, false, DEFAULT_HE_ATTEMPT_DELAY, DEFAULT_HE_OTHER_FAMILY_DELAY, ProtocolFamilyPreference.INTERLEAVE);
}
ConnectionConfig(
@@ -64,13 +91,21 @@ protected ConnectionConfig() {
final Timeout socketTimeout,
final Timeout idleTimeout,
final TimeValue validateAfterInactivity,
- final TimeValue timeToLive) {
+ final TimeValue timeToLive,
+ final boolean happyEyeballsEnabled,
+ final TimeValue happyEyeballsAttemptDelay,
+ final TimeValue happyEyeballsOtherFamilyDelay,
+ final ProtocolFamilyPreference protocolFamilyPreference) {
super();
this.connectTimeout = connectTimeout;
this.socketTimeout = socketTimeout;
this.idleTimeout = idleTimeout;
this.validateAfterInactivity = validateAfterInactivity;
this.timeToLive = timeToLive;
+ this.happyEyeballsEnabled = happyEyeballsEnabled;
+ this.happyEyeballsAttemptDelay = happyEyeballsAttemptDelay != null ? happyEyeballsAttemptDelay : DEFAULT_HE_ATTEMPT_DELAY;
+ this.happyEyeballsOtherFamilyDelay = happyEyeballsOtherFamilyDelay != null ? happyEyeballsOtherFamilyDelay : DEFAULT_HE_OTHER_FAMILY_DELAY;
+ this.protocolFamilyPreference = protocolFamilyPreference != null ? protocolFamilyPreference : ProtocolFamilyPreference.INTERLEAVE;
}
/**
@@ -108,6 +143,46 @@ public TimeValue getTimeToLive() {
return timeToLive;
}
+ /**
+ * Whether Happy Eyeballs connection attempts are enabled.
+ *
+ * @see Builder#setHappyEyeballsEnabled(boolean)
+ * @since 5.7
+ */
+ public boolean isHappyEyeballsEnabled() {
+ return happyEyeballsEnabled;
+ }
+
+ /**
+ * Delay between subsequent concurrent connection attempts.
+ *
+ * @see Builder#setHappyEyeballsAttemptDelay(TimeValue)
+ * @since 5.7
+ */
+ public TimeValue getHappyEyeballsAttemptDelay() {
+ return happyEyeballsAttemptDelay;
+ }
+
+ /**
+ * Initial delay before launching the first address of the other protocol family.
+ *
+ * @see Builder#setHappyEyeballsOtherFamilyDelay(TimeValue)
+ * @since 5.7
+ */
+ public TimeValue getHappyEyeballsOtherFamilyDelay() {
+ return happyEyeballsOtherFamilyDelay;
+ }
+
+ /**
+ * Protocol family preference controlling address selection and ordering.
+ *
+ * @see Builder#setProtocolFamilyPreference(ProtocolFamilyPreference)
+ * @since 5.7
+ */
+ public ProtocolFamilyPreference getProtocolFamilyPreference() {
+ return protocolFamilyPreference;
+ }
+
@Override
protected ConnectionConfig clone() throws CloneNotSupportedException {
return (ConnectionConfig) super.clone();
@@ -122,6 +197,10 @@ public String toString() {
builder.append(", idleTimeout=").append(idleTimeout);
builder.append(", validateAfterInactivity=").append(validateAfterInactivity);
builder.append(", timeToLive=").append(timeToLive);
+ builder.append(", happyEyeballsEnabled=").append(happyEyeballsEnabled);
+ builder.append(", happyEyeballsAttemptDelay=").append(happyEyeballsAttemptDelay);
+ builder.append(", happyEyeballsOtherFamilyDelay=").append(happyEyeballsOtherFamilyDelay);
+ builder.append(", protocolFamilyPreference=").append(protocolFamilyPreference);
builder.append("]");
return builder.toString();
}
@@ -135,7 +214,11 @@ public static ConnectionConfig.Builder copy(final ConnectionConfig config) {
.setConnectTimeout(config.getConnectTimeout())
.setSocketTimeout(config.getSocketTimeout())
.setValidateAfterInactivity(config.getValidateAfterInactivity())
- .setTimeToLive(config.getTimeToLive());
+ .setTimeToLive(config.getTimeToLive())
+ .setHappyEyeballsEnabled(config.isHappyEyeballsEnabled())
+ .setHappyEyeballsAttemptDelay(config.getHappyEyeballsAttemptDelay())
+ .setHappyEyeballsOtherFamilyDelay(config.getHappyEyeballsOtherFamilyDelay())
+ .setProtocolFamilyPreference(config.getProtocolFamilyPreference());
}
public static class Builder {
@@ -146,6 +229,12 @@ public static class Builder {
private TimeValue validateAfterInactivity;
private TimeValue timeToLive;
+ // New fields (defaults)
+ private boolean happyEyeballsEnabled = false; // disabled by default
+ private TimeValue happyEyeballsAttemptDelay = DEFAULT_HE_ATTEMPT_DELAY;
+ private TimeValue happyEyeballsOtherFamilyDelay = DEFAULT_HE_OTHER_FAMILY_DELAY;
+ private ProtocolFamilyPreference protocolFamilyPreference = ProtocolFamilyPreference.INTERLEAVE;
+
Builder() {
super();
this.connectTimeout = DEFAULT_CONNECT_TIMEOUT;
@@ -281,13 +370,82 @@ public Builder setTimeToLive(final long timeToLive, final TimeUnit timeUnit) {
return this;
}
+ /**
+ * Enables or disables Happy Eyeballs connection attempts.
+ *
+ * @since 5.7
+ * @return this instance.
+ */
+ public Builder setHappyEyeballsEnabled(final boolean enabled) {
+ this.happyEyeballsEnabled = enabled;
+ return this;
+ }
+
+ /**
+ * Sets the delay between concurrent connection attempts.
+ *
+ * The value must not be less than 10 ms. The recommended minimum is 100 ms
+ * and the recommended upper bound is 2 seconds.
+ *
+ *
+ * @since 5.7
+ * @return this instance.
+ * @throws IllegalArgumentException if {@code delay} is less than 10 ms.
+ */
+ public Builder setHappyEyeballsAttemptDelay(final TimeValue delay) {
+ if (delay != null && delay.toMilliseconds() < MIN_ATTEMPT_DELAY_MS) {
+ throw new IllegalArgumentException(
+ "Happy Eyeballs attempt delay must not be less than " + MIN_ATTEMPT_DELAY_MS + " ms, got: "
+ + delay.toMilliseconds() + " ms");
+ }
+ this.happyEyeballsAttemptDelay = delay;
+ return this;
+ }
+
+ /**
+ * Sets the initial delay before launching the first address of the other
+ * protocol family (IPv6 vs IPv4) when interleaving attempts.
+ *
+ * The value must not be less than 10 ms and is clamped to the attempt delay.
+ *
+ *
+ * @since 5.7
+ * @return this instance.
+ * @throws IllegalArgumentException if {@code delay} is less than 10 ms.
+ */
+ public Builder setHappyEyeballsOtherFamilyDelay(final TimeValue delay) {
+ if (delay != null && delay.toMilliseconds() < MIN_ATTEMPT_DELAY_MS) {
+ throw new IllegalArgumentException(
+ "Happy Eyeballs other-family delay must not be less than " + MIN_ATTEMPT_DELAY_MS + " ms, got: "
+ + delay.toMilliseconds() + " ms");
+ }
+ this.happyEyeballsOtherFamilyDelay = delay;
+ return this;
+ }
+
+ /**
+ * Sets the protocol family preference that guides address selection and ordering.
+ *
+ * @since 5.7
+ * @return this instance.
+ */
+ public Builder setProtocolFamilyPreference(final ProtocolFamilyPreference preference) {
+ this.protocolFamilyPreference = preference;
+ return this;
+ }
+
public ConnectionConfig build() {
return new ConnectionConfig(
connectTimeout != null ? connectTimeout : DEFAULT_CONNECT_TIMEOUT,
socketTimeout,
idleTimeout,
validateAfterInactivity,
- timeToLive);
+ timeToLive,
+ happyEyeballsEnabled,
+ happyEyeballsAttemptDelay != null ? happyEyeballsAttemptDelay : DEFAULT_HE_ATTEMPT_DELAY,
+ happyEyeballsOtherFamilyDelay != null ? happyEyeballsOtherFamilyDelay : DEFAULT_HE_OTHER_FAMILY_DELAY,
+ protocolFamilyPreference != null ? protocolFamilyPreference : ProtocolFamilyPreference.INTERLEAVE
+ );
}
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java
new file mode 100644
index 0000000000..fb30563830
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/ProtocolFamilyPreference.java
@@ -0,0 +1,72 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.config;
+
+/**
+ * Protocol family preference for outbound connections.
+ *
+ * Used by connection initiation code to filter or order destination
+ * addresses and, when enabled, to interleave families during concurrent attempts.
+ *
+ * @since 5.7
+ */
+public enum ProtocolFamilyPreference {
+
+ /**
+ * No family bias. Preserve destination address selection order.
+ */
+ DEFAULT,
+
+ /**
+ * Prefer IPv4 addresses (stable: preserves sorted order within each family).
+ */
+ PREFER_IPV4,
+
+ /**
+ * Prefer IPv6 addresses (stable: preserves sorted order within each family).
+ */
+ PREFER_IPV6,
+
+ /**
+ * Filter out all non-IPv4 addresses.
+ */
+ IPV4_ONLY,
+
+ /**
+ * Filter out all non-IPv6 addresses.
+ */
+ IPV6_ONLY,
+
+ /**
+ * Interleave address families (v6, then v4, then v6, …) when multiple
+ * addresses are available, preserving the relative order within each family
+ * as produced by destination address sorting.
+ */
+ INTERLEAVE
+
+}
+
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
index e0f0d5050d..f4efd34cec 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
@@ -37,6 +37,7 @@
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SchemePortResolver;
import org.apache.hc.client5.http.UnsupportedSchemeException;
+import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.impl.ConnPoolSupport;
import org.apache.hc.client5.http.impl.DefaultSchemePortResolver;
@@ -71,21 +72,14 @@ public class DefaultAsyncClientConnectionOperator implements AsyncClientConnecti
private final MultihomeIOSessionRequester sessionRequester;
private final Lookup tlsStrategyLookup;
- /**
- * Constructs a new {@code DefaultAsyncClientConnectionOperator}.
- *
- * Note: this class is marked {@code @Internal}; rely on it
- * only if you are prepared for incompatible changes in a future major
- * release. Typical client code should use the high-level builders in
- * {@code HttpAsyncClients} instead.
- */
- protected DefaultAsyncClientConnectionOperator(
+ DefaultAsyncClientConnectionOperator(
final Lookup tlsStrategyLookup,
final SchemePortResolver schemePortResolver,
- final DnsResolver dnsResolver) {
+ final DnsResolver dnsResolver,
+ final ConnectionConfig defaultConnectionConfig) {
this.tlsStrategyLookup = Args.notNull(tlsStrategyLookup, "TLS strategy lookup");
this.schemePortResolver = schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE;
- this.sessionRequester = new MultihomeIOSessionRequester(dnsResolver);
+ this.sessionRequester = new MultihomeIOSessionRequester(dnsResolver, defaultConnectionConfig);
}
@Override
@@ -97,21 +91,21 @@ public Future connect(
final Object attachment,
final FutureCallback callback) {
return connect(connectionInitiator, host, null, localAddress, connectTimeout,
- attachment, null, callback);
+ attachment, null, callback);
}
@Override
public Future connect(
- final ConnectionInitiator connectionInitiator,
- final HttpHost endpointHost,
- final NamedEndpoint endpointName,
- final SocketAddress localAddress,
- final Timeout connectTimeout,
- final Object attachment,
- final HttpContext context,
- final FutureCallback callback) {
- return connect(connectionInitiator, endpointHost, null, endpointName, localAddress, connectTimeout, attachment,
- context, callback);
+ final ConnectionInitiator connectionInitiator,
+ final HttpHost endpointHost,
+ final NamedEndpoint endpointName,
+ final SocketAddress localAddress,
+ final Timeout connectTimeout,
+ final Object attachment,
+ final HttpContext context,
+ final FutureCallback callback) {
+ return connect(connectionInitiator, endpointHost, null, endpointName, localAddress, connectTimeout,
+ attachment, context, callback);
}
@Override
@@ -127,16 +121,22 @@ public Future connect(
final FutureCallback callback) {
Args.notNull(connectionInitiator, "Connection initiator");
Args.notNull(endpointHost, "Host");
+
final ComplexFuture future = new ComplexFuture<>(callback);
final HttpHost remoteEndpoint = RoutingSupport.normalize(endpointHost, schemePortResolver);
+
final SocketAddress remoteAddress;
if (unixDomainSocket == null) {
final InetAddress remoteInetAddress = endpointHost.getAddress();
- remoteAddress = remoteInetAddress != null ?
- new InetSocketAddress(remoteInetAddress, remoteEndpoint.getPort()) : null;
+ if (remoteInetAddress != null) {
+ remoteAddress = new InetSocketAddress(remoteInetAddress, remoteEndpoint.getPort());
+ } else {
+ remoteAddress = null;
+ }
} else {
remoteAddress = createUnixSocketAddress(unixDomainSocket);
}
+
final TlsConfig tlsConfig = attachment instanceof TlsConfig ? (TlsConfig) attachment : TlsConfig.DEFAULT;
onBeforeSocketConnect(context, endpointHost);
@@ -161,16 +161,22 @@ public void completed(final IOSession session) {
LOG.debug("{} {} connected {}->{}", ConnPoolSupport.getId(connection), endpointHost,
connection.getLocalAddress(), connection.getRemoteAddress());
}
- final TlsStrategy tlsStrategy = tlsStrategyLookup != null ? tlsStrategyLookup.lookup(endpointHost.getSchemeName()) : null;
+ final TlsStrategy tlsStrategy = tlsStrategyLookup != null
+ ? tlsStrategyLookup.lookup(endpointHost.getSchemeName())
+ : null;
if (tlsStrategy != null) {
try {
final Timeout socketTimeout = connection.getSocketTimeout();
- final Timeout handshakeTimeout = tlsConfig.getHandshakeTimeout() != null ? tlsConfig.getHandshakeTimeout() : connectTimeout;
+ final Timeout handshakeTimeout = tlsConfig.getHandshakeTimeout() != null
+ ? tlsConfig.getHandshakeTimeout()
+ : connectTimeout;
final NamedEndpoint tlsName = endpointName != null ? endpointName : endpointHost;
+
onBeforeTlsHandshake(context, endpointHost);
if (LOG.isDebugEnabled()) {
LOG.debug("{} {} upgrading to TLS", ConnPoolSupport.getId(connection), tlsName);
}
+
tlsStrategy.upgrade(
connection,
tlsName,
@@ -208,11 +214,12 @@ public void cancelled() {
}
});
+
future.setDependency(sessionFuture);
return future;
}
- // The IOReactor does not support AFUNIXSocketChannel from JUnixSocket, so if a Unix domain socket was configured,
+ // The IOReactor does not support AFUNIXSocketChannel from JUnixSocket, so if a Unix domain socket was configured,
// we must use JEP 380 sockets and addresses.
private static SocketAddress createUnixSocketAddress(final Path socketPath) {
try {
@@ -240,7 +247,10 @@ public void upgrade(
final Object attachment,
final HttpContext context,
final FutureCallback callback) {
- final String newProtocol = URIScheme.HTTP.same(endpointHost.getSchemeName()) ? URIScheme.HTTPS.id : endpointHost.getSchemeName();
+ final String newProtocol = URIScheme.HTTP.same(endpointHost.getSchemeName())
+ ? URIScheme.HTTPS.id
+ : endpointHost.getSchemeName();
+
final TlsStrategy tlsStrategy = tlsStrategyLookup != null ? tlsStrategyLookup.lookup(newProtocol) : null;
if (tlsStrategy != null) {
final NamedEndpoint tlsName = endpointName != null ? endpointName : endpointHost;
@@ -263,7 +273,9 @@ public void completed(final TransportSecurityLayer transportSecurityLayer) {
});
} else {
- callback.failed(new UnsupportedSchemeException(newProtocol + " protocol is not supported"));
+ if (callback != null) {
+ callback.failed(new UnsupportedSchemeException(newProtocol + " protocol is not supported"));
+ }
}
}
@@ -279,4 +291,8 @@ protected void onBeforeTlsHandshake(final HttpContext httpContext, final HttpHos
protected void onAfterTlsHandshake(final HttpContext httpContext, final HttpHost endpointHost) {
}
+ public void shutdown() {
+ sessionRequester.shutdown();
+ }
+
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeConnectionInitiator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeConnectionInitiator.java
index 2e8e00bbf0..f524c2785c 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeConnectionInitiator.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeConnectionInitiator.java
@@ -27,10 +27,14 @@
package org.apache.hc.client5.http.impl.nio;
+import java.io.Closeable;
+import java.io.IOException;
import java.net.SocketAddress;
import java.util.concurrent.Future;
import org.apache.hc.client5.http.DnsResolver;
+import org.apache.hc.client5.http.SystemDefaultDnsResolver;
+import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.ThreadingBehavior;
import org.apache.hc.core5.concurrent.FutureCallback;
@@ -46,7 +50,7 @@
* @since 5.0
*/
@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
-public final class MultihomeConnectionInitiator implements ConnectionInitiator {
+public final class MultihomeConnectionInitiator implements ConnectionInitiator, Closeable {
private final ConnectionInitiator connectionInitiator;
private final MultihomeIOSessionRequester sessionRequester;
@@ -54,8 +58,19 @@ public final class MultihomeConnectionInitiator implements ConnectionInitiator {
public MultihomeConnectionInitiator(
final ConnectionInitiator connectionInitiator,
final DnsResolver dnsResolver) {
+ this(connectionInitiator, dnsResolver, null);
+ }
+
+ /**
+ * @since 5.6
+ */
+ public MultihomeConnectionInitiator(
+ final ConnectionInitiator connectionInitiator,
+ final DnsResolver dnsResolver,
+ final ConnectionConfig connectionConfig) {
this.connectionInitiator = Args.notNull(connectionInitiator, "Connection initiator");
- this.sessionRequester = new MultihomeIOSessionRequester(dnsResolver);
+ final DnsResolver effectiveResolver = dnsResolver != null ? dnsResolver : SystemDefaultDnsResolver.INSTANCE;
+ this.sessionRequester = new MultihomeIOSessionRequester(effectiveResolver, connectionConfig);
}
@Override
@@ -67,7 +82,8 @@ public Future connect(
final Object attachment,
final FutureCallback callback) {
Args.notNull(remoteEndpoint, "Remote endpoint");
- return sessionRequester.connect(connectionInitiator, remoteEndpoint, remoteAddress, localAddress, connectTimeout, attachment, callback);
+ return sessionRequester.connect(connectionInitiator, remoteEndpoint, remoteAddress, localAddress,
+ connectTimeout, attachment, callback);
}
public Future connect(
@@ -77,7 +93,18 @@ public Future connect(
final Object attachment,
final FutureCallback callback) {
Args.notNull(remoteEndpoint, "Remote endpoint");
- return sessionRequester.connect(connectionInitiator, remoteEndpoint, localAddress, connectTimeout, attachment, callback);
+ return sessionRequester.connect(connectionInitiator, remoteEndpoint, localAddress,
+ connectTimeout, attachment, callback);
+ }
+
+ /**
+ * Shuts down internal resources used by this initiator (if any).
+ *
+ * @since 5.6
+ */
+ @Override
+ public void close() throws IOException {
+ sessionRequester.shutdown();
}
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequester.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequester.java
index 4b8172c4ce..c9b4e9d2ea 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequester.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequester.java
@@ -28,33 +28,79 @@
package org.apache.hc.client5.http.impl.nio;
import java.io.IOException;
+import java.net.ConnectException;
+import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.UnknownHostException;
+import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.hc.client5.http.ConnectExceptionSupport;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.ProtocolFamilyPreference;
import org.apache.hc.core5.concurrent.ComplexFuture;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.net.NamedEndpoint;
import org.apache.hc.core5.reactor.ConnectionInitiator;
import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+/**
+ * Multi-home dialing strategy for {@link ConnectionInitiator}.
+ *
+ * If {@link ConnectionConfig#isHappyEyeballsEnabled()} is {@code false},
+ * attempts addresses sequentially (legacy behaviour). If enabled, performs concurrent,
+ * interleaved connection attempts across protocol families (Happy Eyeballs–style).
+ *
+ * @since 5.6
+ */
final class MultihomeIOSessionRequester {
private static final Logger LOG = LoggerFactory.getLogger(MultihomeIOSessionRequester.class);
+
+ private static final long DEFAULT_ATTEMPT_DELAY_MS = 250L;
+ private static final long DEFAULT_OTHER_FAMILY_DELAY_MS = 50L;
+
private final DnsResolver dnsResolver;
+ private final ConnectionConfig connectionConfig;
+
+ private final ScheduledExecutorService scheduler; // created only when concurrent connect is enabled
+ private final AtomicBoolean shutdown;
MultihomeIOSessionRequester(final DnsResolver dnsResolver) {
+ this(dnsResolver, null);
+ }
+
+ MultihomeIOSessionRequester(final DnsResolver dnsResolver, final ConnectionConfig connectionConfig) {
this.dnsResolver = dnsResolver != null ? dnsResolver : SystemDefaultDnsResolver.INSTANCE;
+ this.connectionConfig = connectionConfig != null ? connectionConfig : ConnectionConfig.DEFAULT;
+ this.shutdown = new AtomicBoolean(false);
+
+ if (this.connectionConfig.isHappyEyeballsEnabled()) {
+ this.scheduler = Executors.newSingleThreadScheduledExecutor(
+ new DefaultThreadFactory("hc-happy-eyeballs", true));
+ } else {
+ this.scheduler = null;
+ }
}
public Future connect(
@@ -67,47 +113,58 @@ public Future connect(
final FutureCallback callback) {
final ComplexFuture future = new ComplexFuture<>(callback);
+
+ // If a specific address is given, dial it directly (no multi-home logic).
if (remoteAddress != null) {
if (LOG.isDebugEnabled()) {
- LOG.debug("{}:{} connecting {} to {} ({})",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), localAddress, remoteAddress, connectTimeout);
+ LOG.debug("{}:{} connecting {}->{} ({})",
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ localAddress, remoteAddress, connectTimeout);
}
- final Future sessionFuture = connectionInitiator.connect(remoteEndpoint, remoteAddress, localAddress, connectTimeout, attachment, new FutureCallback() {
- @Override
- public void completed(final IOSession session) {
- future.completed(session);
- }
-
- @Override
- public void failed(final Exception cause) {
- if (LOG.isDebugEnabled()) {
- LOG.debug("{}:{} connection to {} failed ({}); terminating operation",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), remoteAddress, cause.getClass());
- }
- if (cause instanceof IOException) {
- future.failed(ConnectExceptionSupport.enhance((IOException) cause, remoteEndpoint,
- (remoteAddress instanceof InetSocketAddress) ?
- new InetAddress[] { ((InetSocketAddress) remoteAddress).getAddress() } :
- new InetAddress[] {}));
- } else {
- future.failed(cause);
- }
- }
+ final Future sessionFuture = connectionInitiator.connect(
+ remoteEndpoint, remoteAddress, localAddress, connectTimeout, attachment,
+ new FutureCallback() {
+ @Override
+ public void completed(final IOSession session) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{}:{} connected {}->{} as {}",
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ localAddress, remoteAddress, session.getId());
+ }
+ future.completed(session);
+ }
- @Override
- public void cancelled() {
- future.cancel();
- }
+ @Override
+ public void failed(final Exception cause) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{}:{} connection to {} failed ({}); terminating",
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ remoteAddress, cause.getClass());
+ }
+ if (cause instanceof IOException) {
+ final InetAddress[] addrs;
+ if (remoteAddress instanceof InetSocketAddress) {
+ final InetAddress a = ((InetSocketAddress) remoteAddress).getAddress();
+ addrs = a != null ? new InetAddress[]{a} : null;
+ } else {
+ addrs = null;
+ }
+ future.failed(ConnectExceptionSupport.enhance((IOException) cause, remoteEndpoint, addrs));
+ } else {
+ future.failed(cause);
+ }
+ }
- });
+ @Override
+ public void cancelled() {
+ future.cancel();
+ }
+ });
future.setDependency(sessionFuture);
return future;
}
- if (LOG.isDebugEnabled()) {
- LOG.debug("{} resolving remote address", remoteEndpoint.getHostName());
- }
-
+ // Resolve all addresses
final List remoteAddresses;
try {
remoteAddresses = dnsResolver.resolve(remoteEndpoint.getHostName(), remoteEndpoint.getPort());
@@ -115,6 +172,9 @@ public void cancelled() {
throw new UnknownHostException(remoteEndpoint.getHostName());
}
} catch (final UnknownHostException ex) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} DNS resolution failed: {}", remoteEndpoint.getHostName(), ex.getMessage());
+ }
future.failed(ex);
return future;
}
@@ -123,32 +183,63 @@ public void cancelled() {
LOG.debug("{} resolved to {}", remoteEndpoint.getHostName(), remoteAddresses);
}
- final Runnable runnable = new Runnable() {
+ final boolean happyEyeballsEnabled = this.connectionConfig.isHappyEyeballsEnabled();
+
+ if (!happyEyeballsEnabled || remoteAddresses.size() == 1) {
+ runSequential(connectionInitiator, remoteEndpoint, remoteAddresses, localAddress,
+ connectTimeout, attachment, future);
+ } else {
+ runConcurrent(connectionInitiator, remoteEndpoint, remoteAddresses, localAddress,
+ connectTimeout, attachment, future);
+ }
+
+ return future;
+ }
+
+ public Future connect(
+ final ConnectionInitiator connectionInitiator,
+ final NamedEndpoint remoteEndpoint,
+ final SocketAddress localAddress,
+ final Timeout connectTimeout,
+ final Object attachment,
+ final FutureCallback callback) {
+ return connect(connectionInitiator, remoteEndpoint, null, localAddress, connectTimeout, attachment, callback);
+ }
+
+ // ----------------- legacy sequential -----------------
+ private void runSequential(
+ final ConnectionInitiator connectionInitiator,
+ final NamedEndpoint remoteEndpoint,
+ final List remoteAddresses,
+ final SocketAddress localAddress,
+ final Timeout connectTimeout,
+ final Object attachment,
+ final ComplexFuture future) {
+
+ final Runnable r = new Runnable() {
private final AtomicInteger attempt = new AtomicInteger(0);
void executeNext() {
final int index = attempt.getAndIncrement();
- final InetSocketAddress remoteAddress = remoteAddresses.get(index);
+ final InetSocketAddress nextRemote = remoteAddresses.get(index);
if (LOG.isDebugEnabled()) {
- LOG.debug("{}:{} connecting {}->{} ({})",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), localAddress, remoteAddress, connectTimeout);
+ LOG.debug("{}:{} connecting {}->{} ({}) [sequential attempt {}/{}]",
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ localAddress, nextRemote, connectTimeout,
+ index + 1, remoteAddresses.size());
}
final Future sessionFuture = connectionInitiator.connect(
- remoteEndpoint,
- remoteAddress,
- localAddress,
- connectTimeout,
- attachment,
+ remoteEndpoint, nextRemote, localAddress, connectTimeout, attachment,
new FutureCallback() {
-
@Override
public void completed(final IOSession session) {
if (LOG.isDebugEnabled()) {
LOG.debug("{}:{} connected {}->{} as {}",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), localAddress, remoteAddress, session.getId());
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ localAddress, nextRemote, session.getId());
}
future.completed(session);
}
@@ -156,23 +247,17 @@ public void completed(final IOSession session) {
@Override
public void failed(final Exception cause) {
if (attempt.get() >= remoteAddresses.size()) {
- if (LOG.isDebugEnabled()) {
- LOG.debug("{}:{} connection to {} failed ({}); terminating operation",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), remoteAddress, cause.getClass());
- }
if (cause instanceof IOException) {
- final InetAddress[] addresses = remoteAddresses.stream()
- .filter(addr -> addr instanceof InetSocketAddress)
- .map(addr -> ((InetSocketAddress) addr).getAddress())
- .toArray(InetAddress[]::new);
- future.failed(ConnectExceptionSupport.enhance((IOException) cause, remoteEndpoint, addresses));
+ final InetAddress[] addrs = toInetAddrs(remoteAddresses);
+ future.failed(ConnectExceptionSupport.enhance((IOException) cause, remoteEndpoint, addrs));
} else {
future.failed(cause);
}
} else {
if (LOG.isDebugEnabled()) {
- LOG.debug("{}:{} connection to {} failed ({}); retrying connection to the next address",
- remoteEndpoint.getHostName(), remoteEndpoint.getPort(), remoteAddress, cause.getClass());
+ LOG.debug("{}:{} connection to {} failed ({}); trying next address",
+ remoteEndpoint.getHostName(), remoteEndpoint.getPort(),
+ nextRemote, cause.getClass());
}
executeNext();
}
@@ -182,7 +267,6 @@ public void failed(final Exception cause) {
public void cancelled() {
future.cancel();
}
-
});
future.setDependency(sessionFuture);
}
@@ -191,20 +275,427 @@ public void cancelled() {
public void run() {
executeNext();
}
-
};
- runnable.run();
- return future;
+
+ r.run();
}
- public Future connect(
+ private void runConcurrent(
final ConnectionInitiator connectionInitiator,
final NamedEndpoint remoteEndpoint,
+ final List remoteAddresses,
final SocketAddress localAddress,
final Timeout connectTimeout,
final Object attachment,
- final FutureCallback callback) {
- return connect(connectionInitiator, remoteEndpoint, null, localAddress, connectTimeout, attachment, callback);
+ final ComplexFuture future) {
+
+ if (scheduler == null) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} Happy Eyeballs requested but scheduler missing; falling back to sequential",
+ remoteEndpoint.getHostName());
+ }
+ runSequential(connectionInitiator, remoteEndpoint, remoteAddresses, localAddress,
+ connectTimeout, attachment, future);
+ return;
+ }
+
+ final InetAddress[] resolvedAddrs = toInetAddrs(remoteAddresses);
+
+ // Split by family
+ final List v6 = new ArrayList<>();
+ final List v4 = new ArrayList<>();
+ for (int i = 0; i < remoteAddresses.size(); i++) {
+ final InetSocketAddress a = remoteAddresses.get(i);
+ if (a.getAddress() instanceof Inet6Address) {
+ v6.add(a);
+ } else {
+ v4.add(a);
+ }
+ }
+
+ final ProtocolFamilyPreference pref = this.connectionConfig.getProtocolFamilyPreference() != null
+ ? this.connectionConfig.getProtocolFamilyPreference()
+ : ProtocolFamilyPreference.INTERLEAVE;
+
+ if (pref == ProtocolFamilyPreference.IPV6_ONLY && v6.isEmpty()
+ || pref == ProtocolFamilyPreference.IPV4_ONLY && v4.isEmpty()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} no addresses for {}", remoteEndpoint.getHostName(), pref);
+ }
+ future.failed(new UnknownHostException(remoteEndpoint.getHostName()));
+ return;
+ }
+
+ List v6Try = v6;
+ List v4Try = v4;
+
+ if (pref == ProtocolFamilyPreference.IPV6_ONLY) {
+ v4Try = new ArrayList<>(0);
+ } else if (pref == ProtocolFamilyPreference.IPV4_ONLY) {
+ v6Try = new ArrayList<>(0);
+ }
+
+ final boolean startWithV6;
+ if (pref == ProtocolFamilyPreference.PREFER_IPV6) {
+ startWithV6 = true;
+ } else if (pref == ProtocolFamilyPreference.PREFER_IPV4) {
+ startWithV6 = false;
+ } else {
+ startWithV6 = !remoteAddresses.isEmpty()
+ && remoteAddresses.get(0).getAddress() instanceof Inet6Address;
+ }
+
+ final long attemptDelayMs = toMillisOrDefault(this.connectionConfig.getHappyEyeballsAttemptDelay(),
+ DEFAULT_ATTEMPT_DELAY_MS);
+ final long otherFamilyDelayMs = Math.min(
+ toMillisOrDefault(this.connectionConfig.getHappyEyeballsOtherFamilyDelay(), DEFAULT_OTHER_FAMILY_DELAY_MS),
+ attemptDelayMs);
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} using Happy Eyeballs: attemptDelay={}ms, otherFamilyDelay={}ms, pref={}",
+ remoteEndpoint.getHostName(), attemptDelayMs, otherFamilyDelayMs, pref);
+ }
+
+ final AtomicBoolean done = new AtomicBoolean(false);
+ final CopyOnWriteArrayList> ioFutures = new CopyOnWriteArrayList<>();
+ final CopyOnWriteArrayList> scheduled = new CopyOnWriteArrayList<>();
+ final AtomicReference lastFailure = new AtomicReference<>(null);
+ final AtomicInteger finishedCount = new AtomicInteger(0);
+ final AtomicInteger totalAttempts = new AtomicInteger(0);
+
+ final Runnable cancelAll = () -> {
+ int cancelledIO = 0;
+ int cancelledTimers = 0;
+
+ for (int i = 0; i < ioFutures.size(); i++) {
+ try {
+ if (ioFutures.get(i).cancel(true)) {
+ cancelledIO++;
+ }
+ } catch (final RuntimeException ignore) {
+ }
+ }
+
+ for (int i = 0; i < scheduled.size(); i++) {
+ try {
+ if (scheduled.get(i).cancel(true)) {
+ cancelledTimers++;
+ }
+ } catch (final RuntimeException ignore) {
+ }
+ }
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} cancelled pending attempts: {} I/O futures, {} timers",
+ remoteEndpoint.getHostName(), cancelledIO, cancelledTimers);
+ }
+ };
+
+ // Propagate cancellation from the returned future into scheduled timers and in-flight connects.
+ future.setDependency(new Future() {
+ @Override
+ public boolean cancel(final boolean mayInterruptIfRunning) {
+ done.set(true);
+ cancelAll.run();
+ return true;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return future.isCancelled();
+ }
+
+ @Override
+ public boolean isDone() {
+ return future.isDone();
+ }
+
+ @Override
+ public IOSession get() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public IOSession get(final long timeout, final TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+ });
+
+ final Attempt attempt = new Attempt(
+ connectionInitiator,
+ remoteEndpoint,
+ localAddress,
+ connectTimeout,
+ attachment,
+ done,
+ ioFutures,
+ lastFailure,
+ finishedCount,
+ totalAttempts,
+ resolvedAddrs,
+ future,
+ cancelAll);
+
+ final List A = startWithV6 ? v6Try : v4Try;
+ final List B = startWithV6 ? v4Try : v6Try;
+
+ long tA = 0L;
+ long tB = A.isEmpty() ? 0L : otherFamilyDelayMs;
+
+ int iA = 0;
+ int iB = 0;
+
+ if (iA < A.size()) {
+ scheduled.add(attempt.schedule(A.get(iA++), tA));
+ tA += attemptDelayMs;
+ }
+ if (iB < B.size()) {
+ scheduled.add(attempt.schedule(B.get(iB++), tB));
+ tB += attemptDelayMs;
+ }
+
+ while (iA < A.size() || iB < B.size()) {
+ if (iA < A.size()) {
+ scheduled.add(attempt.schedule(A.get(iA++), tA));
+ tA += attemptDelayMs;
+ }
+ if (iB < B.size()) {
+ scheduled.add(attempt.schedule(B.get(iB++), tB));
+ tB += attemptDelayMs;
+ }
+ }
+ }
+
+ private static long toMillisOrDefault(final TimeValue tv, final long defMs) {
+ return tv != null ? tv.toMilliseconds() : defMs;
+ }
+
+ private static InetAddress[] toInetAddrs(final List sockAddrs) {
+ final InetAddress[] out = new InetAddress[sockAddrs.size()];
+ for (int i = 0; i < sockAddrs.size(); i++) {
+ out[i] = sockAddrs.get(i).getAddress();
+ }
+ return out;
+ }
+
+ private final class Attempt {
+
+ private final ConnectionInitiator initiator;
+ private final NamedEndpoint host;
+ private final SocketAddress local;
+ private final Timeout timeout;
+ private final Object attachment;
+
+ private final AtomicBoolean done;
+ private final CopyOnWriteArrayList> ioFutures;
+ private final AtomicReference lastFailure;
+ private final AtomicInteger finishedCount;
+ private final AtomicInteger totalAttempts;
+ private final InetAddress[] resolvedAddrs;
+ private final ComplexFuture promise;
+ private final Runnable cancelAll;
+
+ Attempt(final ConnectionInitiator initiator,
+ final NamedEndpoint host,
+ final SocketAddress local,
+ final Timeout timeout,
+ final Object attachment,
+ final AtomicBoolean done,
+ final CopyOnWriteArrayList> ioFutures,
+ final AtomicReference lastFailure,
+ final AtomicInteger finishedCount,
+ final AtomicInteger totalAttempts,
+ final InetAddress[] resolvedAddrs,
+ final ComplexFuture promise,
+ final Runnable cancelAll) {
+ this.initiator = initiator;
+ this.host = host;
+ this.local = local;
+ this.timeout = timeout;
+ this.attachment = attachment;
+ this.done = done;
+ this.ioFutures = ioFutures;
+ this.lastFailure = lastFailure;
+ this.finishedCount = finishedCount;
+ this.totalAttempts = totalAttempts;
+ this.resolvedAddrs = resolvedAddrs;
+ this.promise = promise;
+ this.cancelAll = cancelAll;
+ }
+
+ ScheduledFuture> schedule(final InetSocketAddress dest, final long delayMs) {
+ totalAttempts.incrementAndGet();
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} scheduling connect to {} in {} ms", host.getHostName(), dest, delayMs);
+ }
+
+ try {
+ return scheduler.schedule(() -> {
+ if (done.get()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} skipping connect to {} (already satisfied)", host.getHostName(), dest);
+ }
+ onAttemptFinished();
+ return;
+ }
+
+ try {
+ final Future ioFuture = initiator.connect(
+ host, dest, local, timeout, attachment,
+ new FutureCallback() {
+ @Override
+ public void completed(final IOSession session) {
+ if (done.compareAndSet(false, true)) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} winner: connected to {} ({} total attempts scheduled)",
+ host.getHostName(), dest, totalAttempts.get());
+ }
+ promise.completed(session);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} cancelling losing attempts", host.getHostName());
+ }
+ cancelAll.run();
+ } else {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} late success to {} discarded (already have winner)",
+ host.getHostName(), dest);
+ }
+ try {
+ session.close();
+ } catch (final RuntimeException ignore) {
+ }
+ }
+ onAttemptFinished();
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ lastFailure.set(ex);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} failed to connect to {} ({}/{}) : {}",
+ host.getHostName(), dest,
+ finishedCount.incrementAndGet(), totalAttempts.get(),
+ ex.getClass().getSimpleName());
+ } else {
+ finishedCount.incrementAndGet();
+ }
+ maybeFailAll();
+ }
+
+ @Override
+ public void cancelled() {
+ lastFailure.compareAndSet(null, new CancellationException("Cancelled"));
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} connect attempt to {} was CANCELLED ({}/{})",
+ host.getHostName(), dest,
+ finishedCount.incrementAndGet(), totalAttempts.get());
+ } else {
+ finishedCount.incrementAndGet();
+ }
+ maybeFailAll();
+ }
+ });
+ ioFutures.add(ioFuture);
+ } catch (final RuntimeException ex) {
+ lastFailure.set(ex);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} connect() threw for {} ({}/{}) : {}",
+ host.getHostName(), dest,
+ finishedCount.incrementAndGet(), totalAttempts.get(),
+ ex.getClass().getSimpleName());
+ } else {
+ finishedCount.incrementAndGet();
+ }
+ maybeFailAll();
+ }
+
+ }, delayMs, TimeUnit.MILLISECONDS);
+ } catch (final RejectedExecutionException ex) {
+ lastFailure.set(ex);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} scheduling rejected for {} ({}/{}) : {}",
+ host.getHostName(), dest,
+ finishedCount.incrementAndGet(), totalAttempts.get(),
+ ex.getClass().getSimpleName());
+ } else {
+ finishedCount.incrementAndGet();
+ }
+ maybeFailAll();
+ return new CompletedScheduledFuture<>();
+ }
+ }
+
+ private void onAttemptFinished() {
+ // no-op placeholder; left to keep the accounting in one place if needed later
+ }
+
+ private void maybeFailAll() {
+ final int finished = finishedCount.get();
+ final int total = totalAttempts.get();
+
+ if (!done.get() && finished == total && done.compareAndSet(false, true)) {
+ final Exception last = lastFailure.get();
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("{} all {} attempts exhausted; failing with {}",
+ host.getHostName(), total, last != null ? last.getClass().getSimpleName() : "unknown");
+ }
+ if (last instanceof IOException) {
+ promise.failed(ConnectExceptionSupport.enhance((IOException) last, host, resolvedAddrs));
+ } else {
+ promise.failed(last != null ? last : new ConnectException("All connection attempts failed"));
+ }
+ cancelAll.run();
+ }
+ }
+ }
+
+ /**
+ * Minimal ScheduledFuture implementation used when scheduling is rejected.
+ */
+ private static final class CompletedScheduledFuture implements ScheduledFuture {
+
+ @Override
+ public long getDelay(final TimeUnit unit) {
+ return 0;
+ }
+
+ @Override
+ public int compareTo(final java.util.concurrent.Delayed o) {
+ return 0;
+ }
+
+ @Override
+ public boolean cancel(final boolean mayInterruptIfRunning) {
+ return false;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return false;
+ }
+
+ @Override
+ public boolean isDone() {
+ return true;
+ }
+
+ @Override
+ public V get() {
+ return null;
+ }
+
+ @Override
+ public V get(final long timeout, final TimeUnit unit) {
+ return null;
+ }
+ }
+
+ void shutdown() {
+ if (shutdown.compareAndSet(false, true)) {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ }
}
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
index 1f07c75fe9..f482de6fc5 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
@@ -168,7 +168,18 @@ public PoolingAsyncClientConnectionManager(
final TimeValue timeToLive,
final SchemePortResolver schemePortResolver,
final DnsResolver dnsResolver) {
- this(new DefaultAsyncClientConnectionOperator(tlsStrategyLookup, schemePortResolver, dnsResolver),
+ this(tlsStrategyLookup,poolConcurrencyPolicy,poolReusePolicy,timeToLive,schemePortResolver,dnsResolver,ConnectionConfig.DEFAULT);
+ }
+
+ public PoolingAsyncClientConnectionManager(
+ final Lookup tlsStrategyLookup,
+ final PoolConcurrencyPolicy poolConcurrencyPolicy,
+ final PoolReusePolicy poolReusePolicy,
+ final TimeValue timeToLive,
+ final SchemePortResolver schemePortResolver,
+ final DnsResolver dnsResolver,
+ final ConnectionConfig connectionConfig) {
+ this(new DefaultAsyncClientConnectionOperator(tlsStrategyLookup, schemePortResolver, dnsResolver, connectionConfig),
poolConcurrencyPolicy, poolReusePolicy, timeToLive, null, false);
}
@@ -251,6 +262,17 @@ public void close(final CloseMode closeMode) {
}
this.pool.close(closeMode);
LOG.debug("Connection pool shut down");
+
+ if (connectionOperator instanceof DefaultAsyncClientConnectionOperator) {
+ try {
+ ((DefaultAsyncClientConnectionOperator) connectionOperator).shutdown();
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Connection operator shut down");
+ }
+ } catch (final RuntimeException ex) {
+ LOG.warn("Connection operator shutdown failed", ex);
+ }
+ }
}
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java
index b879395911..6c2a03dfb8 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder.java
@@ -70,6 +70,10 @@ public class PoolingAsyncClientConnectionManagerBuilder {
private Resolver tlsConfigResolver;
private boolean messageMultiplexing;
+ private AsyncClientConnectionOperator connectionOperator;
+
+ private ConnectionConfig defaultConnectionConfig;
+
public static PoolingAsyncClientConnectionManagerBuilder create() {
return new PoolingAsyncClientConnectionManagerBuilder();
}
@@ -170,6 +174,7 @@ public final PoolingAsyncClientConnectionManagerBuilder setConnPoolListener(fina
*/
public final PoolingAsyncClientConnectionManagerBuilder setDefaultConnectionConfig(final ConnectionConfig config) {
this.connectionConfigResolver = route -> config;
+ this.defaultConnectionConfig = config;
return this;
}
@@ -266,17 +271,33 @@ public final PoolingAsyncClientConnectionManagerBuilder setMessageMultiplexing(f
return this;
}
+ /**
+ * Sets custom {@link AsyncClientConnectionOperator} instance.
+ *
+ * @param connectionOperator the custom connection operator.
+ * @return this instance.
+ * @since 5.6
+ */
+ @Experimental
+ public final PoolingAsyncClientConnectionManagerBuilder setConnectionOperator(
+ final AsyncClientConnectionOperator connectionOperator) {
+ this.connectionOperator = connectionOperator;
+ return this;
+ }
+
@Internal
protected AsyncClientConnectionOperator createConnectionOperator(
final TlsStrategy tlsStrategy,
final SchemePortResolver schemePortResolver,
- final DnsResolver dnsResolver) {
+ final DnsResolver dnsResolver,
+ final ConnectionConfig defaultConnectionConfig) {
return new DefaultAsyncClientConnectionOperator(
RegistryBuilder.create()
.register(URIScheme.HTTPS.getId(), tlsStrategy)
.build(),
schemePortResolver,
- dnsResolver);
+ dnsResolver,
+ defaultConnectionConfig);
}
public PoolingAsyncClientConnectionManager build() {
@@ -290,8 +311,11 @@ public PoolingAsyncClientConnectionManager build() {
tlsStrategyCopy = DefaultClientTlsStrategy.createDefault();
}
}
+ final AsyncClientConnectionOperator operator = connectionOperator != null
+ ? connectionOperator
+ : createConnectionOperator(tlsStrategyCopy, schemePortResolver, dnsResolver, defaultConnectionConfig);
final PoolingAsyncClientConnectionManager poolingmgr = new PoolingAsyncClientConnectionManager(
- createConnectionOperator(tlsStrategyCopy, schemePortResolver, dnsResolver),
+ operator,
poolConcurrencyPolicy,
poolReusePolicy,
null,
@@ -308,4 +332,4 @@ public PoolingAsyncClientConnectionManager build() {
return poolingmgr;
}
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java
new file mode 100644
index 0000000000..710bf5fde7
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/AddressSelectingDnsResolverTest.java
@@ -0,0 +1,550 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.hc.client5.http.config.ProtocolFamilyPreference;
+import org.apache.hc.client5.http.impl.InMemoryDnsResolver;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AddressSelectingDnsResolverTest {
+
+ private static final AddressSelectingDnsResolver.SourceAddressResolver NO_SOURCE_ADDR =
+ (final InetSocketAddress dest) -> null;
+
+ private InMemoryDnsResolver delegate;
+
+ @BeforeEach
+ void setUp() {
+ delegate = new InMemoryDnsResolver();
+ }
+
+ @Test
+ void ipv4Only_filtersOutIPv6() throws Exception {
+ final InetAddress v4 = inet("203.0.113.10"); // TEST-NET-3
+ final InetAddress v6 = inet("2001:db8::10"); // documentation prefix
+
+ delegate.add("dual.example", v6, v4);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR);
+
+ final InetAddress[] ordered = r.resolve("dual.example");
+ assertEquals(1, ordered.length);
+ assertInstanceOf(Inet4Address.class, ordered[0]);
+ assertEquals(v4, ordered[0]);
+ }
+
+ @Test
+ void ipv6Only_filtersOutIPv4() throws Exception {
+ final InetAddress v4 = inet("192.0.2.1"); // TEST-NET-1
+ final InetAddress v6 = inet("2001:db8::1");
+
+ delegate.add("dual.example", v4, v6);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV6_ONLY, NO_SOURCE_ADDR);
+
+ final InetAddress[] ordered = r.resolve("dual.example");
+ assertEquals(1, ordered.length);
+ assertInstanceOf(Inet6Address.class, ordered[0]);
+ assertEquals(v6, ordered[0]);
+ }
+
+ @Test
+ void ipv4Only_emptyWhenNoIPv4Candidates() throws Exception {
+ final InetAddress v6a = inet("2001:db8::1");
+ final InetAddress v6b = inet("2001:db8::2");
+
+ delegate.add("v6only.example", v6a, v6b);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR);
+
+ final InetAddress[] ordered = r.resolve("v6only.example");
+ assertNull(ordered);
+ }
+
+ @Test
+ void default_hasNoFamilyBias() throws Exception {
+ final InetAddress v6a = inet("2001:db8::1");
+ final InetAddress v6b = inet("2001:db8::2");
+ final InetAddress v4a = inet("192.0.2.1");
+ final InetAddress v4b = inet("203.0.113.10");
+
+ delegate.add("dual.example", v6a, v6b, v4a, v4b);
+
+ final AddressSelectingDnsResolver r1 =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR);
+ final AddressSelectingDnsResolver r2 =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR);
+
+ final InetAddress[] out1 = r1.resolve("dual.example");
+ final InetAddress[] out2 = r2.resolve("dual.example");
+
+ assertArrayEquals(out1, out2);
+ assertEquals(4, out1.length);
+ }
+
+ @Test
+ void interleave_alternatesFamilies_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception {
+ final InetAddress v6a = inet("2001:db8::1");
+ final InetAddress v6b = inet("2001:db8::2");
+ final InetAddress v4a = inet("192.0.2.1");
+ final InetAddress v4b = inet("203.0.113.10");
+
+ // With NO_SOURCE_ADDR, RFC sort becomes a stable no-op; deterministic interleave.
+ delegate.add("dual.example", v6a, v6b, v4a, v4b);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.INTERLEAVE, NO_SOURCE_ADDR);
+
+ final InetAddress[] out = r.resolve("dual.example");
+ assertEquals(Arrays.asList(v6a, v4a, v6b, v4b), Arrays.asList(out));
+ }
+
+ @Test
+ void preferIpv6_groupsAllV6First_preservingRelativeOrder_whenRfcSortIsNoop() throws Exception {
+ final InetAddress v4a = inet("192.0.2.1");
+ final InetAddress v6a = inet("2001:db8::1");
+ final InetAddress v4b = inet("203.0.113.10");
+ final InetAddress v6b = inet("2001:db8::2");
+
+ delegate.add("dual.example", v4a, v6a, v4b, v6b);
+
+ final AddressSelectingDnsResolver preferV6 =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.PREFER_IPV6, NO_SOURCE_ADDR);
+
+ final InetAddress[] out = preferV6.resolve("dual.example");
+ assertEquals(Arrays.asList(v6a, v6b, v4a, v4b), Arrays.asList(out));
+ assertInstanceOf(Inet6Address.class, out[0]);
+ }
+
+ @Test
+ void filtersOutMulticastDestinations() throws Exception {
+ final InetAddress multicastV6 = inet("ff02::1");
+ final InetAddress v6 = inet("2001:db8::1");
+
+ delegate.add("mcast.example", multicastV6, v6);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR);
+
+ final InetAddress[] out = r.resolve("mcast.example");
+ assertEquals(1, out.length);
+ assertEquals(v6, out[0]);
+ }
+
+ // -------------------------------------------------------------------------
+ // Direct tests for classifyScope(..) and Scope.fromValue(..)
+ // -------------------------------------------------------------------------
+
+ @Test
+ void classifyScope_ipv6Loopback_isLinkLocal() throws Exception {
+ // ::1 maps to link-local scope; interface-local (0x1) is multicast-only (RFC 4291 §2.7).
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("::1")));
+ }
+
+ @Test
+ void classifyScope_ipv4Loopback_isLinkLocal() throws Exception {
+ // IPv4 127/8 maps to link-local per RFC 6724 §3.1.
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("127.0.0.1")));
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("127.255.255.255")));
+ }
+
+ @Test
+ void classifyScope_linkLocal() throws Exception {
+ // IPv4 169.254/16 → link-local.
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("169.254.0.1")));
+ // IPv6 fe80::/10 → link-local.
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("fe80::1")));
+ }
+
+ @Test
+ void classifyScope_ipv4Private_isGlobal() throws Exception {
+ // RFC 6724: all IPv4 except 127/8 and 169.254/16 are global,
+ // including RFC1918 (10/8, 172.16/12, 192.168/16) and 100.64/10.
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("10.0.0.1")));
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("172.16.0.1")));
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("192.168.1.1")));
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("100.64.0.1")));
+ }
+
+ @Test
+ void classifyScope_ipv6SiteLocal() throws Exception {
+ // IPv6 deprecated site-local (fec0::/10) still classified as site-local per RFC 6724.
+ assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("fec0::1")));
+ }
+
+ @Test
+ void classifyScope_global() throws Exception {
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("8.8.8.8")));
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("2003::1")));
+ }
+
+ @Test
+ void classifyScope_ipv6Multicast_usesLowNibbleScope() throws Exception {
+ // ff01::1 -> scope 0x1 -> INTERFACE_LOCAL
+ assertEquals(AddressSelectingDnsResolver.Scope.INTERFACE_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff01::1")));
+ // ff02::1 -> scope 0x2 -> LINK_LOCAL
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff02::1")));
+ // ff04::1 -> scope 0x4 -> ADMIN_LOCAL
+ assertEquals(AddressSelectingDnsResolver.Scope.ADMIN_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff04::1")));
+ // ff05::1 -> scope 0x5 -> SITE_LOCAL
+ assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff05::1")));
+ // ff08::1 -> scope 0x8 -> ORG_LOCAL
+ assertEquals(AddressSelectingDnsResolver.Scope.ORG_LOCAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff08::1")));
+ // ff0e::1 -> scope 0xe -> GLOBAL
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff0e::1")));
+ }
+
+ @Test
+ void scopeFromValue_mapsKnownConstants() {
+ assertEquals(AddressSelectingDnsResolver.Scope.INTERFACE_LOCAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0x1));
+ assertEquals(AddressSelectingDnsResolver.Scope.LINK_LOCAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0x2));
+ assertEquals(AddressSelectingDnsResolver.Scope.ADMIN_LOCAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0x4));
+ assertEquals(AddressSelectingDnsResolver.Scope.SITE_LOCAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0x5));
+ assertEquals(AddressSelectingDnsResolver.Scope.ORG_LOCAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0x8));
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.Scope.fromValue(0xe));
+ }
+
+ @Test
+ void scopeFromValue_throwsOnUnknownValue() {
+ assertThrows(IllegalArgumentException.class,
+ () -> AddressSelectingDnsResolver.Scope.fromValue(0x0));
+ assertThrows(IllegalArgumentException.class,
+ () -> AddressSelectingDnsResolver.Scope.fromValue(0x3));
+ assertThrows(IllegalArgumentException.class,
+ () -> AddressSelectingDnsResolver.Scope.fromValue(0xf));
+ }
+
+ @Test
+ void rfcRule2_prefersMatchingScope() throws Exception {
+ final InetAddress aDst = inet("2001:db8::1");
+ final InetAddress bDst = inet("2001:db8::2");
+
+ // A matches scope (GLOBAL == GLOBAL); B mismatches (GLOBAL != LINK_LOCAL)
+ final InetAddress aSrc = inet("2001:db8::abcd");
+ final InetAddress bSrc = inet("fe80::1");
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void rfcRule5_prefersMatchingLabel() throws Exception {
+ final InetAddress aDst = inet("2001:db8::1"); // label 5 (2001::/32)
+ final InetAddress bDst = inet("2001:db8::2"); // label 5
+
+ final InetAddress aSrc = inet("2001:db8::abcd"); // label 5 -> matches A
+ final InetAddress bSrc = inet("::ffff:192.0.2.1"); // label 4 -> does not match B
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void rfcRule6_prefersHigherPrecedence() throws Exception {
+ final InetAddress aDst = inet("::1"); // precedence 50 (policy ::1)
+ final InetAddress bDst = inet("2001:db8::1"); // precedence 5 (policy 2001::/32)
+
+ final InetAddress aSrc = inet("::1");
+ final InetAddress bSrc = inet("2001:db8::abcd");
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void rfcRule8_prefersSmallerScope_whenPrecedenceAndLabelTie() throws Exception {
+ // Both fall to ::/0 policy -> precedence 40, label 1, but different scopes.
+ final InetAddress aDst = inet("fe80::1"); // LINK_LOCAL scope (0x2)
+ final InetAddress bDst = inet("2003::1"); // GLOBAL scope (0xe)
+
+ final InetAddress aSrc = inet("fe80::2"); // LINK_LOCAL, label 1
+ final InetAddress bSrc = inet("2003::2"); // GLOBAL, label 1
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void rfcRule9_prefersLongestMatchingPrefix_ipv6Only() throws Exception {
+ // Both in same policy (::/0 -> prec 40, label 1), same scope (GLOBAL).
+ // A's source shares a longer prefix with its destination than B's.
+ final InetAddress aDst = inet("2003::1");
+ final InetAddress bDst = inet("2003::1:0:0:1");
+
+ // aSrc shares a full /64 with aDst; bSrc only shares /48.
+ final InetAddress aSrc = inet("2003::2");
+ final InetAddress bSrc = inet("2003::2:0:0:1");
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ // A has longer common prefix (src=2003::2, dst=2003::1 share 127 bits)
+ // B has shorter common prefix (src=2003::2:0:0:1, dst=2003::1:0:0:1 share 79 bits)
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void rfcRule9_appliesToIpv4Pairs() throws Exception {
+ // Both IPv4, same policy (::ffff:0:0/96 → prec 35, label 4), same scope (GLOBAL).
+ // Rule 9 should prefer the address whose source shares a longer prefix.
+ final InetAddress aDst = inet("192.0.2.1");
+ final InetAddress bDst = inet("203.0.113.1");
+
+ // aSrc shares 24 bits with aDst (192.0.2.x); bSrc shares fewer bits with bDst.
+ final InetAddress aSrc = inet("192.0.2.100");
+ final InetAddress bSrc = inet("203.0.114.1");
+
+ delegate.add("t.example", bDst, aDst);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, sourceMap(aDst, aSrc, bDst, bSrc));
+
+ final InetAddress[] out = r.resolve("t.example");
+ // aSrc-aDst share more prefix bits than bSrc-bDst, so aDst should come first.
+ assertEquals(Arrays.asList(aDst, bDst), Arrays.asList(out));
+ }
+
+ @Test
+ void ipv4Only_filtersSingleV6Address() throws Exception {
+ // Regression: a single IPv6 address must still be filtered when IPV4_ONLY is set.
+ final InetAddress v6 = inet("2001:db8::1");
+ delegate.add("v6.example", v6);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR);
+
+ assertNull(r.resolve("v6.example"));
+ }
+
+ @Test
+ void classifyScope_unknownMulticastNibble_fallsBackToGlobal() throws Exception {
+ // ff03::1 -> scope nibble 0x3, which is not a known Scope constant.
+ assertEquals(AddressSelectingDnsResolver.Scope.GLOBAL,
+ AddressSelectingDnsResolver.classifyScope(inet("ff03::1")));
+ }
+
+ @Test
+ void addr_fmt_simpleName() throws Exception {
+ assertEquals("null", AddressSelectingDnsResolver.addr(null));
+
+ final InetAddress v4 = inet("192.0.2.1");
+ final InetAddress v6 = inet("2001:db8::1");
+
+ assertEquals("IPv4(" + v4.getHostAddress() + ")", AddressSelectingDnsResolver.addr(v4));
+ assertEquals("IPv6(" + v6.getHostAddress() + ")", AddressSelectingDnsResolver.addr(v6));
+
+ assertEquals(Arrays.asList("IPv6(" + v6.getHostAddress() + ")", "IPv4(" + v4.getHostAddress() + ")"),
+ AddressSelectingDnsResolver.fmt(new InetAddress[]{v6, v4}));
+
+ assertEquals(Arrays.asList("IPv4(" + v4.getHostAddress() + ")", "IPv6(" + v6.getHostAddress() + ")"),
+ AddressSelectingDnsResolver.fmt(Arrays.asList(v4, v6)));
+
+ }
+
+ private static InetAddress inet(final String s) {
+ try {
+ return InetAddress.getByName(s);
+ } catch (final UnknownHostException ex) {
+ throw new AssertionError(ex);
+ }
+ }
+
+ private static AddressSelectingDnsResolver.SourceAddressResolver sourceMap(
+ final InetAddress aDst, final InetAddress aSrc,
+ final InetAddress bDst, final InetAddress bSrc) {
+ return (final InetSocketAddress dest) -> {
+ final InetAddress d = dest.getAddress();
+ if (aDst.equals(d)) {
+ return aSrc;
+ }
+ if (bDst.equals(d)) {
+ return bSrc;
+ }
+ return null;
+ };
+ }
+
+
+ @Test
+ void resolveHostPort_emptyAfterFilter_throwsUnknownHost() throws Exception {
+ final InetAddress v6a = inet("2001:db8::1");
+ final InetAddress v6b = inet("2001:db8::2");
+
+ delegate.add("v6only.example", v6a, v6b);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR);
+
+ // resolve(host) returns null when filter removes everything
+ assertNull(r.resolve("v6only.example"));
+
+ // resolve(host, port) must throw instead of returning an unresolved InetSocketAddress
+ assertThrows(UnknownHostException.class, () -> r.resolve("v6only.example", 443));
+ }
+
+ @Test
+ void resolveHostPort_returnsFilteredAddresses() throws Exception {
+ final InetAddress v4 = inet("203.0.113.10");
+ final InetAddress v6 = inet("2001:db8::10");
+
+ delegate.add("dual.example", v6, v4);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.IPV4_ONLY, NO_SOURCE_ADDR);
+
+ final List result = r.resolve("dual.example", 8080);
+ assertEquals(1, result.size());
+ assertEquals(new InetSocketAddress(v4, 8080), result.get(0));
+ }
+
+ @Test
+ void singleUnusableAddress_isFiltered() throws Exception {
+ final InetAddress wildcard = inet("0.0.0.0");
+
+ delegate.add("bad.example", wildcard);
+
+ final AddressSelectingDnsResolver r =
+ new AddressSelectingDnsResolver(delegate, ProtocolFamilyPreference.DEFAULT, NO_SOURCE_ADDR);
+
+ // Single unusable address should be filtered out, not passed through
+ assertNull(r.resolve("bad.example"));
+ }
+
+ @Test
+ void networkContains_ipv6Prefix32() throws Exception {
+ final AddressSelectingDnsResolver.Network p32 =
+ new AddressSelectingDnsResolver.Network(inet("2001:db8::").getAddress(), 32);
+
+ assertTrue(p32.contains(inet("2001:db8::1")));
+ assertTrue(p32.contains(inet("2001:db8:ffff::1")));
+
+ assertFalse(p32.contains(inet("2001:db9::1")));
+ assertFalse(p32.contains(inet("2000:db8::1")));
+ }
+
+ @Test
+ void networkContains_ipv4IsMatchedViaV4MappedWhenPrefixIsV6Mapped96() throws Exception {
+ // Build ::ffff:0:0 as raw 16 bytes. Do NOT use InetAddress.getByName(..) here:
+ // the JDK may normalize it to an Inet4Address, yielding a 4-byte array.
+ final byte[] v6mapped = new byte[16];
+ v6mapped[10] = (byte) 0xff;
+ v6mapped[11] = (byte) 0xff;
+
+ final AddressSelectingDnsResolver.Network p96 =
+ new AddressSelectingDnsResolver.Network(v6mapped, 96);
+
+ assertTrue(p96.contains(inet("192.0.2.1")));
+ assertTrue(p96.contains(inet("203.0.113.10")));
+
+ // A pure IPv6 address must not match that v4-mapped prefix.
+ assertFalse(p96.contains(inet("2001:db8::1")));
+ }
+
+ @Test
+ void networkContains_nonByteAlignedPrefix7Boundary() throws Exception {
+ // fc00::/7 (ULA) is in the policy table.
+ final AddressSelectingDnsResolver.Network p7 =
+ new AddressSelectingDnsResolver.Network(inet("fc00::").getAddress(), 7);
+
+ // Inside /7: fc00:: and fd00:: (since /7 covers fc00..fdff)
+ assertTrue(p7.contains(inet("fc00::1")));
+ assertTrue(p7.contains(inet("fd00::1")));
+
+ // Just outside /7: fe00:: (top bits 11111110 vs 1111110x)
+ assertFalse(p7.contains(inet("fe00::1")));
+ assertFalse(p7.contains(inet("2001:db8::1")));
+ }
+
+}
\ No newline at end of file
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java
new file mode 100644
index 0000000000..7d9c813254
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AddressSelectingDnsResolverExample.java
@@ -0,0 +1,79 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+package org.apache.hc.client5.http.examples;
+
+import java.net.InetAddress;
+
+import org.apache.hc.client5.http.DnsResolver;
+import org.apache.hc.client5.http.AddressSelectingDnsResolver;
+import org.apache.hc.client5.http.SystemDefaultDnsResolver;
+import org.apache.hc.client5.http.config.ProtocolFamilyPreference;
+
+public final class AddressSelectingDnsResolverExample {
+
+ public static void main(final String[] args) throws Exception {
+ final String host = args.length > 0 ? args[0] : "localhost";
+ final ProtocolFamilyPreference pref = args.length > 1
+ ? ProtocolFamilyPreference.valueOf(args[1])
+ : ProtocolFamilyPreference.DEFAULT;
+
+ System.out.println("Host: " + host);
+ System.out.println("Preference: " + pref);
+ System.out.println();
+
+ // Before: raw system resolver output (no destination address ordering).
+ final InetAddress[] raw = SystemDefaultDnsResolver.INSTANCE.resolve(host);
+ System.out.println("Before (system resolver):");
+ printAddresses(raw);
+
+ // After: destination address ordered + family preference applied.
+ final DnsResolver resolver = new AddressSelectingDnsResolver(SystemDefaultDnsResolver.INSTANCE, pref);
+ final InetAddress[] out = resolver.resolve(host);
+ System.out.println("After (AddressSelectingDnsResolver, " + pref + "):");
+ printAddresses(out);
+ }
+
+ private static void printAddresses(final InetAddress[] addresses) {
+ if (addresses == null) {
+ System.out.println(" null");
+ return;
+ }
+ if (addresses.length == 0) {
+ System.out.println(" []");
+ return;
+ }
+ for (final InetAddress a : addresses) {
+ final String family = a instanceof java.net.Inet6Address ? "IPv6" : "IPv4";
+ System.out.println(" " + family + " " + a.getHostAddress());
+ }
+ System.out.println();
+ }
+
+ private AddressSelectingDnsResolverExample() {
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientHappyEyeballs.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientHappyEyeballs.java
new file mode 100644
index 0000000000..7f3328d68f
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientHappyEyeballs.java
@@ -0,0 +1,313 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+
+import org.apache.hc.client5.http.AddressSelectingDnsResolver;
+import org.apache.hc.client5.http.SystemDefaultDnsResolver;
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
+import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.ProtocolFamilyPreference;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.message.StatusLine;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Example: DNS destination address ordering + Happy Eyeballs-style connection racing
+ *
+ * This example shows how to:
+ *
+ * - Wrap the system DNS resolver with {@link AddressSelectingDnsResolver}
+ * to apply destination address selection (IPv6/IPv4 ordering).
+ * - Use {@link ConnectionConfig} to enable Happy Eyeballs-style concurrent
+ * connection attempts with pacing and a protocol family preference
+ * (e.g., {@code IPV4_ONLY}, {@code IPV6_ONLY}, {@code PREFER_IPV6},
+ * {@code PREFER_IPV4}, {@code INTERLEAVE}).
+ * - Control the connect timeout so demos don’t stall on slow/broken networks.
+ *
+ *
+ * Note: DNS resolution is synchronous (the JDK does not expose separate
+ * AAAA/A queries), so this implementation covers the connection-racing aspects of
+ * Happy Eyeballs but not the asynchronous dual-query DNS behavior.
+ *
+ * How to run with the example runner
+ *
+ * # Default (no args): hits ... and ...
+ * ./run-example.sh AsyncClientHappyEyeballs
+ *
+ * # Pass one URI (runner supports command-line args)
+ * ./run-example.sh AsyncClientHappyEyeballs http://neverssl.com/
+ *
+ * # Pass multiple URIs
+ * ./run-example.sh AsyncClientHappyEyeballs http://neverssl.com/ https://example.org/
+ *
+ * # Optional system properties (the runner forwards -D...):
+ * # -Dhc.he.pref=INTERLEAVE|PREFER_IPV4|PREFER_IPV6|IPV4_ONLY|IPV6_ONLY (default: INTERLEAVE)
+ * # -Dhc.he.delay.ms=250 (Happy Eyeballs attempt spacing; default 250)
+ * # -Dhc.he.other.ms=50 (first other-family offset; default 50; clamped ≤ attempt delay)
+ * # -Dhc.connect.ms=10000 (TCP connect timeout; default 10000)
+ *
+ * ./run-example.sh AsyncClientHappyEyeballs http://neverssl.com/ \
+ * -Dhc.he.pref=INTERLEAVE -Dhc.he.delay.ms=250 -Dhc.he.other.ms=50 -Dhc.connect.ms=8000
+ *
+ *
+ * What to expect
+ *
+ * - For dual-stack hosts, the client schedules interleaved IPv6/IPv4 connects per the preference and delays.
+ * - On networks without working IPv6, the IPv6 attempt will likely fail quickly while IPv4 succeeds.
+ * - If you force {@code IPV6_ONLY} on a network without IPv6 routing, you’ll get
+ * {@code java.net.SocketException: Network is unreachable} — that’s expected.
+ *
+ *
+ * Tip
+ * For the clearest behavior, align the resolver bias and the connection preference:
+ * construct the resolver with the same {@link ProtocolFamilyPreference} that you set in
+ * {@link ConnectionConfig}.
+ */
+public final class AsyncClientHappyEyeballs {
+
+ private AsyncClientHappyEyeballs() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ // --- Read settings from system properties (with sensible defaults) ---
+ final ProtocolFamilyPreference pref = parsePref(System.getProperty("hc.he.pref"), ProtocolFamilyPreference.INTERLEAVE);
+ final long attemptDelayMs = parseLong(System.getProperty("hc.he.delay.ms"), 250L);
+ final long otherFamilyDelayMs = Math.min(parseLong(System.getProperty("hc.he.other.ms"), 50L), attemptDelayMs);
+ final long connectMs = parseLong(System.getProperty("hc.connect.ms"), 10000L); // 10s default
+
+ // --- Resolve targets from CLI args (or fall back to ipv6-test.com pair) ---
+ final List targets = new ArrayList<>();
+ if (args != null && args.length > 0) {
+ for (int i = 0; i < args.length; i++) {
+ final URI u = safeParse(args[i]);
+ if (u != null) {
+ targets.add(u);
+ } else {
+ System.out.println("Skipping invalid URI: " + args[i]);
+ }
+ }
+ } else {
+ try {
+ targets.add(new URI("http://ipv6-test.com/"));
+ targets.add(new URI("https://ipv6-test.com/"));
+ } catch (final URISyntaxException ignore) {
+ }
+ }
+
+ // --- Print banner so the runner shows the configuration up front ---
+ System.out.println("Happy Eyeballs: pref=" + pref
+ + ", attemptDelay=" + attemptDelayMs + "ms"
+ + ", otherFamilyDelay=" + otherFamilyDelayMs + "ms"
+ + ", connectTimeout=" + connectMs + "ms");
+
+ // --- DNS resolver with destination address selection (biased using the same pref for clarity) ---
+ final AddressSelectingDnsResolver dnsResolver =
+ new AddressSelectingDnsResolver(SystemDefaultDnsResolver.INSTANCE, pref);
+
+ // --- Connection config enabling Happy Eyeballs-style pacing and family preference ---
+ final ConnectionConfig connectionConfig = ConnectionConfig.custom()
+ .setHappyEyeballsEnabled(true)
+ .setHappyEyeballsAttemptDelay(TimeValue.ofMilliseconds(attemptDelayMs))
+ .setHappyEyeballsOtherFamilyDelay(TimeValue.ofMilliseconds(otherFamilyDelayMs))
+ .setProtocolFamilyPreference(pref).setConnectTimeout(Timeout.ofMilliseconds(connectMs))
+
+ .build();
+
+ final RequestConfig requestConfig = RequestConfig.custom()
+ .build();
+
+ // --- TLS strategy ---
+ final TlsStrategy tls = ClientTlsStrategyBuilder.create()
+ .buildAsync();
+
+ // --- Connection manager wires in DNS + ConnectionConfig + TLS ---
+ final PoolingAsyncClientConnectionManager cm =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .setDnsResolver(dnsResolver)
+ .setDefaultConnectionConfig(connectionConfig)
+ .setTlsStrategy(tls)
+ .build();
+
+ final CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setConnectionManager(cm)
+ .setDefaultRequestConfig(requestConfig)
+ .build();
+
+ client.start();
+
+ // --- Execute each target once ---
+ for (int i = 0; i < targets.size(); i++) {
+ final URI uri = targets.get(i);
+ final HttpHost host = new HttpHost(
+ uri.getScheme(),
+ uri.getHost(),
+ computePort(uri)
+ );
+ final String path = buildPathAndQuery(uri);
+
+ final SimpleHttpRequest request = SimpleRequestBuilder.get()
+ .setHttpHost(host)
+ .setPath(path)
+ .build();
+
+ System.out.println("Executing request " + request);
+ final Future future = client.execute(
+ SimpleRequestProducer.create(request),
+ SimpleResponseConsumer.create(),
+ new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse response) {
+ System.out.println(request + " -> " + new StatusLine(response));
+ System.out.println(response.getBody());
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ System.out.println(request + " -> " + ex);
+ }
+
+ @Override
+ public void cancelled() {
+ System.out.println(request + " cancelled");
+ }
+ });
+
+ try {
+ future.get();
+ } catch (final java.util.concurrent.ExecutionException ex) {
+ // Show the root cause without a giant stack trace in the example
+ System.out.println(request + " -> " + ex.getCause());
+ }
+ }
+
+ System.out.println("Shutting down");
+ client.close(CloseMode.GRACEFUL);
+ cm.close(CloseMode.GRACEFUL);
+ }
+
+ // ------------ helpers (Java 8 friendly) ------------
+
+ private static int computePort(final URI uri) {
+ final int p = uri.getPort();
+ if (p >= 0) {
+ return p;
+ }
+ final String scheme = uri.getScheme();
+ if ("http".equalsIgnoreCase(scheme)) {
+ return 80;
+ }
+ if ("https".equalsIgnoreCase(scheme)) {
+ return 443;
+ }
+ return -1;
+ }
+
+ private static String buildPathAndQuery(final URI uri) {
+ String path = uri.getRawPath();
+ if (path == null || path.isEmpty()) {
+ path = "/";
+ }
+ final String query = uri.getRawQuery();
+ if (query != null && !query.isEmpty()) {
+ return path + "?" + query;
+ }
+ return path;
+ }
+
+ private static long parseLong(final String s, final long defVal) {
+ if (s == null) {
+ return defVal;
+ }
+ try {
+ return Long.parseLong(s.trim());
+ } catch (final NumberFormatException ignore) {
+ return defVal;
+ }
+ }
+
+ private static ProtocolFamilyPreference parsePref(final String s, final ProtocolFamilyPreference defVal) {
+ if (s == null) {
+ return defVal;
+ }
+ final String u = s.trim().toUpperCase(java.util.Locale.ROOT);
+ if ("IPV6_ONLY".equals(u)) {
+ return ProtocolFamilyPreference.IPV6_ONLY;
+ }
+ if ("IPV4_ONLY".equals(u)) {
+ return ProtocolFamilyPreference.IPV4_ONLY;
+ }
+ if ("PREFER_IPV6".equals(u)) {
+ return ProtocolFamilyPreference.PREFER_IPV6;
+ }
+ if ("PREFER_IPV4".equals(u)) {
+ return ProtocolFamilyPreference.PREFER_IPV4;
+ }
+ if ("INTERLEAVE".equals(u)) {
+ return ProtocolFamilyPreference.INTERLEAVE;
+ }
+ return defVal;
+ }
+
+ private static URI safeParse(final String s) {
+ try {
+ final URI u = new URI(s);
+ final String scheme = u.getScheme();
+ if (!URIScheme.HTTP.same(scheme) && !URIScheme.HTTPS.same(scheme)) {
+ System.out.println("Unsupported scheme (only http/https): " + s);
+ return null;
+ }
+ if (u.getHost() == null) {
+ System.out.println("Missing host in URI: " + s);
+ return null;
+ }
+ return u;
+ } catch (final URISyntaxException ex) {
+ return null;
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequesterTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequesterTest.java
index e2ec5696b8..3faad02c2c 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequesterTest.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/MultihomeIOSessionRequesterTest.java
@@ -24,15 +24,18 @@
* .
*
*/
-
package org.apache.hc.client5.http.impl.nio;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.InetAddress;
@@ -41,14 +44,21 @@
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import org.apache.hc.client5.http.DnsResolver;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.ProtocolFamilyPreference;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.net.NamedEndpoint;
import org.apache.hc.core5.reactor.ConnectionInitiator;
import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -57,19 +67,38 @@ class MultihomeIOSessionRequesterTest {
private DnsResolver dnsResolver;
private ConnectionInitiator connectionInitiator;
- private MultihomeIOSessionRequester sessionRequester;
private NamedEndpoint namedEndpoint;
+ // Shared scheduler to make mock timings deterministic across platforms/CI
+ private ScheduledExecutorService testScheduler;
+
@BeforeEach
void setUp() {
dnsResolver = Mockito.mock(DnsResolver.class);
connectionInitiator = Mockito.mock(ConnectionInitiator.class);
namedEndpoint = Mockito.mock(NamedEndpoint.class);
- sessionRequester = new MultihomeIOSessionRequester(dnsResolver);
+
+ testScheduler = Executors.newScheduledThreadPool(2, r -> {
+ final Thread t = new Thread(r, "mh-test-scheduler");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ @AfterEach
+ void shutdownScheduler() {
+ if (testScheduler != null) {
+ testScheduler.shutdownNow();
+ }
}
@Test
- void testConnectWithMultipleAddresses() throws Exception {
+ void testConnectWithMultipleAddresses_allFail_surfaceLastFailure() throws Exception {
+ final MultihomeIOSessionRequester sessionRequester =
+ new MultihomeIOSessionRequester(dnsResolver, ConnectionConfig.custom()
+ .setHappyEyeballsEnabled(false)
+ .build());
+
final InetAddress address1 = InetAddress.getByAddress(new byte[]{10, 0, 0, 1});
final InetAddress address2 = InetAddress.getByAddress(new byte[]{10, 0, 0, 2});
final List remoteAddresses = Arrays.asList(
@@ -77,41 +106,37 @@ void testConnectWithMultipleAddresses() throws Exception {
new InetSocketAddress(address2, 8080)
);
- Mockito.when(namedEndpoint.getHostName()).thenReturn("somehost");
- Mockito.when(namedEndpoint.getPort()).thenReturn(8080);
- Mockito.when(dnsResolver.resolve("somehost", 8080)).thenReturn(remoteAddresses);
+ when(namedEndpoint.getHostName()).thenReturn("somehost");
+ when(namedEndpoint.getPort()).thenReturn(8080);
+ when(dnsResolver.resolve("somehost", 8080)).thenReturn(remoteAddresses);
- Mockito.when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
+ when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> {
- final FutureCallback callback = invocation.getArgument(5);
- // Simulate a failure for the first connection attempt
- final CompletableFuture future = new CompletableFuture<>();
- callback.failed(new IOException("Simulated connection failure"));
- future.completeExceptionally(new IOException("Simulated connection failure"));
- return future;
+ final FutureCallback cb = invocation.getArgument(5);
+ final CompletableFuture f = new CompletableFuture<>();
+ final IOException io = new IOException("Simulated connection failure");
+ cb.failed(io);
+ f.completeExceptionally(io);
+ return f;
});
final Future future = sessionRequester.connect(
- connectionInitiator,
- namedEndpoint,
- null,
- Timeout.ofMilliseconds(500),
- null,
- null
+ connectionInitiator, namedEndpoint, null, Timeout.ofMilliseconds(500), null, null
);
assertTrue(future.isDone());
- try {
- future.get();
- fail("Expected ExecutionException");
- } catch (final ExecutionException ex) {
- assertInstanceOf(IOException.class, ex.getCause());
- assertEquals("Simulated connection failure", ex.getCause().getMessage());
- }
+ final ExecutionException ex = assertThrows(ExecutionException.class, future::get);
+ assertInstanceOf(IOException.class, ex.getCause());
+ assertEquals("Simulated connection failure", ex.getCause().getMessage());
}
@Test
void testConnectSuccessfulAfterRetries() throws Exception {
+ final MultihomeIOSessionRequester sessionRequester =
+ new MultihomeIOSessionRequester(dnsResolver, ConnectionConfig.custom()
+ .setHappyEyeballsEnabled(false)
+ .build());
+
final InetAddress address1 = InetAddress.getByAddress(new byte[]{10, 0, 0, 1});
final InetAddress address2 = InetAddress.getByAddress(new byte[]{10, 0, 0, 2});
final List remoteAddresses = Arrays.asList(
@@ -119,43 +144,136 @@ void testConnectSuccessfulAfterRetries() throws Exception {
new InetSocketAddress(address2, 8080)
);
- Mockito.when(namedEndpoint.getHostName()).thenReturn("somehost");
- Mockito.when(namedEndpoint.getPort()).thenReturn(8080);
- Mockito.when(dnsResolver.resolve("somehost", 8080)).thenReturn(remoteAddresses);
+ when(namedEndpoint.getHostName()).thenReturn("somehost");
+ when(namedEndpoint.getPort()).thenReturn(8080);
+ when(dnsResolver.resolve("somehost", 8080)).thenReturn(remoteAddresses);
- Mockito.when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
+ when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> {
- final FutureCallback callback = invocation.getArgument(5);
+ final FutureCallback cb = invocation.getArgument(5);
final InetSocketAddress remoteAddress = invocation.getArgument(1);
- final CompletableFuture future = new CompletableFuture<>();
+ final CompletableFuture f = new CompletableFuture<>();
if (remoteAddress.getAddress().equals(address1)) {
- // Fail the first address
- callback.failed(new IOException("Simulated connection failure"));
- future.completeExceptionally(new IOException("Simulated connection failure"));
+ final IOException io = new IOException("Simulated connection failure");
+ cb.failed(io);
+ f.completeExceptionally(io);
} else {
- // Succeed for the second address
- final IOSession mockSession = Mockito.mock(IOSession.class);
- callback.completed(mockSession);
- future.complete(mockSession);
+ final IOSession s = Mockito.mock(IOSession.class);
+ cb.completed(s);
+ f.complete(s);
}
- return future;
+ return f;
});
final Future future = sessionRequester.connect(
- connectionInitiator,
- namedEndpoint,
- null,
- Timeout.ofMilliseconds(500),
- null,
- null
+ connectionInitiator, namedEndpoint, null, Timeout.ofMilliseconds(500), null, null
);
assertTrue(future.isDone());
- try {
- final IOSession session = future.get();
- assertNotNull(session);
- } catch (final ExecutionException ex) {
- fail("Did not expect an ExecutionException", ex);
- }
+ assertNotNull(future.get());
+ }
+
+ @Test
+ void testHappyEyeballs_fastV4BeatsSlowerV6() throws Exception {
+ final MultihomeIOSessionRequester sessionRequester =
+ new MultihomeIOSessionRequester(dnsResolver, ConnectionConfig.custom()
+ .setHappyEyeballsEnabled(true)
+ .setHappyEyeballsAttemptDelay(TimeValue.ofMilliseconds(250))
+ .setHappyEyeballsOtherFamilyDelay(TimeValue.ofMilliseconds(50))
+ .setProtocolFamilyPreference(ProtocolFamilyPreference.INTERLEAVE)
+ .build());
+
+ final InetAddress v6 = InetAddress.getByName("2001:db8::10");
+ final InetAddress v4 = InetAddress.getByName("203.0.113.10");
+ final InetSocketAddress aV6 = new InetSocketAddress(v6, 8080);
+ final InetSocketAddress aV4 = new InetSocketAddress(v4, 8080);
+
+ when(namedEndpoint.getHostName()).thenReturn("dual");
+ when(namedEndpoint.getPort()).thenReturn(8080);
+ // v6 first from DNS so requester will start with v6 and schedule v4 shortly after
+ when(dnsResolver.resolve("dual", 8080)).thenReturn(Arrays.asList(aV6, aV4));
+
+ final IOSession v6Session = Mockito.mock(IOSession.class, "v6Session");
+ final IOSession v4Session = Mockito.mock(IOSession.class, "v4Session");
+
+ when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> {
+ final InetSocketAddress remote = invocation.getArgument(1);
+ final FutureCallback cb = invocation.getArgument(5);
+ final CompletableFuture f = new CompletableFuture<>();
+
+ // Large margin so v4 always wins even with CI jitter.
+ if (remote.equals(aV6)) {
+ testScheduler.schedule(() -> {
+ cb.completed(v6Session);
+ f.complete(v6Session);
+ }, 1200, TimeUnit.MILLISECONDS);
+ } else {
+ testScheduler.schedule(() -> {
+ cb.completed(v4Session);
+ f.complete(v4Session);
+ }, 60, TimeUnit.MILLISECONDS);
+ }
+ return f;
+ });
+
+ final Future future = sessionRequester.connect(
+ connectionInitiator, namedEndpoint, null, Timeout.ofSeconds(3), null, null);
+
+ final IOSession winner = future.get(3, TimeUnit.SECONDS);
+ assertSame(v4Session, winner, "IPv4 should win with faster completion");
+ verify(connectionInitiator, atLeast(2)).connect(any(), any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void testHappyEyeballs_v6Fails_v4Succeeds() throws Exception {
+ final MultihomeIOSessionRequester sessionRequester =
+ new MultihomeIOSessionRequester(dnsResolver, ConnectionConfig.custom()
+ .setHappyEyeballsEnabled(true)
+ .setHappyEyeballsAttemptDelay(TimeValue.ofMilliseconds(200))
+ .setHappyEyeballsOtherFamilyDelay(TimeValue.ofMilliseconds(50))
+ .setProtocolFamilyPreference(ProtocolFamilyPreference.INTERLEAVE)
+ .build());
+
+ final InetAddress v6 = InetAddress.getByName("2001:db8::10");
+ final InetAddress v4 = InetAddress.getByName("203.0.113.10");
+ final InetSocketAddress aV6 = new InetSocketAddress(v6, 8443);
+ final InetSocketAddress aV4 = new InetSocketAddress(v4, 8443);
+
+ when(namedEndpoint.getHostName()).thenReturn("dual");
+ when(namedEndpoint.getPort()).thenReturn(8443);
+ when(dnsResolver.resolve("dual", 8443)).thenReturn(Arrays.asList(aV6, aV4));
+
+ final IOSession v4Session = Mockito.mock(IOSession.class, "v4Session");
+
+ when(connectionInitiator.connect(any(), any(), any(), any(), any(), any()))
+ .thenAnswer(invocation -> {
+ final InetSocketAddress remote = invocation.getArgument(1);
+ final FutureCallback cb = invocation.getArgument(5);
+ final CompletableFuture f = new CompletableFuture<>();
+
+ if (remote.equals(aV6)) {
+ // Fail v6 quickly
+ testScheduler.schedule(() -> {
+ final IOException io = new IOException("v6 down");
+ cb.failed(io);
+ f.completeExceptionally(io);
+ }, 30, TimeUnit.MILLISECONDS);
+ } else {
+ // Succeed v4 after a short delay
+ testScheduler.schedule(() -> {
+ cb.completed(v4Session);
+ f.complete(v4Session);
+ }, 60, TimeUnit.MILLISECONDS);
+ }
+ return f;
+ });
+
+ final Future future = sessionRequester.connect(
+ connectionInitiator, namedEndpoint, null, Timeout.ofSeconds(2), null, null
+ );
+
+ final IOSession session = future.get(2, TimeUnit.SECONDS);
+ assertSame(v4Session, session);
}
}