From 8283e019a11b48b92c7acdb82f88113e8a5a7d42 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 24 Mar 2023 22:47:43 +0100 Subject: [PATCH] Implement HappyEyeballsV2AsyncClientConnectionOperator This commit adds the implementation of HappyEyeballsV2AsyncClientConnectionOperator, a new class that supports asynchronous connection establishment over both IPv4 and IPv6. The operator follows the Happy Eyeballs v2 algorithm to attempt to connect first over IPv6 and then fall back to IPv4 if needed. --- ...eballsV2AsyncClientConnectionOperator.java | 745 ++++++++++++++++++ ...2AsyncClientConnectionOperatorBuilder.java | 277 +++++++ .../http/impl/nio/InetAddressComparator.java | 619 +++++++++++++++ .../PoolingAsyncClientConnectionManager.java | 9 + ...ngAsyncClientConnectionManagerBuilder.java | 64 +- ...2AsyncClientConnectionOperatorExample.java | 219 +++++ .../examples/HappyEyeballsV2RulesTest.java | 658 ++++++++++++++++ ...lsV2AsyncClientConnectionOperatorTest.java | 408 ++++++++++ 8 files changed, 2990 insertions(+), 9 deletions(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperator.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorBuilder.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/InetAddressComparator.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2AsyncClientConnectionOperatorExample.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2RulesTest.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/nio/HappyEyeballsV2AsyncClientConnectionOperatorTest.java 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 b56c24628..2f5a504dd 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..4ab9701dd --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/HappyEyeballsV2RulesTest.java @@ -0,0 +1,658 @@ +/* + * ==================================================================== + * 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 { + rule1(); + rule2(); + rule3(); + rule4(); + rule5(); + rule6(); + rule7(); + rule8(); + rule9(); + } + + 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."); + } + } + + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + + 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()); + } +}