diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperator.java new file mode 100644 index 000000000..f41dab82d --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperator.java @@ -0,0 +1,745 @@ +/* + * ==================================================================== + * 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.impl.nio; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.impl.ConnPoolSupport; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; +import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator; +import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.config.Lookup; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.ConnectionInitiator; +import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.ConnectException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The {@link AsyncClientConnectionOperator} implementation that uses Happy Eyeballs V2 algorithm to connect + * to the target server. Happy Eyeballs V2 (HEV2) algorithm is used to connect to the target server by concurrently + * attempting to establish multiple connections to different IP addresses. The first connection to complete + * successfully is selected and the others are closed. If all connections fail, the last error is rethrown. + * The algorithm also applies a configurable delay before subsequent connection attempts. HEV2 was introduced + * as a means to mitigate the latency issues caused by IPv4 and IPv6 co-existence in the Internet. HEV2 is defined + * in RFC 8305. + * + *

+ * This connection operator maintains a connection pool for each unique route (combination of target host and + * target port) and selects the next connection from the pool to establish a new connection or reuse an + * existing connection. The connection pool uses a First-In-First-Out (FIFO) queue and has a configurable limit + * on the maximum number of connections that can be kept alive in the pool. Once the maximum number of connections + * has been reached, the oldest connection in the pool is closed to make room for a new one. + *

+ * + *

+ * This class is thread-safe and can be used in a multi-threaded environment. + *

+ * + *

+ * The HEV2 algorithm is configurable through the following parameters: + *

+ *

+ * + * + *

+ * This class can be used with any {@link org.apache.hc.core5.http.nio.AsyncClientEndpoint} implementation + * that supports HTTP/1.1 or HTTP/2 protocols. + *

+ * + * @since 5.3 + */ +public class HappyEyeballsV2AsyncClientConnectionOperator implements AsyncClientConnectionOperator { + + private static final Logger LOG = LoggerFactory.getLogger(AsyncClientConnectionOperator.class); + + + /** + * The default delay used between subsequent DNS resolution attempts, in milliseconds. + */ + private final Timeout DEFAULT_RESOLUTION_DELAY = Timeout.ofMilliseconds(50); + /** + * The default timeout duration for establishing a connection, in milliseconds. + */ + private final Timeout DEFAULT_TIMEOUT = Timeout.ofMilliseconds(250); + + /** + * The default minimum delay between connection attempts. + * This delay is used to prevent the connection operator from spamming connection attempts and to provide a reasonable + * delay between attempts for the user. + */ + private final Timeout DEFAULT_MINIMUM_CONNECTION_ATTEMPT_DELAY = Timeout.ofMilliseconds(100); + + /** + * The default maximum delay between connection attempts. + * This delay is used to prevent the connection operator from spamming connection attempts and to provide a reasonable + * delay between attempts for the user. This value is used to cap the delay between attempts to prevent the delay from becoming + * too long and causing unnecessary delays in the application's processing. + */ + private final Timeout DEFAULT_MAXIMUM_CONNECTION_ATTEMPT_DELAY = Timeout.ofMilliseconds(2000); + + /** + * The default delay before attempting to establish a connection. + * This delay is used to provide a reasonable amount of time for the underlying transport to be ready before attempting + * to establish a connection. This can help to improve the likelihood of successful connection attempts and reduce + * unnecessary delays in the application's processing. + */ + private final Timeout DEFAULT_CONNECTION_ATTEMPT_DELAY = Timeout.ofMilliseconds(250); + + + /** + * The {@link ScheduledExecutorService} used by this connection operator to execute delayed tasks, such as DNS resolution and connection attempts. + * This executor is used to control the timing of tasks in order to optimize the performance of connection attempts. By default, a single thread is used + * to execute tasks sequentially, but this can be adjusted depending on the application's workload and number of instances of the connection operator. + * If multiple instances of the connection operator are being used in the same application, it may be more efficient to use a {@link java.util.concurrent.ThreadPoolExecutor} + * with a fixed number of threads instead of a single thread executor. This will allow tasks to be executed in parallel, which can improve the overall + * performance of the application. + * If the scheduler provided to the constructor is null, a new instance of {@link Executors#newSingleThreadScheduledExecutor()} will be used as the default. + */ + private final ScheduledExecutorService scheduler; + + /** + * The underlying {@link AsyncClientConnectionOperator} that is used to establish connections + * to the target server. + */ + private final AsyncClientConnectionOperator connectionOperator; + + /** + * The DNS resolver used to resolve hostnames to IP addresses. + */ + private final DnsResolver dnsResolver; + + /** + * A lookup table used to determine the {@link TlsStrategy} to use for a given connection route. + */ + private final Lookup tlsStrategyLookup; + + /** + * The default timeout for connection establishment attempts. If a connection cannot be established + * within this timeout, the attempt is considered failed. + */ + private final Timeout timeout; + + /** + * The minimum delay between connection establishment attempts. + */ + private final Timeout minimumConnectionAttemptDelay; + + /** + * The maximum delay between connection establishment attempts. + */ + private final Timeout maximumConnectionAttemptDelay; + + /** + * The current delay between connection establishment attempts. + */ + private final Timeout connectionAttemptDelay; + + /** + * The delay before resolution is started. + */ + private final Timeout resolution_delay; + + /** + * The number of IP addresses of each address family to include in the initial list of + * IP addresses to attempt connections to. This value is set to 2 by default, but can be + * increased to more aggressively favor a particular address family (e.g. set to 4 for IPv6). + */ + private final int firstAddressFamilyCount; + + /** + * The address family to use for establishing connections. This can be set to either + * {@link AddressFamily#IPv4} or {@link AddressFamily#IPv6}. + */ + private final AddressFamily addressFamily; + + /** + * An {@link AtomicInteger} that keeps track of the number of scheduled tasks in the {@link ScheduledExecutorService}. + */ + private final AtomicInteger scheduledTasks = new AtomicInteger(0); + + /** + * The AddressFamily enum represents the possible address families that can be used when attempting to establish + *

+ * connections using the Happy Eyeballs V2 algorithm. + * + *

+ * The Happy Eyeballs V2 algorithm allows for concurrent connection attempts to be made to different IP addresses, + *

+ * so this enum specifies whether connections should be attempted using IPv4 or IPv6 addresses. + * + *

+ */ + public enum AddressFamily { + IPv4, IPv6 + } + + /** + * Constructs a new {@link HappyEyeballsV2AsyncClientConnectionOperator} with the specified parameters. + * + * @param tlsStrategyLookup the lookup object used to retrieve a {@link TlsStrategy} for a given {@link Route} + * @param connectionOperator the underlying {@link AsyncClientConnectionOperator} to use for establishing connections + * @param dnsResolver the {@link DnsResolver} to use for resolving target hostnames + * @param timeout the timeout duration for establishing a connection + * @param resolution_delay the configurable delay before subsequent DNS resolution attempts + * @param minimumConnectionAttemptDelay the minimum configurable delay between connection attempts + * @param maximumConnectionAttemptDelay the maximum configurable delay between connection attempts + * @param connectionAttemptDelay the configurable delay before attempting to establish a connection + * @param firstAddressFamilyCount the number of initial address families to use for establishing a connection + * @param addressFamily the preferred address family to use for establishing a connection + * @param scheduler the {@link ScheduledExecutorService} to use for scheduling tasks + * @throws IllegalArgumentException if {@code firstAddressFamilyCount} is not positive + */ + public HappyEyeballsV2AsyncClientConnectionOperator(final Lookup tlsStrategyLookup, + final AsyncClientConnectionOperator connectionOperator, + final DnsResolver dnsResolver, + final Timeout timeout, + final Timeout resolution_delay, + final Timeout minimumConnectionAttemptDelay, + final Timeout maximumConnectionAttemptDelay, + final Timeout connectionAttemptDelay, + final int firstAddressFamilyCount, + final AddressFamily addressFamily, + final ScheduledExecutorService scheduler) { + this.tlsStrategyLookup = Args.notNull(tlsStrategyLookup, "TLS strategy lookup"); + this.connectionOperator = Args.notNull(connectionOperator, "Connection operator"); + this.dnsResolver = dnsResolver != null ? dnsResolver : SystemDefaultDnsResolver.INSTANCE; + this.timeout = timeout != null ? timeout : DEFAULT_TIMEOUT; + this.resolution_delay = resolution_delay != null ? resolution_delay : DEFAULT_RESOLUTION_DELAY; + this.minimumConnectionAttemptDelay = minimumConnectionAttemptDelay != null ? minimumConnectionAttemptDelay : DEFAULT_MINIMUM_CONNECTION_ATTEMPT_DELAY; + this.maximumConnectionAttemptDelay = maximumConnectionAttemptDelay != null ? maximumConnectionAttemptDelay : DEFAULT_MAXIMUM_CONNECTION_ATTEMPT_DELAY; + this.connectionAttemptDelay = connectionAttemptDelay != null ? connectionAttemptDelay : DEFAULT_CONNECTION_ATTEMPT_DELAY; + this.firstAddressFamilyCount = Args.positive(firstAddressFamilyCount, "firstAddressFamilyCount"); + this.addressFamily = addressFamily != null ? addressFamily : AddressFamily.IPv6; + this.scheduler = scheduler != null ? scheduler : Executors.newSingleThreadScheduledExecutor(); + + } + + /** + * Constructs a new instance of {@link HappyEyeballsV2AsyncClientConnectionOperator} using the specified + * {@link Lookup} for {@link TlsStrategy} and {@link SchemePortResolver} and {@link DnsResolver}. + *

+ * The constructor internally creates a new instance of {@link DefaultAsyncClientConnectionOperator} with the + * specified {@link Lookup} for {@link TlsStrategy}, {@link SchemePortResolver} and {@link DnsResolver}. The + * created {@link AsyncClientConnectionOperator} is then passed to the main constructor along with default values + * for other parameters. + *

+ * + * @param tlsStrategyLookup The {@link Lookup} for {@link TlsStrategy}. + * @param schemePortResolver The {@link SchemePortResolver} to use for resolving scheme ports. + * @param dnsResolver The {@link DnsResolver} to use for resolving hostnames to IP addresses. + * @throws IllegalArgumentException if the {@code tlsStrategyLookup} or {@code schemePortResolver} or {@code dnsResolver} parameter is {@code null}. + */ + public HappyEyeballsV2AsyncClientConnectionOperator( + final Lookup tlsStrategyLookup, + final SchemePortResolver schemePortResolver, + final DnsResolver dnsResolver) { + this(tlsStrategyLookup, + new DefaultAsyncClientConnectionOperator(tlsStrategyLookup, schemePortResolver, dnsResolver), + dnsResolver, + null, + null, + null, + null, + null, + 1, + null, + null); + } + + /** + * Creates a new instance of {@link HappyEyeballsV2AsyncClientConnectionOperator} using the provided TLS strategy lookup + * and scheme-port resolver. The DNS resolver will be set to the system default resolver. + * + * @param tlsStrategyLookup The lookup instance for {@link TlsStrategy} to be used for establishing connections. + * @param schemePortResolver The resolver instance for mapping scheme names to default port numbers. + * @throws IllegalArgumentException if {@code tlsStrategyLookup} is {@code null}. + */ + public HappyEyeballsV2AsyncClientConnectionOperator( + final Lookup tlsStrategyLookup, + final SchemePortResolver schemePortResolver) { + this(tlsStrategyLookup, schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE, null); + } + + /** + * Creates a new instance of {@link HappyEyeballsV2AsyncClientConnectionOperator} using the provided TLS strategy lookup. + * The scheme-port resolver and DNS resolver will be set to their default instances. + * + * @param tlsStrategyLookup The lookup instance for {@link TlsStrategy} to be used for establishing connections. + * @throws IllegalArgumentException if {@code tlsStrategyLookup} is {@code null}. + */ + public HappyEyeballsV2AsyncClientConnectionOperator( + final Lookup tlsStrategyLookup) { + this(tlsStrategyLookup, DefaultSchemePortResolver.INSTANCE, null); + } + + + /** + * Attempts to connect to the given host and returns a Future that will be completed when the connection is established + * or when an error occurs. This method may attempt to connect to multiple IP addresses associated with the host, + * depending on the address family and the number of connection attempts to execute. The address family and number of + * connection attempts can be configured by calling the corresponding setters on this class. + * + * @param connectionInitiator the connection initiator to use when creating the connection + * @param host the host to connect to + * @param localAddress the local address to bind to when connecting, or null to use any available local address + * @param connectTimeout the timeout to use when connecting, or null to use the default timeout + * @param attachment the attachment to associate with the connection, or null if no attachment is needed + * @param callback the callback to invoke when the connection is established or an error occurs, or null if no callback is needed + * @return a Future that will be completed when the connection is established or when an error occurs + */ + @Override + public Future connect( + final ConnectionInitiator connectionInitiator, + final HttpHost host, + final SocketAddress localAddress, + final Timeout connectTimeout, + final Object attachment, + final FutureCallback callback) { + + final CompletableFuture connectionFuture = new CompletableFuture<>(); + + final Timeout conTimeout = connectTimeout != null ? connectTimeout : timeout; + + resolveDnsAsync(host.getHostName()) + .thenCompose(inetAddresses -> { + final List ipv4Addresses = new ArrayList<>(); + final List ipv6Addresses = new ArrayList<>(); + + for (final InetAddress inetAddress : inetAddresses) { + if (inetAddress instanceof Inet4Address) { + ipv4Addresses.add(inetAddress); + } else if (inetAddress instanceof Inet6Address) { + ipv6Addresses.add(inetAddress); + } + } + + // Sort the array of addresses using the custom Comparator + Arrays.sort(inetAddresses, InetAddressComparator.INSTANCE); + + final List> connectionFutures = new ArrayList<>(); + + // Create a list of connection attempts to execute + final List> attempts = new ArrayList<>(); + + // Create a list of connection attempts to execute + if (addressFamily == AddressFamily.IPv4 && !ipv4Addresses.isEmpty()) { + for (int i = 0; i < firstAddressFamilyCount && i < ipv4Addresses.size(); i++) { + attempts.add(connectAttempt(connectionInitiator, host, conTimeout, attachment, + Collections.singletonList(ipv4Addresses.get(i)), localAddress)); + } + } else if (addressFamily == AddressFamily.IPv6 && !ipv6Addresses.isEmpty()) { + for (int i = 0; i < firstAddressFamilyCount && i < ipv6Addresses.size(); i++) { + attempts.add(connectAttempt(connectionInitiator, host, conTimeout, attachment, + Collections.singletonList(ipv6Addresses.get(i)), localAddress)); + } + } else { + if (!ipv4Addresses.isEmpty()) { + for (int i = 0; i < firstAddressFamilyCount && i < ipv4Addresses.size(); i++) { + attempts.add(connectAttempt(connectionInitiator, host, conTimeout, attachment, + Collections.singletonList(ipv4Addresses.get(i)), localAddress)); + } + } + if (!ipv6Addresses.isEmpty()) { + for (int i = 0; i < firstAddressFamilyCount && i < ipv6Addresses.size(); i++) { + attempts.add(connectAttempt(connectionInitiator, host, conTimeout, attachment, + Collections.singletonList(ipv6Addresses.get(i)), localAddress)); + } + } + } + + // Execute the connection attempts concurrently using CompletableFuture.anyOf + return CompletableFuture.anyOf(attempts.toArray(new CompletableFuture[0])) + .thenCompose(result -> { + if (result instanceof ManagedAsyncClientConnection) { + // If there is a result, cancel all other attempts and complete the connectionFuture + connectionFutures.forEach(future -> future.cancel(true)); + connectionFuture.complete((ManagedAsyncClientConnection) result); + // Check if all tasks have completed and shutdown the scheduler + } else { + // If there is an exception, complete the connectionFuture exceptionally with the exception + connectionFuture.completeExceptionally(new ConnectException("Failed to connect to any address for " + host)); + } + // Invoke the callback if provided + if (callback != null) { + connectionFuture.whenComplete((conn, ex) -> { + if (ex != null) { + callback.failed(new Exception(ex)); + } else { + callback.completed(conn); + } + }); + } + return connectionFuture; + }); + }) + .exceptionally(e -> { + connectionFuture.completeExceptionally(e); + if (callback != null) { + callback.failed(new Exception(e)); + } + return null; + + }).whenComplete((result, ex) -> shutdownSchedulerIfTasksCompleted()); + + return connectionFuture; + } + + /** + * Asynchronously resolves the DNS for the given host name and returns a CompletableFuture that will be completed + * with an array of InetAddress objects representing the IP addresses of the host. + * The resolution of AAAA records is delayed by the configured resolution delay to allow for a chance for A records to be + * returned first. + * + * @param host the host name to resolve DNS for + * @return a CompletableFuture that will be completed with an array of InetAddress objects representing the IP addresses + */ + private CompletableFuture resolveDnsAsync(final String host) { + final CompletableFuture dnsFuture = new CompletableFuture<>(); + final List addresses = new ArrayList<>(); + CompletableFuture.runAsync(() -> { + try { + final InetAddress[] inetAddresses = dnsResolver.resolve(host); + addresses.addAll(Arrays.asList(inetAddresses)); + // Introduce a delay before resolving AAAA records after receiving A records + if (inetAddresses.length > 0) { + scheduledTasks.incrementAndGet(); + scheduler.schedule(() -> { + try { + final InetAddress[] inet6Addresses = dnsResolver.resolve(host); + addresses.addAll(Arrays.asList(inet6Addresses)); + dnsFuture.complete(addresses.toArray(new InetAddress[0])); + } catch (final UnknownHostException e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Failed to resolve AAAA DNS for host '{}': {}", host, e.getMessage(), e); + } + dnsFuture.completeExceptionally(e); + } finally { + scheduledTasks.decrementAndGet(); // Decrease the count. If it reaches 0, the scheduler will be shutdown + } + }, resolution_delay.toMilliseconds(), TimeUnit.MILLISECONDS); + } else { + dnsFuture.complete(addresses.toArray(new InetAddress[0])); + } + } catch (final Exception e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Failed to resolve DNS for host '{}': {}", host, e.getMessage(), e); + } + dnsFuture.completeExceptionally(e); + } + }); + return dnsFuture; + } + + + /** + * Initiates an asynchronous connection attempt to the given list of IP addresses for the specified {@link HttpHost}. + * + * @param connectionInitiator the {@link ConnectionInitiator} to use for establishing the connection + * @param host the {@link HttpHost} to connect to + * @param connectTimeout the timeout for the connection attempt + * @param attachment the attachment object to pass to the connection operator + * @param addresses the list of IP addresses to attempt to connect to + * @param localAddress the local socket address to bind the connection to, or {@code null} if not binding + * @return a {@link CompletableFuture} that completes with a {@link ManagedAsyncClientConnection} if the connection attempt succeeds, + * or exceptionally with an exception if all attempts fail + */ + private CompletableFuture connectAttempt( + final ConnectionInitiator connectionInitiator, + final HttpHost host, + final Timeout connectTimeout, + final Object attachment, + final List addresses, + final SocketAddress localAddress) { + + final CompletableFuture connectionFuture = new CompletableFuture<>(); + + // Create a list of connection attempts to execute + final List> attempts = new ArrayList<>(); + for (int i = 0; i < addresses.size(); i++) { + final InetAddress address = addresses.get(i); + + if (LOG.isDebugEnabled()) { + LOG.info("Attempting to connect to {}", address); + } + + final CompletableFuture attempt = new CompletableFuture<>(); + attempts.add(attempt); + final HttpHost currentHost = new HttpHost(host.getSchemeName(), address, host.getHostName(), host.getPort()); + + connectionOperator.connect( + connectionInitiator, + currentHost, + localAddress, + connectTimeout, + attachment, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection connection) { + if (LOG.isDebugEnabled()) { + LOG.debug("Successfully connected {}", ConnPoolSupport.getId(connection)); + } + connectionFuture.complete(connection); + } + + @Override + public void failed(final Exception ex) { + if (LOG.isDebugEnabled()) { + LOG.debug("Failed to connect {}", ConnPoolSupport.getId(address), ex); + } + attempt.completeExceptionally(ex); + } + + @Override + public void cancelled() { + if (LOG.isDebugEnabled()) { + LOG.debug("Cancelled connect for {}", ConnPoolSupport.getId(address)); + } + attempt.cancel(true); + } + }); + + // Introduce a delay before executing the next connection attempt + if (i < addresses.size() - 1) { + final Duration delay = calculateDelay(i); + scheduledTasks.incrementAndGet(); + scheduler.schedule(() -> { + scheduledTasks.decrementAndGet(); + attempt.complete(null); + }, delay.toMillis(), TimeUnit.MILLISECONDS); + } + } + + // Execute the connection attempts concurrently using CompletableFuture.allOf + CompletableFuture.allOf(attempts.toArray(new CompletableFuture[0])) + .whenCompleteAsync((result, exception) -> { + if (!connectionFuture.isDone()) { + // If all attempts fail, complete the connectionFuture exceptionally with a ConnectException + connectionFuture.completeExceptionally(new ConnectException("Failed to connect to any address for " + host)); + } + }); + + return connectionFuture; + } + + /** + * Upgrades the specified connection to a higher-level protocol. This method delegates to + * {@link #upgrade(ManagedAsyncClientConnection, HttpHost, Object, HttpContext, FutureCallback)} passing null + * as the {@code protocolHandler}. + * + * @param connection the connection to upgrade + * @param host the host to connect to + * @param attachment the attachment object for the upgrade process + * @see #upgrade(ManagedAsyncClientConnection, HttpHost, Object, HttpContext) + */ + + @Override + public void upgrade( + final ManagedAsyncClientConnection connection, + final HttpHost host, + final Object attachment) { + upgrade(connection, host, attachment, null, null); + } + + /** + * Upgrades the specified connection to a higher-level protocol using the given {@code context}. This method delegates + * to {@link #upgrade(ManagedAsyncClientConnection, HttpHost, Object, HttpContext, FutureCallback)} passing null + * as the {@code protocolHandler}. + * + * @param connection the connection to upgrade + * @param host the host to connect to + * @param attachment the attachment object for the upgrade process + * @param context the HttpContext to use for the upgrade process + * @see #upgrade(ManagedAsyncClientConnection, HttpHost, Object) + * @see #upgrade(ManagedAsyncClientConnection, HttpHost, Object, HttpContext, FutureCallback) + */ + @Override + public void upgrade( + final ManagedAsyncClientConnection connection, + final HttpHost host, + final Object attachment, + final HttpContext context) { + upgrade(connection, host, attachment, context, null); + } + + /** + * Upgrades the given {@link ManagedAsyncClientConnection} to a secure connection using the appropriate + * {@link TlsStrategy} if available. If no {@link TlsStrategy} is available, the callback is called with the + * original connection. + * + * @param connection the connection to upgrade + * @param host the target host + * @param attachment the attachment object + * @param context the HttpContext, can be null + * @param callback the callback to call when the upgrade is complete or fails, can be null + */ + @Override + public void upgrade( + final ManagedAsyncClientConnection connection, + final HttpHost host, + final Object attachment, + final HttpContext context, + final FutureCallback callback) { + final TlsStrategy tlsStrategy = tlsStrategyLookup != null ? tlsStrategyLookup.lookup(host.getSchemeName()) : null; + if (tlsStrategy != null) { + tlsStrategy.upgrade( + connection, + host, + attachment, + null, + new FutureCallback() { + @Override + public void completed(final TransportSecurityLayer transportSecurityLayer) { + // If the upgrade succeeded, call the callback with the original connection + if (callback != null) { + callback.completed(connection); + } + } + + @Override + public void failed(final Exception ex) { + // If the upgrade failed, call the callback with the exception + if (callback != null) { + callback.failed(ex); + } + } + + @Override + public void cancelled() { + // If the upgrade was cancelled, call the callback with an exception indicating cancellation + if (callback != null) { + callback.failed(new CancellationException("Upgrade was cancelled")); + } + } + }); + } else { + // If no TLS strategy is available, call the callback with the original connection + if (callback != null) { + callback.completed(connection); + } + } + } + + /** + * Calculates the delay before the next connection attempt based on the attempt index and the configured connection + *

+ * attempt delay parameters. + * + * @param attemptIndex the index of the connection attempt, starting from 0 + * @return the duration to wait before the next connection attempt + */ + private Duration calculateDelay(final int attemptIndex) { + final Duration delay; + final Duration attemptDelay = connectionAttemptDelay.toDuration(); + final Duration maximumAttemptDelay = maximumConnectionAttemptDelay.toDuration(); + final Duration minimumAttemptDelay = minimumConnectionAttemptDelay.toDuration(); + + if (attemptIndex == 0) { + delay = attemptDelay; + } else { + delay = attemptDelay.multipliedBy(2).compareTo(maximumAttemptDelay) <= 0 ? + attemptDelay.multipliedBy(2) : maximumAttemptDelay; + } + return delay.compareTo(minimumAttemptDelay) >= 0 ? delay : minimumAttemptDelay; + } + + /** + * Initiates an orderly shutdown in which previously submitted tasks are executed, + * but no new tasks will be accepted. If the provided CloseMode is GRACEFUL, + * the scheduler will wait for currently executing tasks to complete before shutting down. + * Otherwise, it will attempt to cancel currently executing tasks. + * + * @param closeMode The mode to use for shutting down the scheduler. + */ + public void shutdown(final CloseMode closeMode) { + if (LOG.isDebugEnabled()) { + LOG.debug("Shutdown ScheduledExecutorService {}", closeMode); + } + if (closeMode == CloseMode.GRACEFUL) { + scheduler.shutdown(); + } else { + scheduler.shutdownNow(); + } + } + + /** + * Initiates an orderly shutdown in which previously submitted tasks are executed, + * but no new tasks will be accepted. The scheduler will wait for currently executing tasks + * to complete before shutting down. + */ + public void shutdown() { + shutdown(CloseMode.GRACEFUL); + } + + /** + * Decrements the number of scheduled tasks and shuts down the scheduler if there are no more tasks left to execute. + * This method is intended to be used when all tasks have been completed, and the scheduler should be shut down + * gracefully. It is executed asynchronously using {@link CompletableFuture#runAsync(Runnable)}, so it will not block + * the calling thread. + */ + private void shutdownSchedulerIfTasksCompleted() { + if (scheduledTasks.decrementAndGet() <= 0) { + this.shutdown(); + } + } +} + diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorBuilder.java new file mode 100644 index 000000000..c8c6f6d38 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorBuilder.java @@ -0,0 +1,277 @@ +/* + * ==================================================================== + * 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.impl.nio; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator; +import org.apache.hc.client5.http.ssl.ConscryptClientTlsStrategy; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.ReflectionUtils; +import org.apache.hc.core5.util.Timeout; + +import java.util.concurrent.ScheduledExecutorService; + +/** + * A builder for creating instances of {@link HappyEyeballsV2AsyncClientConnectionOperator}. + * + *

This builder provides a fluent API for configuring various options of the + * {@link HappyEyeballsV2AsyncClientConnectionOperator}. Once all the desired options have been set, + * the {@link #build()} method can be called to create an instance of the connection operator. + * + *

The following options can be configured using this builder: + *

    + *
  • The TLS strategy to be used for establishing TLS connections
  • + *
  • The connection operator to be used for creating connections
  • + *
  • The DNS resolver to be used for resolving hostnames
  • + *
  • The timeout for establishing a connection
  • + *
  • The delay between resolution of hostnames and connection establishment attempts
  • + *
  • The minimum and maximum delays between connection establishment attempts
  • + *
  • The delay between subsequent connection establishment attempts
  • + *
  • The number of connections to be established with the first address family in the list
  • + *
  • The preferred address family to be used for establishing connections
  • + *
+ * + *

If no options are explicitly set using this builder, default options will be used for each option. + * + *

This class is not thread-safe. + * + * @see HappyEyeballsV2AsyncClientConnectionOperator + * @since 5.3 + */ +public class HappyEyeballsV2AsyncClientConnectionOperatorBuilder { + + private AsyncClientConnectionOperator connectionOperator; + private DnsResolver dnsResolver; + private TlsStrategy tlsStrategy; + private Timeout timeout; + private Timeout minimumConnectionAttemptDelay; + private Timeout maximumConnectionAttemptDelay; + private Timeout connectionAttemptDelay; + private Timeout resolutionDelay; + private int firstAddressFamilyCount; + private HappyEyeballsV2AsyncClientConnectionOperator.AddressFamily addressFamily; + private ScheduledExecutorService scheduler; + + + public static HappyEyeballsV2AsyncClientConnectionOperatorBuilder create() { + return new HappyEyeballsV2AsyncClientConnectionOperatorBuilder(); + } + + + HappyEyeballsV2AsyncClientConnectionOperatorBuilder() { + super(); + } + + + private boolean systemProperties; + + /** + * Use system properties when creating and configuring default + * implementations. + */ + public final HappyEyeballsV2AsyncClientConnectionOperatorBuilder useSystemProperties() { + this.systemProperties = true; + return this; + } + + /** + * Sets the {@link AsyncClientConnectionOperator} to use for establishing connections. + * + * @param connectionOperator the {@link AsyncClientConnectionOperator} to use + * @return this {@link HappyEyeballsV2AsyncClientConnectionOperatorBuilder} instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withConnectionOperator( + final AsyncClientConnectionOperator connectionOperator) { + this.connectionOperator = connectionOperator; + return this; + } + + /** + * Sets the {@link DnsResolver} to use for resolving host names to IP addresses. + * + * @param dnsResolver the {@link DnsResolver} to use + * @return this builder instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withDnsResolver(final DnsResolver dnsResolver) { + this.dnsResolver = dnsResolver; + return this; + } + + /** + * Sets the {@link TlsStrategy} to use for creating TLS connections. + * + * @param tlsStrategy the {@link TlsStrategy} to use + * @return this {@link HappyEyeballsV2AsyncClientConnectionOperatorBuilder} instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withTlsStrategyLookup(final TlsStrategy tlsStrategy) { + this.tlsStrategy = tlsStrategy; + return this; + } + + /** + * Set the timeout to use for connection attempts. + * + * @param timeout the timeout to use for connection attempts + * @return this builder + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withTimeout(final Timeout timeout) { + this.timeout = timeout; + return this; + } + + /** + * Sets the minimum delay between connection attempts. The actual delay may be longer if a resolution delay has been + * specified, in which case the minimum connection attempt delay is added to the resolution delay. + * + * @param minimumConnectionAttemptDelay the minimum delay between connection attempts + * @return this builder instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withMinimumConnectionAttemptDelay( + final Timeout minimumConnectionAttemptDelay) { + this.minimumConnectionAttemptDelay = minimumConnectionAttemptDelay; + return this; + } + + /** + * Sets the maximum delay between two connection attempts. + * + * @param maximumConnectionAttemptDelay the maximum delay between two connection attempts + * @return the builder instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withMaximumConnectionAttemptDelay( + final Timeout maximumConnectionAttemptDelay) { + this.maximumConnectionAttemptDelay = maximumConnectionAttemptDelay; + return this; + } + + /** + * Sets the delay between two connection attempts. + * + * @param connectionAttemptDelay the delay between two connection attempts + * @return the builder instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withConnectionAttemptDelay( + final Timeout connectionAttemptDelay) { + this.connectionAttemptDelay = connectionAttemptDelay; + return this; + } + + /** + * Sets the delay before attempting to resolve the next address in the list. + * + * @param resolutionDelay the delay before attempting to resolve the next address in the list + * @return the builder instance + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withResolutionDelay(final Timeout resolutionDelay) { + this.resolutionDelay = resolutionDelay; + return this; + } + + /** + * Sets the number of first address families to try before falling back to the other address families. + * + * @param firstAddressFamilyCount the number of first address families to try + * @return this builder + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withFirstAddressFamilyCount( + final int firstAddressFamilyCount) { + this.firstAddressFamilyCount = firstAddressFamilyCount; + return this; + } + + /** + * Sets the preferred address family to use for connections. + * + * @param addressFamily the preferred address family + * @return this builder + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withAddressFamily(final HappyEyeballsV2AsyncClientConnectionOperator.AddressFamily addressFamily) { + this.addressFamily = addressFamily; + return this; + } + + /** + * Sets the {@link ScheduledExecutorService} for the {@link HappyEyeballsV2AsyncClientConnectionOperator}. + * + * @param scheduler The ScheduledExecutorService to set. + * @return this builder + */ + public HappyEyeballsV2AsyncClientConnectionOperatorBuilder withScheduler(final ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + return this; + } + + /** + * Builds a {@link HappyEyeballsV2AsyncClientConnectionOperator} with the specified parameters. + * + * @return the {@link HappyEyeballsV2AsyncClientConnectionOperator} instance built with the specified parameters. + * @throws IllegalArgumentException if the connection operator is null. + */ + public HappyEyeballsV2AsyncClientConnectionOperator build() { + final TlsStrategy tlsStrategyCopy; + if (tlsStrategy != null) { + tlsStrategyCopy = tlsStrategy; + } else { + if (ReflectionUtils.determineJRELevel() <= 8 && ConscryptClientTlsStrategy.isSupported()) { + if (systemProperties) { + tlsStrategyCopy = ConscryptClientTlsStrategy.getSystemDefault(); + } else { + tlsStrategyCopy = ConscryptClientTlsStrategy.getDefault(); + } + } else { + if (systemProperties) { + tlsStrategyCopy = DefaultClientTlsStrategy.getSystemDefault(); + } else { + tlsStrategyCopy = DefaultClientTlsStrategy.getDefault(); + } + } + } + + + connectionOperator = Args.notNull(connectionOperator, "Connection operator"); + + return new HappyEyeballsV2AsyncClientConnectionOperator( + RegistryBuilder.create() + .register(URIScheme.HTTPS.getId(), tlsStrategyCopy) + .build(), + connectionOperator, + dnsResolver, + timeout, + resolutionDelay, + minimumConnectionAttemptDelay, + maximumConnectionAttemptDelay, + connectionAttemptDelay, + firstAddressFamilyCount, + addressFamily, + scheduler + ); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/InetAddressComparator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/InetAddressComparator.java new file mode 100644 index 000000000..da6119247 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/InetAddressComparator.java @@ -0,0 +1,619 @@ +/* + * ==================================================================== + * 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.impl.nio; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; + +/** + * This class implements a comparator for {@link InetAddress} instances based on the Happy Eyeballs V2 algorithm. + *

+ * The comparator is used to sort a list of IP addresses based on their reachability and preference. + * + *

+ * The Happy Eyeballs algorithm is a mechanism for reducing connection latency when connecting to IPv6-capable + *

+ * servers over networks where both IPv6 and IPv4 are available. The algorithm attempts to establish connections + *

+ * using IPv6 and IPv4 in parallel, and selects the first connection to complete successfully. + * + *

+ * This comparator implements the Happy Eyeballs V2 rules defined in RFC 8305. The following rules are used for + *

+ * comparing two IP addresses: + * + *

    + *
  • Rule 1: Avoid unusable destinations.
  • + *
  • Rule 2: Prefer matching scope.
  • + *
  • Rule 3: Avoid deprecated addresses.
  • + *
  • Rule 4: Prefer higher precedence.
  • + *
  • Rule 5: Prefer matching label.
  • + *
  • Rule 6: Prefer smaller address.
  • + *
  • Rule 7: Prefer home network.
  • + *
  • Rule 8: Prefer public network.
  • + *
  • Rule 9: Prefer stable privacy addresses.
  • + *
  • Rule 10: Prefer temporary addresses.
  • + *
+ * + * @since 5.3 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +@Internal +class InetAddressComparator implements Comparator { + + /** + * Singleton instance of the comparator. + */ + public static final InetAddressComparator INSTANCE = new InetAddressComparator(); + + /** + * Compares two IP addresses based on the Happy Eyeballs algorithm rules. + *

+ * The method first orders the addresses based on their precedence, and then compares them based on other rules, + *

+ * including avoiding unusable destinations, preferring matching scope, preferring global scope, preferring + *

+ * IPv6 addresses, and preferring smaller address prefixes. + * + * @param addr1 the first address to be compared + * @param addr2 the second address to be compared + * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater + * than the second. + */ + @Override + public int compare(final InetAddress addr1, final InetAddress addr2) { + if (addr1 == null && addr2 == null) { + return 0; + } + if (addr1 == null) { + return -1; + } + if (addr2 == null) { + return 1; + } + + try { + // Get the list of network interfaces + final List networkInterfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + + + // Rule 1: Avoid unusable destinations. + final boolean add1IsReachable; + final boolean add2IsReachable; + + // Check if address 1 is reachable on any network interface + add1IsReachable = isAddressReachable(addr1, networkInterfaces); + + // Check if address 2 is reachable on any network interface + add2IsReachable = isAddressReachable(addr2, networkInterfaces); + + if (add1IsReachable && !add2IsReachable) { + return -1; + } else if (!add1IsReachable && add2IsReachable) { + return 1; + } + + } catch (final Exception e) { + return 0; + } + + // Rule 2: Prefer matching scope. + final InetAddress localhost; + final InetAddress sourceAddr; + try { + localhost = InetAddress.getLocalHost(); + sourceAddr = InetAddress.getByName(localhost.getHostAddress()); + } catch (final UnknownHostException e) { + return 0; + } + + final int scope1 = getScope(addr1); + final int scope2 = getScope(addr2); + final int scopeSource = getScope(sourceAddr); + + if (scope1 == scope2) { + return 0; + } else if (scope1 == scopeSource) { + return -1; + } else if (scope2 == scopeSource) { + return 1; + } + + //Rule 3: Avoid deprecated addresses. + final boolean add1IsDeprecated = isDeprecated(addr1); + final boolean add2IsDeprecated = isDeprecated(addr2); + + if (add1IsDeprecated && !add2IsDeprecated) { + return 1; + } else if (!add1IsDeprecated && add2IsDeprecated) { + return -1; + } + + + // Rule 4: Prefer home addresses. + final boolean isHomeAddr11 = isHomeAddress(addr1); + final boolean isCareOfAddr11 = isCareOfAddress(addr1); + final boolean isHomeAddr21 = isHomeAddress(addr2); + final boolean isCareOfAddr21 = isCareOfAddress(addr2); + + if (isHomeAddr11 && isCareOfAddr11 && !isHomeAddr21) { + return -1; + } else if (isHomeAddr21 && isCareOfAddr21 && !isHomeAddr11) { + return 1; + } + + //Rule 5: Prefer matching label. + final int label1 = getLabel(addr1); + final int label2 = getLabel(addr2); + + if (label1 < label2) { + return -1; + } else if (label1 > label2) { + return 1; + } + + // Rule 6: Prefer higher precedence. + final int add1Precedence = getPrecedence(addr1); + final int add2Precedence = getPrecedence(addr2); + + if (add1Precedence > add2Precedence) { + return -1; + } else if (add1Precedence < add2Precedence) { + return 1; + } + + // Rule 7: Prefer native transport. + final boolean addr1Encapsulated = isEncapsulated(addr1); + final boolean addr2Encapsulated = isEncapsulated(addr2); + + if (addr1Encapsulated && !addr2Encapsulated) { + return 1; + } else if (!addr1Encapsulated && addr2Encapsulated) { + return -1; + } + + + // Rule 8: Prefer smaller scope. + if (scope1 < scope2) { + return -1; + } else if (scope1 > scope2) { + return 1; + } + + // Rule 9: Use longest matching prefix. + final int prefixLen1 = commonPrefixLen(addr1, addr2); + final int prefixLen2 = commonPrefixLen(addr2, addr1); + + if (prefixLen1 > prefixLen2) { + return -1; + } else if (prefixLen1 < prefixLen2) { + return 1; + } + + // Rule 10: Otherwise, leave the order unchanged. + return 0; + } + + + private static int getScope(final InetAddress addr) { + if (isIpv6Address(addr)) { + if (addr.isMulticastAddress()) { + return getIpv6MulticastScope(addr); + } else if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) { + return 0x02; + } else if (addr.isSiteLocalAddress()) { + return 0x05; + } else { + return 0x0e; + } + } else if (isIpv4Address(addr)) { + if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) { + return 0x02; + } else { + return 0x0e; + } + } else { + return 0x01; + } + } + + + /** + * Checks whether the given IPv6 address is a deprecated address. + * + * @param addr the IPv6 address to check. + * @return {@code true} if the given address is deprecated, {@code false} otherwise. + */ + private boolean isDeprecated(final InetAddress addr) { + if (addr instanceof Inet4Address) { + return false; + } else if (addr instanceof Inet6Address) { + final Inet6Address ipv6Addr = (Inet6Address) addr; + final byte[] addressBytes = ipv6Addr.getAddress(); + + // Check if the IPv6 address is IPv4-mapped + if (addressBytes[0] == 0 && addressBytes[1] == 0 && addressBytes[2] == 0 && addressBytes[3] == 0 && + addressBytes[4] == 0 && addressBytes[5] == 0 && addressBytes[6] == 0 && addressBytes[7] == 0 && + addressBytes[8] == 0 && addressBytes[9] == 0 && addressBytes[10] == (byte) 0xFF && addressBytes[11] == (byte) 0xFF) { + return true; + } + + // Check if the IPv6 address is a link-local address + if ((addressBytes[0] & 0xFF) == 0xFE && (addressBytes[1] & 0xC0) == 0x80) { + return true; + } + } + return false; + } + + /** + * Determines the label to use for an IP address based on its type and properties. + * + * @param addr the IP address to determine the label for + * @return an integer value representing the label to use for the IP address + */ + private static int getLabel(final InetAddress addr) { + if (isIpv4Address(addr)) { + return 4; + } else if (isIpv6Address(addr)) { + if (addr.isLoopbackAddress()) { + return 0; + } else if (isIpv6Address6To4(addr)) { + return 2; + } else if (isIpv6AddressTeredo(addr)) { + return 5; + } else if (isIpv6AddressULA(addr)) { + return 13; + } else if (((Inet6Address) addr).isIPv4CompatibleAddress()) { + return 3; + } else if (addr.isSiteLocalAddress()) { + return 11; + } else if (isIpv6Address6Bone(addr)) { + return 12; + } else { + // All other IPv6 addresses, including global unicast addresses. + return 1; + } + } else { + // This should never happen. + return 1; + } + } + + /** + * Returns true if the given InetAddress is an IPv6 address. + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is an IPv6 address; false otherwise. + */ + private static boolean isIpv6Address(final InetAddress addr) { + return addr instanceof Inet6Address; + } + + /** + * Returns true if the given InetAddress is an IPv4 address. + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is an IPv4 address; false otherwise. + */ + private static boolean isIpv4Address(final InetAddress addr) { + return addr instanceof Inet4Address; + } + + /** + * Returns true if the given InetAddress is an IPv6 6to4 address. + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is an IPv6 6to4 address; false otherwise. + */ + private static boolean isIpv6Address6To4(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x20 && byteAddr[1] == 0x02; + } + + /** + * Returns true if the given InetAddress is an IPv6 Teredo address. + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is an IPv6 Teredo address; false otherwise. + */ + private static boolean isIpv6AddressTeredo(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x20 && byteAddr[1] == 0x01 && byteAddr[2] == 0x00 + && byteAddr[3] == 0x00; + } + + /** + * Returns true if the given InetAddress is an IPv6 Unique Local Address (ULA). + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is a ULA; false otherwise. + */ + private static boolean isIpv6AddressULA(final InetAddress addr) { + return isIpv6Address(addr) && (addr.getAddress()[0] & 0xfe) == 0xfc; + } + + /** + * Returns true if the given InetAddress is an IPv6 6bone address. + * + * @param addr The InetAddress to check. + * @return True if the given InetAddress is an IPv6 6bone address; false otherwise. + */ + private static boolean isIpv6Address6Bone(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x3f && byteAddr[1] == (byte) 0xfe; + } + + /** + * Returns the scope of the given IPv6 multicast address. + * + * @param addr The IPv6 multicast address. + * @return The scope of the given IPv6 multicast address. + */ + private static int getIpv6MulticastScope(final InetAddress addr) { + return !isIpv6Address(addr) ? 0 : (addr.getAddress()[1] & 0x0f); + } + + /** + * Determines the precedence of an IP address based on its type and scope. + * Precedence is used to determine which of two candidate IP addresses + * should be used as the destination address in an IP packet, according to + * the rules defined in RFC 6724. + * + * @param addr The IP address to determine the precedence of. + * @return The precedence of the IP address, as an integer. The possible + * values are: + *

    + *
  • 1 - multicast address
  • + *
  • 2 - IPv4 address
  • + *
  • 3 - global unicast address
  • + *
  • 4 - IPv4-mapped IPv6 address
  • + *
  • 5 - unique local address
  • + *
  • 6 - link-local address
  • + *
  • 7 - site-local address
  • + *
+ */ + private int getPrecedence(final InetAddress addr) { + if (addr instanceof Inet6Address) { + final byte[] addrBytes = addr.getAddress(); + if (addrBytes[0] == (byte) 0xFF) { + return 1; // multicast + } else if (isIPv4MappedIPv6Address(addrBytes)) { + return 4; // IPv4-mapped IPv6 address + } else if (isULA(addrBytes)) { + return 5; // unique local address + } else if (isLinkLocal(addrBytes)) { + return 6; // link-local address + } else if (isSiteLocal(addrBytes)) { + return 7; // site-local address + } else { + return 3; // global address + } + } else { + return 2; // IPv4 address + } + } + + /** + * Checks whether the given byte array represents an IPv4-mapped IPv6 address. + * + * @param addr the byte array to check + * @return true if the byte array represents an IPv4-mapped IPv6 address, false otherwise + */ + private static boolean isIPv4MappedIPv6Address(final byte[] addr) { + return addr.length == 16 && addr[0] == 0x00 && addr[1] == 0x00 && addr[2] == 0x00 + && addr[3] == 0x00 && addr[4] == 0x00 && addr[5] == 0x00 && addr[6] == 0x00 + && addr[7] == 0x00 && addr[8] == 0x00 && addr[9] == 0x00 && addr[10] == (byte) 0xFF + && addr[11] == (byte) 0xFF; + } + + /** + * Returns true if the given byte array represents an IPv6 Unique Local Address (ULA) range, false otherwise. + * + * @param addr the byte array to check + * @return true if the byte array represents a ULA range, false otherwise + */ + boolean isULA(final byte[] addr) { + return addr.length == 16 && ((addr[0] & 0xFE) == (byte) 0xFC); + } + + /** + * Returns true if the given byte array represents an IPv6 link-local address, false otherwise. + * + * @param addr the byte array to check + * @return true if the byte array represents a link-local address, false otherwise + */ + private boolean isLinkLocal(final byte[] addr) { + return addr.length == 16 && (addr[0] & 0xFF) == 0xFE && (addr[1] & 0xC0) == 0x80; + } + + /** + * Returns true if the given byte array represents an IPv6 site-local address, false otherwise. + * + * @param addr the byte array to check + * @return true if the byte array represents a site-local address, false otherwise + */ + private boolean isSiteLocal(final byte[] addr) { + return addr.length == 16 && (addr[0] & 0xFF) == 0xFE && (addr[1] & 0xC0) == 0xC0; + } + + + /** + * Checks if the given address is reachable on any network interface or if it is a link-local, loopback, + * or site-local address. + * + * @param address the address to check + * @param networkInterfaces a list of network interfaces to check + * @return {@code true} if the address is reachable or a link-local, loopback, or site-local address; + * {@code false} otherwise + */ + private static boolean isAddressReachable(final InetAddress address, final List networkInterfaces) { + // Check if the address is a link-local, loopback, or site-local address + if (address.isLinkLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()) { + return true; + } + // Check if the address is reachable on any network interface + for (final NetworkInterface networkInterface : networkInterfaces) { + final Enumeration addresses = networkInterface.getInetAddresses(); + while (addresses.hasMoreElements()) { + final InetAddress addr = addresses.nextElement(); + if (addr.equals(address)) { + return true; + } + } + } + return false; + } + + /** + * Checks if the given address is a "home" address. A home address is defined as a global unicast IPv6 address + * with the high-order bit of the first octet set to zero, or a private IPv4 address (i.e., an address in + * the ranges 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16). + * + * @param addr the address to check + * @return {@code true} if the address is a "home" address; {@code false} otherwise + */ + private static boolean isHomeAddress(final InetAddress addr) { + if (addr instanceof Inet6Address) { + // Check if the address is a global unicast address with the + // high-order bit of the first octet set to zero. + final byte[] bytes = addr.getAddress(); + return (bytes[0] & 0x80) == 0x00; + } else if (addr instanceof Inet4Address) { + // Check if the address is a private address. + final byte[] bytes = addr.getAddress(); + return ((bytes[0] & 0xFF) == 10) + || (((bytes[0] & 0xFF) == 172) && ((bytes[1] & 0xF0) == 0x10)) + || (((bytes[0] & 0xFF) == 192) && ((bytes[1] & 0xFF) == 168)); + } + return false; + } + + /** + * Determines if the given InetAddress is a care-of address. A care-of address is an + * IPv6 address that is assigned to a mobile node while it is away from its home network. + * + * @param addr The InetAddress to check. + * @return True if the InetAddress is a care-of address, false otherwise. + */ + private static boolean isCareOfAddress(final InetAddress addr) { + if (addr instanceof Inet6Address) { + final byte[] bytes = addr.getAddress(); + return (bytes[0] & 0xfe) == 0xfc; // IPv6 Unique Local Addresses (ULA) range + } + return false; + } + + /** + * Determines if the given InetAddress is an encapsulated address. An encapsulated address + * is either an IPv4-mapped IPv6 address or an IPv4-compatible IPv6 address. + * + * @param addr The InetAddress to check. + * @return True if the InetAddress is an encapsulated address, false otherwise. + */ + private static boolean isEncapsulated(final InetAddress addr) { + if (isIpv6Address(addr)) { + final String addrStr = addr.getHostAddress(); + // Check if the IPv6 address is in the format of "::ffff:x.x.x.x" + if (addrStr.startsWith("::ffff:")) { + return true; + } + } else if (isIpv4Address(addr)) { + // Check if the IPv4 address is in the format of "x.x.x.x" + final byte[] byteAddr = addr.getAddress(); + if (byteAddr[0] == 0 && byteAddr[1] == 0 && byteAddr[2] == 0 && byteAddr[3] != 0) { + return true; + } + } + return false; + } + + + /** + * Calculates the length of the common prefix between two IP addresses. + * + * @param addr1 The first IP address to compare. + * @param addr2 The second IP address to compare. + * @return The length of the common prefix between the two IP addresses. + */ + private static int commonPrefixLen(final InetAddress addr1, final InetAddress addr2) { + byte[] bytes1 = addr1.getAddress(); + byte[] bytes2 = addr2.getAddress(); + + // If the second address is IPv4, convert it to IPv6 format. + if (bytes2.length == 4) { + bytes2 = Arrays.copyOf(bytes2, 16); + } + + // If the addresses are IPv6, truncate them to the first 64 bits. + if (bytes1.length > 8) { + bytes1 = Arrays.copyOf(bytes1, 8); + bytes2 = Arrays.copyOf(bytes2, 8); + } + + int prefixLen = 0; + for (int i = 0; i < bytes1.length; i++) { + int bits = 8; + for (int j = 7; j >= 0; j--) { + if ((bytes1[i] & (1 << j)) == (bytes2[i] & (1 << j))) { + prefixLen += 1; + } else { + bits--; + } + if (bits == 0) { + break; + } + } + if (bits != 8) { + break; + } + } + return prefixLen; + } + +} \ No newline at end of file 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 6ebaf2129..3bc0ae166 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 @@ -163,6 +163,15 @@ public PoolingAsyncClientConnectionManager( poolConcurrencyPolicy, poolReusePolicy, timeToLive); } + PoolingAsyncClientConnectionManager( + final PoolConcurrencyPolicy poolConcurrencyPolicy, + final PoolReusePolicy poolReusePolicy, + final TimeValue timeToLive, + final AsyncClientConnectionOperator connectionOperator) { + this(connectionOperator, + poolConcurrencyPolicy, poolReusePolicy, timeToLive); + } + @Internal protected PoolingAsyncClientConnectionManager( final AsyncClientConnectionOperator connectionOperator, 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 6f72c6bb0..b41e1f5ec 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 @@ -30,8 +30,10 @@ import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.SchemePortResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; import org.apache.hc.client5.http.ssl.ConscryptClientTlsStrategy; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.core5.function.Resolver; @@ -86,6 +88,19 @@ public class PoolingAsyncClientConnectionManagerBuilder { private Resolver connectionConfigResolver; private Resolver tlsConfigResolver; + /** + * A flag indicating whether to use the Happy Eyeballs v2 fast fallback strategy + * for establishing connections. If true, the connection manager will use + * {@link HappyEyeballsV2AsyncClientConnectionOperator} to establish connections + * that attempt to connect to multiple IP addresses in parallel, selecting the + * first successful connection. + *

+ * When false, the default connection operator will be used, which establishes + * connections sequentially. + * @since 5.3 + */ + private boolean fastFallback; + public static PoolingAsyncClientConnectionManagerBuilder create() { return new PoolingAsyncClientConnectionManagerBuilder(); } @@ -229,6 +244,20 @@ public final PoolingAsyncClientConnectionManagerBuilder useSystemProperties() { return this; } + /** + * Enables or disables the fast fallback option for connection management. + * When fast fallback is enabled, the connection manager will attempt to + * quickly switch to alternative connection options if the initial connection fails. + * + * @param fastFallback true to enable fast fallback, false to disable it + * @return this builder instance for method chaining + * @since 5.3 + */ + public final PoolingAsyncClientConnectionManagerBuilder useFastFallback(final boolean fastFallback) { + this.fastFallback = fastFallback; + return this; + } + public PoolingAsyncClientConnectionManager build() { final TlsStrategy tlsStrategyCopy; if (tlsStrategy != null) { @@ -248,15 +277,32 @@ public PoolingAsyncClientConnectionManager build() { } } } - final PoolingAsyncClientConnectionManager poolingmgr = new PoolingAsyncClientConnectionManager( - RegistryBuilder.create() - .register(URIScheme.HTTPS.getId(), tlsStrategyCopy) - .build(), - poolConcurrencyPolicy, - poolReusePolicy, - null, - schemePortResolver, - dnsResolver); + final PoolingAsyncClientConnectionManager poolingmgr; + if (fastFallback) { + final HappyEyeballsV2AsyncClientConnectionOperator connectionOperator = new HappyEyeballsV2AsyncClientConnectionOperator( + RegistryBuilder.create() + .register(URIScheme.HTTPS.getId(), tlsStrategyCopy) + .build(), + DefaultSchemePortResolver.INSTANCE, + SystemDefaultDnsResolver.INSTANCE); + + poolingmgr = new PoolingAsyncClientConnectionManager( + poolConcurrencyPolicy, + poolReusePolicy, + null, + connectionOperator); + } else { + poolingmgr = new PoolingAsyncClientConnectionManager( + RegistryBuilder.create() + .register(URIScheme.HTTPS.getId(), tlsStrategyCopy) + .build(), + poolConcurrencyPolicy, + poolReusePolicy, + null, + schemePortResolver, + dnsResolver); + } + poolingmgr.setConnectionConfigResolver(connectionConfigResolver); poolingmgr.setTlsConfigResolver(tlsConfigResolver); if (maxConnTotal > 0) { diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2AsyncClientConnectionOperatorExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2AsyncClientConnectionOperatorExample.java new file mode 100644 index 000000000..ec8e869e6 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2AsyncClientConnectionOperatorExample.java @@ -0,0 +1,219 @@ +/* + * ==================================================================== + * 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 org.apache.hc.client5.http.DnsResolver; +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.impl.DefaultSchemePortResolver; +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.HappyEyeballsV2AsyncClientConnectionOperator; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator; +import org.apache.hc.client5.http.ssl.ConscryptClientTlsStrategy; +import org.apache.hc.core5.concurrent.DefaultThreadFactory; +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.config.RegistryBuilder; +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.pool.PoolConcurrencyPolicy; +import org.apache.hc.core5.pool.PoolReusePolicy; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +/** + * Example class demonstrating how to use the Happy Eyeballs V2 connection operator in the Apache + * HttpComponents asynchronous HTTP client. + */ +public class HappyEyeballsV2AsyncClientConnectionOperatorExample { + + public static void main(final String[] args) throws ExecutionException, InterruptedException, IOException { + + // Create a custom AsyncConnectionFactory + final HappyEyeballsV2AsyncClientConnectionOperator connectionOperator = new HappyEyeballsV2AsyncClientConnectionOperator( + RegistryBuilder.create().register(URIScheme.HTTPS.getId(), ConscryptClientTlsStrategy.getDefault()).build(), + DefaultSchemePortResolver.INSTANCE, + new MultipleHostsDnsResolver(new String[]{"ipv6.google.com", "ipv4.google.com", "facebook.com", "yahoo.com"}, new int[]{443, 443, 443, 443})); + + + final PoolingAsyncClientConnectionManager connectionManager = new CustomPoolingAsyncClientConnectionManager( + connectionOperator, + PoolConcurrencyPolicy.LAX, + PoolReusePolicy.LIFO, + TimeValue.ofMinutes(1)); + + connectionManager.setConnectionConfigResolver(httpRoute -> ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(2)) + .setSocketTimeout(Timeout.ofSeconds(1)) + .setValidateAfterInactivity(TimeValue.ofSeconds(10)) + .setTimeToLive(TimeValue.ofMinutes(1)) + .build()); + + // Create a custom AsyncClient + final CloseableHttpAsyncClient client = HttpAsyncClients.custom() + .setConnectionManager(connectionManager) + .setThreadFactory(new DefaultThreadFactory("async-client", true)) + .build(); + + // Start the CloseableHttpAsyncClient + client.start(); + + + final SimpleHttpRequest request = SimpleRequestBuilder.get() + .setHttpHost(new HttpHost("www.google.com")) + .setPath("/") + .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"); + } + + }); + future.get(); + + System.out.println("Shutting down"); + client.close(CloseMode.GRACEFUL); + + } + + /** + * Custom implementation of PoolingAsyncClientConnectionManager that uses the specified connection operator + * and pool configuration. + */ + private static class CustomPoolingAsyncClientConnectionManager extends PoolingAsyncClientConnectionManager { + + CustomPoolingAsyncClientConnectionManager( + final AsyncClientConnectionOperator connectionOperator, + final PoolConcurrencyPolicy poolConcurrencyPolicy, + final PoolReusePolicy poolReusePolicy, + final TimeValue timeToLive) { + super(connectionOperator, poolConcurrencyPolicy, poolReusePolicy, timeToLive); + } + + } + + /** + * A DNS resolver that resolves hostnames against multiple hosts and ports. + */ + static class MultipleHostsDnsResolver implements DnsResolver { + + /** + * The array of hostnames to use for resolving. + */ + private final String[] hosts; + + /** + * The array of ports to use for resolving. + */ + private final int[] ports; + + public MultipleHostsDnsResolver(final String[] hosts, final int[] ports) { + this.hosts = hosts; + this.ports = ports; + } + + public static final SystemDefaultDnsResolver INSTANCE = new SystemDefaultDnsResolver(); + + @Override + public InetAddress[] resolve(final String host) throws UnknownHostException { + final List addresses = new ArrayList<>(); + for (int i = 0; i < hosts.length; i++) { + final InetAddress[] inetAddresses = InetAddress.getAllByName(hosts[i]); + for (final InetAddress inetAddress : inetAddresses) { + addresses.add(new InetSocketAddress(inetAddress, ports[i])); + } + } + + // Convert InetSocketAddress objects to InetAddress objects + final List result = new ArrayList<>(); + for (final InetSocketAddress address : addresses) { + result.add(address.getAddress()); + } + return result.toArray(new InetAddress[0]); + } + + @Override + public String resolveCanonicalHostname(final String host) throws UnknownHostException { + if (host == null) { + return null; + } + final InetAddress in = InetAddress.getByName(host); + final String canonicalServer = in.getCanonicalHostName(); + if (in.getHostAddress().contentEquals(canonicalServer)) { + return host; + } + return canonicalServer; + } + + } + +} + + + + + + + + diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2RulesTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2RulesTest.java new file mode 100644 index 000000000..d888844b2 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2RulesTest.java @@ -0,0 +1,680 @@ +/* + * ==================================================================== + * 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.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +/** + * This class contains unit tests to verify the implementation of the Happy Eyeballs V2 connection + * operator, which applies a set of rules to determine the optimal order in which to connect to + * multiple IP addresses for a given hostname. These tests verify that the implementation follows + * the specified rules correctly and returns the expected results. + */ +public class HappyEyeballsV2RulesTest { + + + public static void main(final String[] args) throws Exception { + + System.out.println("---- Running HappyEyeballsV2RulesTest ----"); + + rule1(); + rule2(); + rule3(); + rule4(); + rule5(); + rule6(); + rule7(); + rule8(); + rule9(); + + + System.out.println("---- All tests passed. ----"); + } + + public static void rule1() throws Exception { + final InetAddress addr1 = InetAddress.getByName("192.0.2.1"); + final InetAddress addr2 = InetAddress.getByName("2001:db8::1"); + final InetAddress addr3 = InetAddress.getByName("www.google.com"); + + final List addresses = Arrays.asList(addr3, addr2, addr1); + addresses.sort((addr11, addr22) -> { + // Get the list of network interfaces + final List networkInterfaces; + try { + networkInterfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + } catch (final SocketException e) { + return 0; + } + + // Rule 1: Avoid unusable destinations. + final boolean add1IsReachable; + final boolean add2IsReachable; + try { + // Check if address 1 is reachable on any network interface + add1IsReachable = isAddressReachable(addr1, networkInterfaces); + } catch (final IOException e) { + return -1; + } + try { + // Check if address 2 is reachable on any network interface + add2IsReachable = isAddressReachable(addr2, networkInterfaces); + } catch (final IOException e) { + return 1; + } + + if (add1IsReachable && !add2IsReachable) { + return -1; + } else if (!add1IsReachable && add2IsReachable) { + return 1; + } + + return 0; + + }); + + if (!addresses.get(0).equals(addr3)) { + throw new Exception("Rule 1 not satisfied. Expected addr3 as the first address."); + } + + if (!addresses.get(1).equals(addr2)) { + throw new Exception("Rule 1 not satisfied. Expected addr2 as the second address."); + } + + if (!addresses.get(2).equals(addr1)) { + throw new Exception("Rule 1 not satisfied. Expected addr1 as the third address."); + } + + System.out.println("Rule 1 satisfied."); + } + + + public static void rule2() throws Exception { + final InetAddress addr1 = InetAddress.getByName("192.0.2.1"); + final InetAddress addr2 = InetAddress.getByName("2001:db8::1"); + final InetAddress addr3 = InetAddress.getByName("www.google.com"); + + final InetAddress localhost; + final InetAddress sourceAddr; + try { + localhost = InetAddress.getLocalHost(); + sourceAddr = InetAddress.getByName(localhost.getHostAddress()); + } catch (final UnknownHostException e) { + throw new RuntimeException(e); + } + + final List addresses = Arrays.asList(addr3, addr1, addr2); + addresses.sort((addr11, addr21) -> { + final int scope1 = getScope(addr11); + final int scope2 = getScope(addr21); + final int scopeSource = getScope(sourceAddr); + + if (scope1 == scope2) { + return 0; + } else if (scope1 == scopeSource) { + return -1; + } else if (scope2 == scopeSource) { + return 1; + } else { + return 0; + } + }); + + // Check if the result is correct + if (!addresses.get(0).equals(addr3)) { + throw new Exception("Rule 2 not satisfied. Expected www.google.com as the first address."); + } + + if (!addresses.get(1).equals(addr1)) { + throw new Exception("Rule 2 not satisfied. Expected addr1 as the second address."); + } + + if (!addresses.get(2).equals(addr2)) { + throw new Exception("Rule 2 not satisfied. Expected addr2 as the third address."); + } + System.out.println("Rule 2 satisfied."); + } + + public static void rule3() throws Exception { + final InetAddress srcAddr1 = InetAddress.getByName("192.0.2.1"); + final InetAddress dstAddr1 = InetAddress.getByName("www.google.com"); + final InetAddress srcAddr2 = InetAddress.getByName("2001:db8::1"); + final InetAddress dstAddr2 = InetAddress.getByName("2001:db8::2"); + + final List addresses = Arrays.asList(dstAddr2, dstAddr1, srcAddr2, srcAddr1); + addresses.sort((addr1, addr2) -> { + final boolean addr1IsDeprecated = isDeprecated(addr1); + final boolean addr2IsDeprecated = isDeprecated(addr2); + final boolean addr1IsSource = isSourceAddress(addr1, srcAddr1, srcAddr2); + final boolean addr2IsSource = isSourceAddress(addr2, srcAddr1, srcAddr2); + + if (addr1IsDeprecated && !addr2IsDeprecated) { + return 1; + } else if (!addr1IsDeprecated && addr2IsDeprecated) { + return -1; + } else if (addr1IsSource && !addr2IsSource) { + return -1; + } else if (!addr1IsSource && addr2IsSource) { + return 1; + } else { + return 0; + } + }); + + if (!addresses.get(0).equals(srcAddr2)) { + throw new Exception("Rule 3 not satisfied. Expected srcAddr2 as the first address."); + } + + if (!addresses.get(1).equals(srcAddr1)) { + throw new Exception("Rule 3 not satisfied. Expected srcAddr1 as the second address."); + } + + if (!addresses.get(2).equals(dstAddr2)) { + throw new Exception("Rule 3 not satisfied. Expected dstAddr2 as the third address."); + } + + if (!addresses.get(3).equals(dstAddr1)) { + throw new Exception("Rule 3 not satisfied. Expected srcAddr2 as the fourth address."); + } + + System.out.println("Rule 3 satisfied."); + } + + public static void rule4() throws Exception { + final InetAddress addr1 = InetAddress.getByName("192.0.2.1"); + final InetAddress addr2 = InetAddress.getByName("192.168.0.1"); + final InetAddress addr3 = InetAddress.getByName("2001:db8::1"); + final InetAddress addr4 = InetAddress.getByName("2001:db8::2"); + + final List addresses = Arrays.asList(addr3, addr1, addr2, addr4); + + addresses.sort((addr11, addr21) -> { + // Rule 4: Prefer home addresses. + final boolean isHomeAddr11 = isHomeAddress(addr11); + final boolean isCareOfAddr11 = isCareOfAddress(addr11); + final boolean isHomeAddr21 = isHomeAddress(addr21); + final boolean isCareOfAddr21 = isCareOfAddress(addr21); + + if (isHomeAddr11 && isCareOfAddr11 && !isHomeAddr21) { + return -1; + } else if (isHomeAddr21 && isCareOfAddr21 && !isHomeAddr11) { + return 1; + } + return 0; + }); + + + if (!addresses.get(0).equals(addr3)) { + throw new Exception("Rule 4 not satisfied. Expected addr1 as the first address."); + } + + System.out.println("Rule 4 satisfied."); + } + + public static void rule5() throws Exception { + final InetAddress addr1 = InetAddress.getByName("192.0.2.1"); + final InetAddress addr2 = InetAddress.getByName("2001:db8::1"); + final InetAddress addr3 = InetAddress.getByName("www.google.com"); + + final List addresses = Arrays.asList(addr3, addr1, addr2); + + addresses.sort((addr11, addr21) -> { + //Rule 5: Prefer matching label. + final int label1 = getLabel(addr11); + final int label2 = getLabel(addr21); + + if (label1 < label2) { + return -1; + } else if (label1 > label2) { + return 1; + } + + return 0; + }); + + if (!addresses.get(0).equals(addr2)) { + throw new Exception("Rule 5 not satisfied. Expected 2001:db8::1 as the first address."); + } + + if (!addresses.get(1).equals(addr3)) { + throw new Exception("Rule 5 not satisfied. Expected www.google.com as the second address."); + } + + if (!addresses.get(2).equals(addr1)) { + throw new Exception("Rule 5 not satisfied. Expected 192.0.2.1 as the third address."); + } + + System.out.println("Rule 5 satisfied."); + } + + public static void rule6() throws Exception { + final InetAddress addr1 = InetAddress.getByName("2001:db8:0:0:1::"); + final InetAddress addr2 = InetAddress.getByName("2001:db8:0:0:2::"); + + final List addresses = Arrays.asList(addr2, addr1); + + addresses.sort((addr11, addr21) -> { + // Rule 6: Prefer higher precedence. + final int add1Precedence = getPrecedence(addr11); + final int add2Precedence = getPrecedence(addr21); + + if (add1Precedence > add2Precedence) { + return -1; + } else if (add1Precedence < add2Precedence) { + return 1; + } + + return 0; + }); + + if (!addresses.get(0).equals(addr2)) { + throw new Exception("Rule 6 not satisfied. Expected addr2 as the first address."); + } + + System.out.println("Rule 6 satisfied."); + } + + + public static void rule7() throws Exception { + final InetAddress addr1 = InetAddress.getByName("2001:db8:0:0:1::"); + final InetAddress addr2 = InetAddress.getByName("2001:db8:0:0:2::"); + + final List addresses = Arrays.asList(addr2, addr1); + + addresses.sort((addr11, addr21) -> { + // Rule 7: Prefer native transport. + final boolean addr1Encapsulated = isEncapsulated(addr11); + final boolean addr2Encapsulated = isEncapsulated(addr21); + + if (addr1Encapsulated && !addr2Encapsulated) { + return 1; + } else if (!addr1Encapsulated && addr2Encapsulated) { + return -1; + } + + return 0; + }); + + if (!addresses.get(0).equals(addr2)) { + throw new Exception("Rule 7 not satisfied. Expected 2001:db8:0:0:2:: as the first address."); + } + + if (!addresses.get(1).equals(addr1)) { + throw new Exception("Rule 7 not satisfied. Expected 2001:db8:0:0:1:: as the second address."); + } + System.out.println("Rule 7 satisfied."); + } + + public static void rule8() throws Exception { + final InetAddress addr1 = InetAddress.getByName("2001:db8:0:0:0:0:0:1"); + final InetAddress addr2 = InetAddress.getByName("2001:db8:0:1:0:0:0:1"); + final InetAddress addr3 = InetAddress.getByName("2001:db8:0:2:0:0:0:1"); + final InetAddress addr4 = InetAddress.getByName("::1"); + final InetAddress addr5 = InetAddress.getByName("fe80::1%lo0"); + + final List addresses = Arrays.asList(addr4, addr5, addr3, addr1, addr2); + + addresses.sort((addr11, addr21) -> { + // Rule 8: Prefer smaller scope. + final int scope1 = getScope(addr11); + final int scope2 = getScope(addr21); + + if (scope1 < scope2) { + return -1; + } else if (scope1 > scope2) { + return 1; + } else { + return 0; + } + }); + + if (!addresses.get(0).equals(addr4)) { + throw new Exception("Rule 8 not satisfied. Expected fe80::1%lo0 as the first address."); + } + + if (!addresses.get(1).equals(addr5)) { + throw new Exception("Rule 8 not satisfied. Expected ::1 as the second address."); + } + + if (!addresses.get(2).equals(addr3)) { + throw new Exception("Rule 8 not satisfied. Expected 2001:db8:0:0:0:0:0:1 as the third address."); + } + + if (!addresses.get(3).equals(addr1)) { + throw new Exception("Rule 8 not satisfied. Expected 2001:db8:0:1:0:0:0:1 as the fourth address."); + } + + if (!addresses.get(4).equals(addr2)) { + throw new Exception("Rule 8 not satisfied. Expected 2001:db8:0:2:0:0:0:1 as the fifth address."); + } + + System.out.println("Rule 8 satisfied."); + } + + public static void rule9() throws Exception { + final InetAddress addr1 = InetAddress.getByName("2001:db8:0:0:1::"); + final InetAddress addr2 = InetAddress.getByName("2001:db8:0:0:1:0:0:2"); + final InetAddress addr3 = InetAddress.getByName("2001:db8:0:0:1:0:0:3"); + final InetAddress addr4 = InetAddress.getByName("2001:db8:0:0:2:0:0:4"); + + final List addresses = Arrays.asList(addr4, addr1, addr2, addr3); + + addresses.sort((addr11, addr21) -> { + // Rule 9: Use longest matching prefix. + final int prefixLen1 = commonPrefixLen(addr11, addr21); + final int prefixLen2 = commonPrefixLen(addr21, addr11); + + if (prefixLen1 > prefixLen2) { + return -1; + } else if (prefixLen1 < prefixLen2) { + return 1; + } + return 0; + }); + + if (!addresses.get(0).equals(addr4)) { + throw new Exception("Rule 9 not satisfied. Expected addr1 as the first address."); + } + + System.out.println("Rule 9 satisfied."); + } + + + private static int getPrecedence(final InetAddress addr) { + if (addr instanceof Inet6Address) { + final byte[] addrBytes = addr.getAddress(); + if (addrBytes[0] == (byte) 0xFF) { + return 1; // multicast + } else if (isIPv4MappedIPv6Address(addrBytes)) { + return 4; // IPv4-mapped IPv6 address + } else if (isULA(addrBytes)) { + return 5; // unique local address + } else if (isLinkLocal(addrBytes)) { + return 6; // link-local address + } else if (isSiteLocal(addrBytes)) { + return 7; // site-local address + } else { + return 3; // global address + } + } else { + return 2; // IPv4 address + } + } + + private static boolean isIPv4MappedIPv6Address(final byte[] addr) { + return addr.length == 16 && addr[0] == 0x00 && addr[1] == 0x00 && addr[2] == 0x00 + && addr[3] == 0x00 && addr[4] == 0x00 && addr[5] == 0x00 && addr[6] == 0x00 + && addr[7] == 0x00 && addr[8] == 0x00 && addr[9] == 0x00 && addr[10] == (byte) 0xFF + && addr[11] == (byte) 0xFF; + } + + private static boolean isULA(final byte[] addr) { + return addr.length == 16 && ((addr[0] & 0xFE) == (byte) 0xFC); + } + + private static boolean isLinkLocal(final byte[] addr) { + return addr.length == 16 && (addr[0] & 0xFF) == 0xFE && (addr[1] & 0xC0) == 0x80; + } + + private static boolean isSiteLocal(final byte[] addr) { + return addr.length == 16 && (addr[0] & 0xFF) == 0xFE && (addr[1] & 0xC0) == 0xC0; + } + + + private static boolean isEncapsulated(final InetAddress addr) { + if (isIpv6Address(addr)) { + final String addrStr = addr.getHostAddress(); + // Check if the IPv6 address is in the format of "::ffff:x.x.x.x" + if (addrStr.startsWith("::ffff:")) { + return true; + } + } else if (isIpv4Address(addr)) { + // Check if the IPv4 address is in the format of "x.x.x.x" + final byte[] byteAddr = addr.getAddress(); + if (byteAddr[0] == 0 && byteAddr[1] == 0 && byteAddr[2] == 0 && byteAddr[3] != 0) { + return true; + } + } + return false; + } + + + private static boolean isDeprecated(final InetAddress addr) { + if (addr instanceof Inet4Address) { + return false; + } else if (addr instanceof Inet6Address) { + final Inet6Address ipv6Addr = (Inet6Address) addr; + final byte[] addressBytes = ipv6Addr.getAddress(); + + // Check if the IPv6 address is IPv4-mapped + if (addressBytes[0] == 0 && addressBytes[1] == 0 && addressBytes[2] == 0 && addressBytes[3] == 0 && + addressBytes[4] == 0 && addressBytes[5] == 0 && addressBytes[6] == 0 && addressBytes[7] == 0 && + addressBytes[8] == 0 && addressBytes[9] == 0 && addressBytes[10] == (byte) 0xFF && addressBytes[11] == (byte) 0xFF) { + return true; + } + + // Check if the IPv6 address is a link-local address + if ((addressBytes[0] & 0xFF) == 0xFE && (addressBytes[1] & 0xC0) == 0x80) { + return true; + } + } + return false; + } + + private static boolean isAddressReachable(final InetAddress address, final List networkInterfaces) throws IOException { + // Check if the address is a link-local, loopback, or site-local address + if (address.isLinkLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()) { + return true; + } + // Check if the address is reachable on any network interface + for (final NetworkInterface networkInterface : networkInterfaces) { + final Enumeration addresses = networkInterface.getInetAddresses(); + while (addresses.hasMoreElements()) { + final InetAddress addr = addresses.nextElement(); + if (addr.equals(address)) { + return true; + } + } + } + + return false; + } + + + private static int getLabel(final InetAddress addr) { + if (isIpv4Address(addr)) { + return 4; + } else if (isIpv6Address(addr)) { + if (addr.isLoopbackAddress()) { + return 0; + } else if (isIpv6Address6To4(addr)) { + return 2; + } else if (isIpv6AddressTeredo(addr)) { + return 5; + } else if (isIpv6AddressULA(addr)) { + return 13; + } else if (((Inet6Address) addr).isIPv4CompatibleAddress()) { + return 3; + } else if (addr.isSiteLocalAddress()) { + return 11; + } else if (isIpv6Address6Bone(addr)) { + return 12; + } else { + // All other IPv6 addresses, including global unicast addresses. + return 1; + } + } else { + // This should never happen. + return 1; + } + } + + private static boolean isIpv6Address(final InetAddress addr) { + return addr instanceof Inet6Address; + } + + private static boolean isIpv4Address(final InetAddress addr) { + return addr instanceof Inet4Address; + } + + private static boolean isIpv6Address6To4(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x20 && byteAddr[1] == 0x02; + } + + private static boolean isIpv6AddressTeredo(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x20 && byteAddr[1] == 0x01 && byteAddr[2] == 0x00 + && byteAddr[3] == 0x00; + } + + private static boolean isIpv6AddressULA(final InetAddress addr) { + return isIpv6Address(addr) && (addr.getAddress()[0] & 0xfe) == 0xfc; + } + + private static boolean isIpv6Address6Bone(final InetAddress addr) { + if (!isIpv6Address(addr)) { + return false; + } + final byte[] byteAddr = addr.getAddress(); + return byteAddr[0] == 0x3f && byteAddr[1] == (byte) 0xfe; + } + + private static int getIpv6MulticastScope(final InetAddress addr) { + return !isIpv6Address(addr) ? 0 : (addr.getAddress()[1] & 0x0f); + } + + + private static int getScope(final InetAddress addr) { + if (isIpv6Address(addr)) { + if (addr.isMulticastAddress()) { + return getIpv6MulticastScope(addr); + } else if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) { + return 0x02; + } else if (addr.isSiteLocalAddress()) { + return 0x05; + } else { + return 0x0e; + } + } else if (isIpv4Address(addr)) { + if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) { + return 0x02; + } else { + return 0x0e; + } + } else { + return 0x01; + } + } + + private static boolean isHomeAddress(final InetAddress addr) { + if (addr instanceof Inet6Address) { + // Check if the address is a global unicast address with the + // high-order bit of the first octet set to zero. + final byte[] bytes = addr.getAddress(); + return (bytes[0] & 0x80) == 0x00; + } else if (addr instanceof Inet4Address) { + // Check if the address is a private address. + final byte[] bytes = addr.getAddress(); + return ((bytes[0] & 0xFF) == 10) + || (((bytes[0] & 0xFF) == 172) && ((bytes[1] & 0xF0) == 0x10)) + || (((bytes[0] & 0xFF) == 192) && ((bytes[1] & 0xFF) == 168)); + } + return false; + } + + private static boolean isCareOfAddress(final InetAddress addr) { + if (addr instanceof Inet6Address) { + final byte[] bytes = addr.getAddress(); + return (bytes[0] & 0xfe) == 0xfc; // IPv6 Unique Local Addresses (ULA) range + } + return false; + } + + private static boolean isSourceAddress(final InetAddress addr, final InetAddress srcAddr1, final InetAddress srcAddr2) { + // Check if the address matches either srcAddr1 or srcAddr2 + return addr.equals(srcAddr1) || addr.equals(srcAddr2); + } + + private static int commonPrefixLen(final InetAddress addr1, final InetAddress addr2) { + byte[] bytes1 = addr1.getAddress(); + byte[] bytes2 = addr2.getAddress(); + + if (bytes2.length == 4) { + bytes2 = Arrays.copyOf(bytes2, 16); + } + + if (bytes1.length > 8) { + bytes1 = Arrays.copyOf(bytes1, 8); + bytes2 = Arrays.copyOf(bytes2, 8); + } + + int prefixLen = 0; + for (int i = 0; i < bytes1.length; i++) { + int bits = 8; + for (int j = 7; j >= 0; j--) { + if ((bytes1[i] & (1 << j)) == (bytes2[i] & (1 << j))) { + prefixLen += 1; + } else { + bits--; + } + if (bits == 0) { + break; + } + } + if (bits != 8) { + break; + } + } + return prefixLen; + } +} + + + + + + + + diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorTest.java new file mode 100644 index 000000000..2d84368f7 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorTest.java @@ -0,0 +1,408 @@ +/* + * ==================================================================== + * 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.impl.nio; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator; +import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection; +import org.apache.hc.client5.http.ssl.ConscryptClientTlsStrategy; +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.config.RegistryBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.apache.hc.core5.http.protocol.BasicHttpContext; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.reactor.ConnectionInitiator; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +public class HappyEyeballsV2AsyncClientConnectionOperatorTest { + + private AsyncClientConnectionOperator happyEyeballsV2AsyncClientConnectionOperator; + + private ConnectionInitiator connectionInitiator; + + private DnsResolver dnsResolver; + + private SocketAddress socketAddress; + + private AsyncClientConnectionOperator connectionOperator; + + + @BeforeEach + public void setup() { + + dnsResolver = Mockito.mock(DnsResolver.class); + connectionOperator = Mockito.mock(AsyncClientConnectionOperator.class); + + happyEyeballsV2AsyncClientConnectionOperator = new HappyEyeballsV2AsyncClientConnectionOperator + (RegistryBuilder.create().register(URIScheme.HTTPS.getId(), ConscryptClientTlsStrategy.getDefault()).build(), + connectionOperator, + dnsResolver, + Timeout.ofSeconds(1), + Timeout.ofSeconds(1), + Timeout.ofSeconds(1), + Timeout.ofSeconds(1), + Timeout.ofSeconds(1), + 1, + HappyEyeballsV2AsyncClientConnectionOperator.AddressFamily.IPv4, + null); + + connectionInitiator = mock(ConnectionInitiator.class); + socketAddress = mock(SocketAddress.class); + + + } + + @DisplayName("Test that application prioritizes IPv6 over IPv4 when both are available") + @Test + public void testIPv6ConnectionIsAttemptedBeforeIPv4() throws UnknownHostException, ExecutionException, InterruptedException { + final HttpContext context = new BasicHttpContext(); + final HttpHost host = new HttpHost("somehost"); + final InetAddress ip1 = InetAddress.getByAddress(new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xFF, (byte) 0xFF, 0x7F, 0, 0, 0x1}); // IPv6 address ::ffff:127.0.0.1 + final InetAddress ip2 = InetAddress.getByAddress(new byte[]{127, 0, 0, 2}); + final TlsConfig tlsConfig = TlsConfig.custom() + .build(); + + Mockito.when(dnsResolver.resolve("somehost")).thenReturn(new InetAddress[]{ip1, ip2}); + Mockito.when(connectionOperator.connect(any(), any(), any(), any(), any(), any())) + .thenAnswer((Answer>) invocation -> { + // Extract the callback from the arguments + final FutureCallback callback = + invocation.getArgument(5); + + // Create a CompletableFuture for the connection result + final CompletableFuture result = new CompletableFuture<>(); + + // Invoke the callback's completed() method with a mock connection + callback.completed(Mockito.mock(ManagedAsyncClientConnection.class)); + + // Return the CompletableFuture + return result; + }); + + + final Future future = happyEyeballsV2AsyncClientConnectionOperator.connect( + connectionInitiator, + host, + socketAddress, + Timeout.ofMilliseconds(123), + tlsConfig, + context, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection managedAsyncClientConnection) { + System.out.println("Executing request " + managedAsyncClientConnection); + } + + @Override + public void failed(final Exception e) { + System.out.println("Exception " + e); + } + + @Override + public void cancelled() { + System.out.println("Cancelled"); + } + } + ); + + future.get(); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + + } + + @DisplayName("Test asynchronous behavior with valid input and expect correct output") + @Test + public void testAsyncBehavior_withValidInput_expectCorrectOutput() throws Exception { + final HttpContext context = new BasicHttpContext(); + final HttpHost host = new HttpHost("somehost"); + final InetAddress ip1 = InetAddress.getByAddress(new byte[]{127, 0, 0, 1}); + final InetAddress ip2 = InetAddress.getByAddress(new byte[]{127, 0, 0, 2}); + final TlsConfig tlsConfig = TlsConfig.custom().build(); + + Mockito.when(dnsResolver.resolve("somehost")).thenReturn(new InetAddress[]{ip1, ip2}); + + // Create a mock connection + final ManagedAsyncClientConnection connection = Mockito.mock(ManagedAsyncClientConnection.class); + + // Create a future that will complete when the connection is established + final CompletableFuture future = new CompletableFuture<>(); + + // Make the connection mock return the future when connect() is called + Mockito.when(connectionOperator.connect(any(), any(), any(), any(), any(), any())) + .thenAnswer((Answer>) invocation -> { + // Extract the callback from the arguments + final FutureCallback callback = + invocation.getArgument(5); + + // Create a CompletableFuture for the connection result + final CompletableFuture result = new CompletableFuture<>(); + + // Invoke the callback's completed() method with a mock connection + callback.completed(connection); + + // Return the CompletableFuture + return result; + }); + + // Call connect() on the operator + final Future result = happyEyeballsV2AsyncClientConnectionOperator.connect( + connectionInitiator, + host, + socketAddress, + Timeout.ofMilliseconds(123), + tlsConfig, + context, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection managedAsyncClientConnection) { + System.out.println("Executing request " + managedAsyncClientConnection); + } + + @Override + public void failed(final Exception e) { + System.out.println("Exception " + e); + } + + @Override + public void cancelled() { + System.out.println("Cancelled"); + } + }); + + // Verify that the connection is not yet established + assertFalse(result.isDone()); + + // Complete the future with the connection mock + future.complete(connection); + + // Wait for the connection to be established + final ManagedAsyncClientConnection actualConnection = result.get(); + + // Verify that the correct connection was returned + assertSame(connection, actualConnection); + } + + + @Test + @DisplayName("Verify Asynchronous Behavior of Request Processing") + public void verifyAsynchronousBehaviorOfRequestProcessing() throws Exception { + final HttpContext context = new BasicHttpContext(); + final HttpHost host = new HttpHost("somehost"); + final InetAddress ip1 = InetAddress.getByAddress(new byte[]{127, 0, 0, 1}); + final InetAddress ip2 = InetAddress.getByAddress(new byte[]{127, 0, 0, 2}); + final TlsConfig tlsConfig = TlsConfig.custom().build(); + + Mockito.when(dnsResolver.resolve("somehost")).thenReturn(new InetAddress[]{ip1, ip2}); + + // Create a mock connection + final ManagedAsyncClientConnection connection = Mockito.mock(ManagedAsyncClientConnection.class); + + // Create a future that will complete when the connection is established + final CompletableFuture future = new CompletableFuture<>(); + + // Make the connection mock return the future when connect() is called + Mockito.when(connectionOperator.connect(any(), any(), any(), any(), any(), any())) + .thenAnswer((Answer>) invocation -> { + // Extract the callback from the arguments + final FutureCallback callback = + invocation.getArgument(5); + + // Create a CompletableFuture for the connection result + final CompletableFuture result = new CompletableFuture<>(); + + // Invoke the callback's completed() method with a mock connection + callback.completed(connection); + + // Return the CompletableFuture + return result; + }); + + // Call connect() on the operator + final Future result = happyEyeballsV2AsyncClientConnectionOperator.connect( + connectionInitiator, + host, + socketAddress, + Timeout.ofMilliseconds(123), + tlsConfig, + context, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection managedAsyncClientConnection) { + System.out.println("Executing request " + managedAsyncClientConnection); + } + + @Override + public void failed(final Exception e) { + System.out.println("Exception " + e); + } + + @Override + public void cancelled() { + System.out.println("Cancelled"); + } + }); + + // Verify that the connection is not yet established + assertFalse(result.isDone()); + + // Complete the future with the connection mock + future.complete(connection); + + // Wait for the connection to be established + final ManagedAsyncClientConnection actualConnection = result.get(); + + // Verify that the correct connection was returned + assertSame(connection, actualConnection); + + // Check that the failed and cancelled callbacks were not called + assertFalse(future.isCompletedExceptionally()); + assertFalse(result.isCancelled()); + } + + @Test + @DisplayName("Test successful connection using only IPv4") + public void testIPv4SuccessfulConnection() throws Exception { + final HttpContext context = new BasicHttpContext(); + final HttpHost host = new HttpHost("ipv4host"); + final InetAddress ip1 = InetAddress.getByAddress(new byte[]{127, 0, 0, 1}); + final TlsConfig tlsConfig = TlsConfig.custom().build(); + + Mockito.when(dnsResolver.resolve("ipv4host")).thenReturn(new InetAddress[]{ip1}); + Mockito.when(connectionOperator.connect(any(), any(), any(), any(), any(), any())) + .thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(5); + final CompletableFuture result = new CompletableFuture<>(); + callback.completed(Mockito.mock(ManagedAsyncClientConnection.class)); + return result; + }); + + final Future future = happyEyeballsV2AsyncClientConnectionOperator.connect( + connectionInitiator, + host, + socketAddress, + Timeout.ofMilliseconds(123), + tlsConfig, + context, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection managedAsyncClientConnection) { + System.out.println("Executing request " + managedAsyncClientConnection); + } + + @Override + public void failed(final Exception e) { + System.out.println("Exception " + e); + } + + @Override + public void cancelled() { + System.out.println("Cancelled"); + } + }); + + future.get(); + assertTrue(future.isDone()); + assertFalse(future.isCancelled()); + Mockito.verify(connectionOperator, times(1)).connect(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("Test failed connection attempt") + public void testFailedConnectionAttempt() throws Exception { + final HttpContext context = new BasicHttpContext(); + final HttpHost host = new HttpHost("failedhost"); + final InetAddress ip1 = InetAddress.getByAddress(new byte[]{127, 0, 0, 1}); + final TlsConfig tlsConfig = TlsConfig.custom().build(); + + Mockito.when(dnsResolver.resolve("failedhost")).thenReturn(new InetAddress[]{ip1}); + Mockito.when(connectionOperator.connect(any(), any(), any(), any(), any(), any())) + .thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(5); + final CompletableFuture result = new CompletableFuture<>(); + callback.failed(new IOException("Failed to connect")); + return result; + }); + + final CompletableFuture future = new CompletableFuture<>(); + happyEyeballsV2AsyncClientConnectionOperator.connect( + connectionInitiator, + host, + socketAddress, + Timeout.ofMilliseconds(123), + tlsConfig, + context, + new FutureCallback() { + @Override + public void completed(final ManagedAsyncClientConnection managedAsyncClientConnection) { + System.out.println("Executing request " + managedAsyncClientConnection); + future.complete(managedAsyncClientConnection); + } + + @Override + public void failed(final Exception e) { + System.out.println("Exception " + e); + future.completeExceptionally(e); + } + + @Override + public void cancelled() { + System.out.println("Cancelled"); + future.cancel(true); + } + }); + + assertThrows(ExecutionException.class, () -> future.get()); + assertTrue(future.isCompletedExceptionally()); + assertFalse(future.isCancelled()); + Mockito.verify(connectionOperator, times(1)).connect(any(), any(), any(), any(), any(), any()); + } +}