diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index b2993a474..cd1e76b6e 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -5,6 +5,12 @@ This library adheres to `Semantic Versioning 2.0 `_.
**UNRELEASED**
+- Fixed ``connect_tcp()`` blindly prioritizing IPv6 even when the host has no usable
+ IPv6 connection. The connection attempts now follow the order returned by the
+ resolver (RFC 6724 destination address selection), which prefers IPv6 when the host
+ has a working IPv6 connection and IPv4 otherwise, avoiding needless delays on hosts
+ whose only IPv6 addresses are link-local or loopback
+ (`#1230 `_)
- Added ``--anyio-mode`` command-line option as an alternative to the ``anyio_mode``
ini setting, and fix the pytest plugin's auto mode detection to recognize the mode
when set via either mechanism(e.g: ``pytest_asyncio``).
diff --git a/src/anyio/_core/_sockets.py b/src/anyio/_core/_sockets.py
index 14ee6e41e..ac178af3e 100644
--- a/src/anyio/_core/_sockets.py
+++ b/src/anyio/_core/_sockets.py
@@ -161,8 +161,9 @@ async def connect_tcp(
6555). If ``remote_host`` is a host name that resolves to multiple IP addresses,
each one is tried until one connection attempt succeeds. If the first attempt does
not connected within 250 milliseconds, a second attempt is started using the next
- address in the list, and so on. On IPv6 enabled systems, an IPv6 address (if
- available) is tried first.
+ address in the list, and so on. The addresses are tried in the order returned by
+ the resolver, which follows RFC 6724 destination address selection (IPv6 is
+ preferred when the host has a usable IPv6 connection).
When the connection has been established, a TLS handshake will be done if either
``ssl_context`` or ``tls_hostname`` is not ``None``, or if ``tls`` is ``True``.
@@ -233,19 +234,13 @@ async def try_connect(remote_host: str, event: Event) -> None:
target_host, remote_port, family=family, type=socket.SOCK_STREAM
)
- # Organize the list so that the first address is an IPv6 address (if available)
- # and the second one is an IPv4 addresses. The rest can be in whatever order.
- v6_found = v4_found = False
- target_addrs = []
- for af, *_, sa in gai_res:
- if af == socket.AF_INET6 and not v6_found:
- v6_found = True
- target_addrs.insert(0, (af, sa[0]))
- elif af == socket.AF_INET and not v4_found and v6_found:
- v4_found = True
- target_addrs.insert(1, (af, sa[0]))
- else:
- target_addrs.append((af, sa[0]))
+ # Use the addresses in the order returned by the resolver, which
+ # implements RFC 6724 destination address selection: IPv6 is preferred
+ # when the host has a usable IPv6 connection, and IPv4 comes first (or
+ # IPv6 is omitted entirely) when it does not. Reordering to blindly put
+ # IPv6 first caused unnecessary delays on hosts whose only IPv6
+ # addresses are link-local or loopback (see #1230).
+ target_addrs = [(af, sa[0]) for af, *_, sa in gai_res]
oserrors: list[OSError] = []
try:
diff --git a/tests/test_sockets.py b/tests/test_sockets.py
index f47567627..92aabef7c 100644
--- a/tests/test_sockets.py
+++ b/tests/test_sockets.py
@@ -129,6 +129,18 @@ def fake_getaddrinfo(*args: Any, **kwargs: Any) -> object:
monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)
+@pytest.fixture
+def fake_localhost_dns_ipv6_first(monkeypatch: MonkeyPatch) -> None:
+ def fake_getaddrinfo(*args: Any, **kwargs: Any) -> object:
+ # Make it return IPv6 addresses first, as a resolver does on hosts with
+ # a usable IPv6 connection
+ results = real_getaddrinfo(*args, **kwargs)
+ return sorted(results, key=lambda item: item[0], reverse=True)
+
+ real_getaddrinfo = socket.getaddrinfo
+ monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)
+
+
@pytest.fixture(
params=[
pytest.param(AddressFamily.AF_INET, id="ipv4"),
@@ -355,7 +367,11 @@ async def test_socket_options(
@pytest.mark.parametrize(
"local_addr, expected_client_addr",
[
- pytest.param("", "::1", id="dualstack"),
+ # With the resolver returning IPv4 first, the connection follows the
+ # resolver order (RFC 6724): IPv4 for the dual-stack server, with
+ # IPv6 still reached via the Happy Eyeballs fallback when the IPv4
+ # attempt fails (the "ipv6" case below).
+ pytest.param("", "::ffff:127.0.0.1", id="dualstack"),
pytest.param("127.0.0.1", "127.0.0.1", id="ipv4"),
pytest.param("::1", "::1", id="ipv6"),
],
@@ -389,6 +405,34 @@ def serve() -> None:
server_sock.close()
assert client_addr[0] == expected_client_addr
+ @skip_ipv6_mark
+ async def test_happy_eyeballs_prefers_ipv6_in_resolver_order(
+ self, fake_localhost_dns_ipv6_first: None
+ ) -> None:
+ # When the resolver returns IPv6 first (as it does on hosts with a
+ # usable IPv6 connection), connect_tcp honors that order and connects
+ # via IPv6 rather than blindly reordering. Refs: #1230
+ client_addr = None, None
+
+ def serve() -> None:
+ nonlocal client_addr
+ client, client_addr = server_sock.accept()
+ client.close()
+
+ server_sock = socket.socket(AddressFamily.AF_INET6)
+ server_sock.bind(("", 0))
+ server_sock.listen()
+ port = server_sock.getsockname()[1]
+ thread = Thread(target=serve, daemon=True)
+ thread.start()
+
+ async with await connect_tcp("localhost", port):
+ pass
+
+ thread.join()
+ server_sock.close()
+ assert client_addr[0] == "::1"
+
async def test_connect_tcp_with_local_port(
self,
server_sock: socket.socket,