Repository: aio-libs/aiohttp

Issue:
Support Happy Eyeballs from Python 3.8
Starting from Python 3.8 asyncio supports https://tools.ietf.org/html/rfc6555

It allows working better in dual-host configurations.
It would be nice to support it by aiohttp if the feature is available by Python version.
Perhaps passing `happy_eyeballs_delay` and `interleave` to `loop.create_connection()` is enough.

A volunteer is welcome!

P.S.
Ooops, parameters are even not documented by asyncio docs, need to fix it.


Discussion:
> Perhaps passing `happy_eyeballs_delay` and `interleave` to `loop.create_connection()` is enough.

Unfortunately, this is not enough and has no effect because `host` passed to `loop.create_connection()` is always an IP address. That happens because aiohttp translates domain names into IP addresses before calling the method.
https://github.com/aio-libs/aiohttp/blob/8c25b44770e56ee5636d16d65687a732e1545c5a/aiohttp/connector.py#L969-L1025

---

An alternative is implementing the Happy Eyeballs algorithm in the aiohttp library reusing `asyncio.staggered.staggered_race()`.

I would create a method similar to [`loop._connect_sock`](https://github.com/python/cpython/blob/cc02b4f2e810ab524d845daa18bc94df5b092dd8/Lib/asyncio/base_events.py#L930-L964) and use logic similar to [this one](https://github.com/python/cpython/blob/cc02b4f2e810ab524d845daa18bc94df5b092dd8/Lib/asyncio/base_events.py#L1036-L1050) right after determining IP addresses.

Then I'd call `self._wrap_create_connection()` (and subsequently `loop.create_connection()`) with a connected socket as `sock` instead of `family`, `proto`, `flags` and `local_addr`.

There would be a couple of problems though:
* this check would be performed only once for a connection determined by Happy Eyeballs and if it failed, it would not try connecting to next IP address as it does at the moment https://github.com/aio-libs/aiohttp/blob/8c25b44770e56ee5636d16d65687a732e1545c5a/aiohttp/connector.py#L1015-L1023
* `sock_connect` timeout that limits runtime of `loop.create_connection()` currently. https://github.com/aio-libs/aiohttp/blob/8c25b44770e56ee5636d16d65687a732e1545c5a/aiohttp/connector.py#L945-L946 The runtime would be significantly smaller because the method would use an already connected socket. Probably, we'd need to split the timeout into two separate ones.
ESPHome has the same/similar issue https://github.com/esphome/aioesphomeapi/pull/233

We need a happy eyeballs implementation that supports passing in a list of resolved addresses that works for python 3.8 for aiohttp so we can solve https://github.com/aio-libs/aiohttp/issues/4451#issuecomment-786890416

Ideally we build a lib for that which could be used for both esphome and aiohttp so we don't have to reinvent the wheel as I'm sure other places need it as well.

The 3.8 support will be a bit of a pain though since we need use a conditional import with <https://pypi.org/project/async-stagger/> as `asyncio.staggered.staggered_race` is only available on newer python which makes the whole thing more complex and would make sense to make it in a new library

If I find the time to build something for esphome, I'll do it as a new library with a compatible license in case there is a chance we can reuse it here.
I've got three projects I'm working on that need this so I think my holiday project is going to be to build a permissive licensed library that they can all use
```python
        for hinfo in hosts:
            host = hinfo["host"]
            port = hinfo["port"]

            # Strip trailing dots, certificates contain FQDN without dots.
            # See https://github.com/aio-libs/aiohttp/issues/3636
            server_hostname = (
                (req.server_hostname or hinfo["hostname"]).rstrip(".")
                if sslcontext
                else None
            )

            try:
                transp, proto = await self._wrap_create_connection(
                    self._factory,
                    host,
                    port,
                    timeout=timeout,
                    ssl=sslcontext,
                    family=hinfo["family"],
                    proto=hinfo["proto"],
                    flags=hinfo["flags"],
                    server_hostname=server_hostname,
                    local_addr=self._local_addr,
                    req=req,
                    client_error=client_error,
                )
```

If we swap out `_wrap_create_connection` with something like
`self._create_happy_eyeballs_connection` and still do a outer loop with all the hosts but the inner loop pops them out of the happy eyeballs connect logic once we reject one via the `if req.is_ssl() and fingerprint:` branch it should behave mostly the same sans the timing.

Unfortunately most of `loop.create_connection` needs to be reimplemented because it expects to get unresolved names and we have resolved names

Worse it looks like we have to resolve local addrs as well since technically that works now


I sliced up the stdlib create_connection into a new library https://github.com/bdraco/aiohappyeyeballs to allow connecting with Happy Eyeballs when you already have a list of addrinfo and not a DNS name.

Resolving patch:
diff --git a/aiohttp/connector.py b/aiohttp/connector.py
--- a/aiohttp/connector.py
+++ b/aiohttp/connector.py
@@ -3,6 +3,7 @@
 import functools
 import logging
 import random
+import socket
 import sys
 import traceback
 import warnings
@@ -31,6 +32,8 @@
     cast,
 )
 
+import aiohappyeyeballs
+
 from . import hdrs, helpers
 from .abc import AbstractResolver
 from .client_exceptions import (
@@ -730,6 +733,10 @@ class TCPConnector(BaseConnector):
     limit_per_host - Number of simultaneous connections to one host.
     enable_cleanup_closed - Enables clean-up closed ssl transports.
                             Disabled by default.
+    happy_eyeballs_delay - This is the “Connection Attempt Delay”
+                           as defined in RFC 8305. To disable
+                           the happy eyeballs algorithm, set to None.
+    interleave - “First Address Family Count” as defined in RFC 8305
     loop - Optional event loop.
     """
 
@@ -748,6 +755,8 @@ def __init__(
         limit_per_host: int = 0,
         enable_cleanup_closed: bool = False,
         timeout_ceil_threshold: float = 5,
+        happy_eyeballs_delay: Optional[float] = 0.25,
+        interleave: Optional[int] = None,
     ) -> None:
         super().__init__(
             keepalive_timeout=keepalive_timeout,
@@ -772,7 +781,9 @@ def __init__(
         self._cached_hosts = _DNSCacheTable(ttl=ttl_dns_cache)
         self._throttle_dns_events: Dict[Tuple[str, int], EventResultOrError] = {}
         self._family = family
-        self._local_addr = local_addr
+        self._local_addr_infos = aiohappyeyeballs.addr_to_addr_infos(local_addr)
+        self._happy_eyeballs_delay = happy_eyeballs_delay
+        self._interleave = interleave
 
     def _close_immediately(self) -> List["asyncio.Future[None]"]:
         for ev in self._throttle_dns_events.values():
@@ -956,6 +967,7 @@ def _get_fingerprint(self, req: ClientRequest) -> Optional["Fingerprint"]:
     async def _wrap_create_connection(
         self,
         *args: Any,
+        addr_infos: List[aiohappyeyeballs.AddrInfoType],
         req: ClientRequest,
         timeout: "ClientTimeout",
         client_error: Type[Exception] = ClientConnectorError,
@@ -965,7 +977,14 @@ async def _wrap_create_connection(
             async with ceil_timeout(
                 timeout.sock_connect, ceil_threshold=timeout.ceil_threshold
             ):
-                return await self._loop.create_connection(*args, **kwargs)
+                sock = await aiohappyeyeballs.start_connection(
+                    addr_infos=addr_infos,
+                    local_addr_infos=self._local_addr_infos,
+                    happy_eyeballs_delay=self._happy_eyeballs_delay,
+                    interleave=self._interleave,
+                    loop=self._loop,
+                )
+                return await self._loop.create_connection(*args, **kwargs, sock=sock)
         except cert_errors as exc:
             raise ClientConnectorCertificateError(req.connection_key, exc) from exc
         except ssl_errors as exc:
@@ -1076,6 +1095,27 @@ async def _start_tls_connection(
 
         return tls_transport, tls_proto
 
+    def _convert_hosts_to_addr_infos(
+        self, hosts: List[Dict[str, Any]]
+    ) -> List[aiohappyeyeballs.AddrInfoType]:
+        """Converts the list of hosts to a list of addr_infos.
+
+        The list of hosts is the result of a DNS lookup. The list of
+        addr_infos is the result of a call to `socket.getaddrinfo()`.
+        """
+        addr_infos: List[aiohappyeyeballs.AddrInfoType] = []
+        for hinfo in hosts:
+            host = hinfo["host"]
+            is_ipv6 = ":" in host
+            family = socket.AF_INET6 if is_ipv6 else socket.AF_INET
+            if self._family and self._family != family:
+                continue
+            addr = (host, hinfo["port"], 0, 0) if is_ipv6 else (host, hinfo["port"])
+            addr_infos.append(
+                (family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", addr)
+            )
+        return addr_infos
+
     async def _create_direct_connection(
         self,
         req: ClientRequest,
@@ -1120,36 +1160,27 @@ def drop_exception(fut: "asyncio.Future[List[Dict[str, Any]]]") -> None:
             raise ClientConnectorError(req.connection_key, exc) from exc
 
         last_exc: Optional[Exception] = None
-
-        for hinfo in hosts:
-            host = hinfo["host"]
-            port = hinfo["port"]
-
+        addr_infos = self._convert_hosts_to_addr_infos(hosts)
+        while addr_infos:
             # Strip trailing dots, certificates contain FQDN without dots.
             # See https://github.com/aio-libs/aiohttp/issues/3636
             server_hostname = (
-                (req.server_hostname or hinfo["hostname"]).rstrip(".")
-                if sslcontext
-                else None
+                (req.server_hostname or host).rstrip(".") if sslcontext else None
             )
 
             try:
                 transp, proto = await self._wrap_create_connection(
                     self._factory,
-                    host,
-                    port,
                     timeout=timeout,
                     ssl=sslcontext,
-                    family=hinfo["family"],
-                    proto=hinfo["proto"],
-                    flags=hinfo["flags"],
+                    addr_infos=addr_infos,
                     server_hostname=server_hostname,
-                    local_addr=self._local_addr,
                     req=req,
                     client_error=client_error,
                 )
             except ClientConnectorError as exc:
                 last_exc = exc
+                aiohappyeyeballs.pop_addr_infos_interleave(addr_infos, self._interleave)
                 continue
 
             if req.is_ssl() and fingerprint:
@@ -1160,6 +1191,10 @@ def drop_exception(fut: "asyncio.Future[List[Dict[str, Any]]]") -> None:
                     if not self._cleanup_closed_disabled:
                         self._cleanup_closed_transports.append(transp)
                     last_exc = exc
+                    # Remove the bad peer from the list of addr_infos
+                    sock: socket.socket = transp.get_extra_info("socket")
+                    bad_peer = sock.getpeername()
+                    aiohappyeyeballs.remove_addr_infos(addr_infos, bad_peer)
                     continue
 
             return transp, proto


Test patch:
diff --git a/requirements/test.txt b/requirements/test.txt
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -6,6 +6,8 @@
 #
 aiodns==3.1.1 ; sys_platform == "linux" or sys_platform == "darwin"
     # via -r requirements/runtime-deps.in
+aiohappyeyeballs==2.3.0
+    # via -r requirements/runtime-deps.in
 aiosignal==1.3.1
     # via -r requirements/runtime-deps.in
 async-timeout==4.0.3 ; python_version < "3.11"
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -208,3 +208,13 @@ def netrc_contents(
     monkeypatch.setenv("NETRC", str(netrc_file_path))
 
     return netrc_file_path
+
+
+@pytest.fixture
+def start_connection():
+    with mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    ) as start_connection_mock:
+        yield start_connection_mock
diff --git a/tests/test_connector.py b/tests/test_connector.py
--- a/tests/test_connector.py
+++ b/tests/test_connector.py
@@ -11,10 +11,11 @@
 import uuid
 from collections import deque
 from contextlib import closing
-from typing import Any, Optional
+from typing import Any, List, Optional
 from unittest import mock
 
 import pytest
+from aiohappyeyeballs import AddrInfoType
 from yarl import URL
 
 import aiohttp
@@ -538,7 +539,9 @@ async def test__drop_acquire_per_host3(loop: Any) -> None:
     assert conn._acquired_per_host[123] == {789}
 
 
-async def test_tcp_connector_certificate_error(loop: Any) -> None:
+async def test_tcp_connector_certificate_error(
+    loop: Any, start_connection: mock.AsyncMock
+) -> None:
     req = ClientRequest("GET", URL("https://127.0.0.1:443"), loop=loop)
 
     async def certificate_error(*args, **kwargs):
@@ -555,7 +558,9 @@ async def certificate_error(*args, **kwargs):
     assert isinstance(ctx.value, aiohttp.ClientSSLError)
 
 
-async def test_tcp_connector_server_hostname_default(loop: Any) -> None:
+async def test_tcp_connector_server_hostname_default(
+    loop: Any, start_connection: mock.AsyncMock
+) -> None:
     conn = aiohttp.TCPConnector()
 
     with mock.patch.object(
@@ -569,7 +574,9 @@ async def test_tcp_connector_server_hostname_default(loop: Any) -> None:
             assert create_connection.call_args.kwargs["server_hostname"] == "127.0.0.1"
 
 
-async def test_tcp_connector_server_hostname_override(loop: Any) -> None:
+async def test_tcp_connector_server_hostname_override(
+    loop: Any, start_connection: mock.AsyncMock
+) -> None:
     conn = aiohttp.TCPConnector()
 
     with mock.patch.object(
@@ -594,6 +601,7 @@ async def test_tcp_connector_multiple_hosts_errors(loop: Any) -> None:
     ip4 = "192.168.1.4"
     ip5 = "192.168.1.5"
     ips = [ip1, ip2, ip3, ip4, ip5]
+    addrs_tried = []
     ips_tried = []
 
     fingerprint = hashlib.sha256(b"foo").digest()
@@ -623,11 +631,24 @@ async def _resolve_host(host, port, traces=None):
     os_error = certificate_error = ssl_error = fingerprint_error = False
     connected = False
 
+    async def start_connection(*args, **kwargs):
+        addr_infos: List[AddrInfoType] = kwargs["addr_infos"]
+
+        first_addr_info = addr_infos[0]
+        first_addr_info_addr = first_addr_info[-1]
+        addrs_tried.append(first_addr_info_addr)
+
+        mock_socket = mock.create_autospec(socket.socket, spec_set=True, instance=True)
+        mock_socket.getpeername.return_value = first_addr_info_addr
+        return mock_socket
+
     async def create_connection(*args, **kwargs):
         nonlocal os_error, certificate_error, ssl_error, fingerprint_error
         nonlocal connected
 
-        ip = args[1]
+        sock = kwargs["sock"]
+        addr_info = sock.getpeername()
+        ip = addr_info[0]
 
         ips_tried.append(ip)
 
@@ -644,6 +665,12 @@ async def create_connection(*args, **kwargs):
             raise ssl.SSLError
 
         if ip == ip4:
+            sock: socket.socket = kwargs["sock"]
+
+            # Close the socket since we are not actually connecting
+            # and we don't want to leak it.
+            sock.close()
+
             fingerprint_error = True
             tr = create_mocked_conn(loop)
             pr = create_mocked_conn(loop)
@@ -660,12 +687,21 @@ def get_extra_info(param):
                 if param == "peername":
                     return ("192.168.1.5", 12345)
 
+                if param == "socket":
+                    return sock
+
                 assert False, param
 
             tr.get_extra_info = get_extra_info
             return tr, pr
 
         if ip == ip5:
+            sock: socket.socket = kwargs["sock"]
+
+            # Close the socket since we are not actually connecting
+            # and we don't want to leak it.
+            sock.close()
+
             connected = True
             tr = create_mocked_conn(loop)
             pr = create_mocked_conn(loop)
@@ -688,8 +724,13 @@ def get_extra_info(param):
 
     conn._loop.create_connection = create_connection
 
-    established_connection = await conn.connect(req, [], ClientTimeout())
-    assert ips == ips_tried
+    with mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection", start_connection
+    ):
+        established_connection = await conn.connect(req, [], ClientTimeout())
+
+    assert ips_tried == ips
+    assert addrs_tried == [(ip, 443) for ip in ips]
 
     assert os_error
     assert certificate_error
@@ -700,6 +741,212 @@ def get_extra_info(param):
     established_connection.close()
 
 
+@pytest.mark.parametrize(
+    ("happy_eyeballs_delay"),
+    [0.1, 0.25, None],
+)
+async def test_tcp_connector_happy_eyeballs(
+    loop: Any, happy_eyeballs_delay: Optional[float]
+) -> None:
+    conn = aiohttp.TCPConnector(happy_eyeballs_delay=happy_eyeballs_delay)
+
+    ip1 = "dead::beef::"
+    ip2 = "192.168.1.1"
+    ips = [ip1, ip2]
+    addrs_tried = []
+
+    req = ClientRequest(
+        "GET",
+        URL("https://mocked.host"),
+        loop=loop,
+    )
+
+    async def _resolve_host(host, port, traces=None):
+        return [
+            {
+                "hostname": host,
+                "host": ip,
+                "port": port,
+                "family": socket.AF_INET6 if ":" in ip else socket.AF_INET,
+                "proto": 0,
+                "flags": socket.AI_NUMERICHOST,
+            }
+            for ip in ips
+        ]
+
+    conn._resolve_host = _resolve_host
+
+    os_error = False
+    connected = False
+
+    async def sock_connect(*args, **kwargs):
+        addr = args[1]
+        nonlocal os_error
+
+        addrs_tried.append(addr)
+
+        if addr[0] == ip1:
+            os_error = True
+            raise OSError
+
+    async def create_connection(*args, **kwargs):
+        sock: socket.socket = kwargs["sock"]
+
+        # Close the socket since we are not actually connecting
+        # and we don't want to leak it.
+        sock.close()
+
+        nonlocal connected
+        connected = True
+        tr = create_mocked_conn(loop)
+        pr = create_mocked_conn(loop)
+        return tr, pr
+
+    conn._loop.sock_connect = sock_connect
+    conn._loop.create_connection = create_connection
+
+    established_connection = await conn.connect(req, [], ClientTimeout())
+
+    assert addrs_tried == [(ip1, 443, 0, 0), (ip2, 443)]
+
+    assert os_error
+    assert connected
+
+    established_connection.close()
+
+
+async def test_tcp_connector_interleave(loop: Any) -> None:
+    conn = aiohttp.TCPConnector(interleave=2)
+
+    ip1 = "192.168.1.1"
+    ip2 = "192.168.1.2"
+    ip3 = "dead::beef::"
+    ip4 = "aaaa::beef::"
+    ip5 = "192.168.1.5"
+    ips = [ip1, ip2, ip3, ip4, ip5]
+    success_ips = []
+    interleave = None
+
+    req = ClientRequest(
+        "GET",
+        URL("https://mocked.host"),
+        loop=loop,
+    )
+
+    async def _resolve_host(host, port, traces=None):
+        return [
+            {
+                "hostname": host,
+                "host": ip,
+                "port": port,
+                "family": socket.AF_INET6 if ":" in ip else socket.AF_INET,
+                "proto": 0,
+                "flags": socket.AI_NUMERICHOST,
+            }
+            for ip in ips
+        ]
+
+    conn._resolve_host = _resolve_host
+
+    async def start_connection(*args, **kwargs):
+        nonlocal interleave
+        addr_infos: List[AddrInfoType] = kwargs["addr_infos"]
+        interleave = kwargs["interleave"]
+        # Mock the 4th host connecting successfully
+        fourth_addr_info = addr_infos[3]
+        fourth_addr_info_addr = fourth_addr_info[-1]
+        mock_socket = mock.create_autospec(socket.socket, spec_set=True, instance=True)
+        mock_socket.getpeername.return_value = fourth_addr_info_addr
+        return mock_socket
+
+    async def create_connection(*args, **kwargs):
+        sock = kwargs["sock"]
+        addr_info = sock.getpeername()
+        ip = addr_info[0]
+
+        success_ips.append(ip)
+
+        sock: socket.socket = kwargs["sock"]
+        # Close the socket since we are not actually connecting
+        # and we don't want to leak it.
+        sock.close()
+        tr = create_mocked_conn(loop)
+        pr = create_mocked_conn(loop)
+        return tr, pr
+
+    conn._loop.create_connection = create_connection
+
+    with mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection", start_connection
+    ):
+        established_connection = await conn.connect(req, [], ClientTimeout())
+
+    assert success_ips == [ip4]
+    assert interleave == 2
+    established_connection.close()
+
+
+async def test_tcp_connector_family_is_respected(loop: Any) -> None:
+    conn = aiohttp.TCPConnector(family=socket.AF_INET)
+
+    ip1 = "dead::beef::"
+    ip2 = "192.168.1.1"
+    ips = [ip1, ip2]
+    addrs_tried = []
+
+    req = ClientRequest(
+        "GET",
+        URL("https://mocked.host"),
+        loop=loop,
+    )
+
+    async def _resolve_host(host, port, traces=None):
+        return [
+            {
+                "hostname": host,
+                "host": ip,
+                "port": port,
+                "family": socket.AF_INET6 if ":" in ip else socket.AF_INET,
+                "proto": 0,
+                "flags": socket.AI_NUMERICHOST,
+            }
+            for ip in ips
+        ]
+
+    conn._resolve_host = _resolve_host
+    connected = False
+
+    async def sock_connect(*args, **kwargs):
+        addr = args[1]
+        addrs_tried.append(addr)
+
+    async def create_connection(*args, **kwargs):
+        sock: socket.socket = kwargs["sock"]
+
+        # Close the socket since we are not actually connecting
+        # and we don't want to leak it.
+        sock.close()
+
+        nonlocal connected
+        connected = True
+        tr = create_mocked_conn(loop)
+        pr = create_mocked_conn(loop)
+        return tr, pr
+
+    conn._loop.sock_connect = sock_connect
+    conn._loop.create_connection = create_connection
+
+    established_connection = await conn.connect(req, [], ClientTimeout())
+
+    # We should only try the IPv4 address since we specified
+    # the family to be AF_INET
+    assert addrs_tried == [(ip2, 443)]
+
+    assert connected
+
+    established_connection.close()
+
+
 async def test_tcp_connector_resolve_host(loop: Any) -> None:
     conn = aiohttp.TCPConnector(use_dns_cache=True)
 
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
--- a/tests/test_proxy.py
+++ b/tests/test_proxy.py
@@ -37,7 +37,12 @@ def tearDown(self) -> None:
         gc.collect()
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_connect(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_connect(self, start_connection: Any, ClientRequestMock: Any) -> None:
         req = ClientRequest(
             "GET",
             URL("http://www.python.org"),
@@ -51,7 +56,18 @@ async def make_conn():
             return aiohttp.TCPConnector()
 
         connector = self.loop.run_until_complete(make_conn())
-        connector._resolve_host = make_mocked_coro([mock.MagicMock()])
+        connector._resolve_host = make_mocked_coro(
+            [
+                {
+                    "hostname": "hostname",
+                    "host": "127.0.0.1",
+                    "port": 80,
+                    "family": socket.AF_INET,
+                    "proto": 0,
+                    "flags": 0,
+                }
+            ]
+        )
 
         proto = mock.Mock(
             **{
@@ -78,7 +94,12 @@ async def make_conn():
         conn.close()
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_proxy_headers(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_headers(self, start_connection: Any, ClientRequestMock: Any) -> None:
         req = ClientRequest(
             "GET",
             URL("http://www.python.org"),
@@ -93,7 +114,18 @@ async def make_conn():
             return aiohttp.TCPConnector()
 
         connector = self.loop.run_until_complete(make_conn())
-        connector._resolve_host = make_mocked_coro([mock.MagicMock()])
+        connector._resolve_host = make_mocked_coro(
+            [
+                {
+                    "hostname": "hostname",
+                    "host": "127.0.0.1",
+                    "port": 80,
+                    "family": socket.AF_INET,
+                    "proto": 0,
+                    "flags": 0,
+                }
+            ]
+        )
 
         proto = mock.Mock(
             **{
@@ -119,7 +151,12 @@ async def make_conn():
 
         conn.close()
 
-    def test_proxy_auth(self) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_auth(self, start_connection: Any) -> None:
         with self.assertRaises(ValueError) as ctx:
             ClientRequest(
                 "GET",
@@ -133,11 +170,16 @@ def test_proxy_auth(self) -> None:
             "proxy_auth must be None or BasicAuth() tuple",
         )
 
-    def test_proxy_dns_error(self) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_dns_error(self, start_connection: Any) -> None:
         async def make_conn():
             return aiohttp.TCPConnector()
 
-        connector = self.loop.run_until_complete(make_conn())
+        connector: aiohttp.TCPConnector = self.loop.run_until_complete(make_conn())
         connector._resolve_host = make_mocked_coro(
             raise_exception=OSError("dont take it serious")
         )
@@ -156,7 +198,12 @@ async def make_conn():
         self.assertEqual(req.url.path, "/")
         self.assertEqual(dict(req.headers), expected_headers)
 
-    def test_proxy_connection_error(self) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_connection_error(self, start_connection: Any) -> None:
         async def make_conn():
             return aiohttp.TCPConnector()
 
@@ -189,7 +236,14 @@ async def make_conn():
             )
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_proxy_server_hostname_default(self, ClientRequestMock) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_server_hostname_default(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -249,7 +303,14 @@ async def make_conn():
         self.loop.run_until_complete(req.close())
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_proxy_server_hostname_override(self, ClientRequestMock) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_proxy_server_hostname_override(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET",
             URL("http://proxy.example.com"),
@@ -313,7 +374,12 @@ async def make_conn():
         self.loop.run_until_complete(req.close())
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect(self, start_connection: Any, ClientRequestMock: Any) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -373,7 +439,14 @@ async def make_conn():
         self.loop.run_until_complete(req.close())
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect_certificate_error(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect_certificate_error(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -427,7 +500,14 @@ async def make_conn():
             )
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect_ssl_error(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect_ssl_error(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -483,7 +563,14 @@ async def make_conn():
             )
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect_http_proxy_error(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect_http_proxy_error(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -542,7 +629,14 @@ async def make_conn():
         self.loop.run_until_complete(req.close())
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect_resp_start_error(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect_resp_start_error(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -595,7 +689,12 @@ async def make_conn():
             )
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_request_port(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_request_port(self, start_connection: Any, ClientRequestMock: Any) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -653,7 +752,14 @@ def test_proxy_auth_property_default(self) -> None:
         self.assertIsNone(req.proxy_auth)
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_connect_pass_ssl_context(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_connect_pass_ssl_context(
+        self, start_connection: Any, ClientRequestMock: Any
+    ) -> None:
         proxy_req = ClientRequest(
             "GET", URL("http://proxy.example.com"), loop=self.loop
         )
@@ -721,7 +827,12 @@ async def make_conn():
         self.loop.run_until_complete(req.close())
 
     @mock.patch("aiohttp.connector.ClientRequest")
-    def test_https_auth(self, ClientRequestMock: Any) -> None:
+    @mock.patch(
+        "aiohttp.connector.aiohappyeyeballs.start_connection",
+        autospec=True,
+        spec_set=True,
+    )
+    def test_https_auth(self, start_connection: Any, ClientRequestMock: Any) -> None:
         proxy_req = ClientRequest(
             "GET",
             URL("http://proxy.example.com"),