From b9d776df4848565f38c5ebe42d49030b3194a1a0 Mon Sep 17 00:00:00 2001
From: Brett Higgins <brett.higgins@gmail.com>
Date: Mon, 17 Jul 2023 10:50:57 -0400
Subject: [PATCH] Fixed failure to try next host after single-host connection
 timeout

Also updated the default ``ClientTimeout`` params to include ``sock_connect``
so that this correct behavior happens by default.
---
 CHANGES/7342.bugfix        |  3 ++
 CONTRIBUTORS.txt           |  1 +
 aiohttp/client.py          |  2 +-
 aiohttp/connector.py       |  2 +-
 docs/client_quickstart.rst |  3 +-
 docs/client_reference.rst  |  4 +-
 tests/test_connector.py    | 75 ++++++++++++++++++++++++++++++++++++++
 7 files changed, 85 insertions(+), 5 deletions(-)
 create mode 100644 CHANGES/7342.bugfix

diff --git a/CHANGES/7342.bugfix b/CHANGES/7342.bugfix
new file mode 100644
index 00000000000..fa411657e0c
--- /dev/null
+++ b/CHANGES/7342.bugfix
@@ -0,0 +1,3 @@
+Fixed failure to try next host after single-host connection timeout.
+Also updated the default ``ClientTimeout`` params to include ``sock_connect``
+so that this correct behavior happens by default.
diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt
index 0cb642bf466..25bf3ee7792 100644
--- a/CONTRIBUTORS.txt
+++ b/CONTRIBUTORS.txt
@@ -60,6 +60,7 @@ Boris Feld
 Borys Vorona
 Boyi Chen
 Brett Cannon
+Brett Higgins
 Brian Bouterse
 Brian C. Lane
 Brian Muller
diff --git a/aiohttp/client.py b/aiohttp/client.py
index 9050cc4120c..777722e2c27 100644
--- a/aiohttp/client.py
+++ b/aiohttp/client.py
@@ -157,7 +157,7 @@ class ClientTimeout:
 
 
 # 5 Minute default read timeout
-DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60)
+DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60, sock_connect=30)
 
 _RetType = TypeVar("_RetType")
 
diff --git a/aiohttp/connector.py b/aiohttp/connector.py
index 3d9f7b59f7b..dcaeb72b6a5 100644
--- a/aiohttp/connector.py
+++ b/aiohttp/connector.py
@@ -1131,7 +1131,7 @@ def drop_exception(fut: "asyncio.Future[List[Dict[str, Any]]]") -> None:
                     req=req,
                     client_error=client_error,
                 )
-            except ClientConnectorError as exc:
+            except (ClientConnectorError, asyncio.TimeoutError) as exc:
                 last_exc = exc
                 continue
 
diff --git a/docs/client_quickstart.rst b/docs/client_quickstart.rst
index 92334a5f4b4..cb5a4f2ce3f 100644
--- a/docs/client_quickstart.rst
+++ b/docs/client_quickstart.rst
@@ -407,7 +407,8 @@ Timeouts
 Timeout settings are stored in :class:`ClientTimeout` data structure.
 
 By default *aiohttp* uses a *total* 300 seconds (5min) timeout, it means that the
-whole operation should finish in 5 minutes.
+whole operation should finish in 5 minutes. In order to allow time for DNS fallback,
+the default ``sock_connect`` timeout is 30 seconds.
 
 The value could be overridden by *timeout* parameter for the session (specified in seconds)::
 
diff --git a/docs/client_reference.rst b/docs/client_reference.rst
index 407030dce14..464cc06c2c8 100644
--- a/docs/client_reference.rst
+++ b/docs/client_reference.rst
@@ -135,7 +135,7 @@ The client session supports the context manager protocol for self closing.
       higher.
 
    :param timeout: a :class:`ClientTimeout` settings structure, 300 seconds (5min)
-        total timeout by default.
+        total timeout, 30 seconds socket connect timeout by default.
 
       .. versionadded:: 3.3
 
@@ -905,7 +905,7 @@ certification chaining.
       .. versionadded:: 3.7
 
    :param timeout: a :class:`ClientTimeout` settings structure, 300 seconds (5min)
-        total timeout by default.
+        total timeout, 30 seconds socket connect timeout by default.
 
    :param loop: :ref:`event loop<asyncio-event-loop>`
                 used for processing HTTP requests.
diff --git a/tests/test_connector.py b/tests/test_connector.py
index 9ef71882f87..8a344a82953 100644
--- a/tests/test_connector.py
+++ b/tests/test_connector.py
@@ -669,6 +669,81 @@ def get_extra_info(param):
     established_connection.close()
 
 
+async def test_tcp_connector_multiple_hosts_one_timeout(loop: Any) -> None:
+    conn = aiohttp.TCPConnector()
+
+    ip1 = "192.168.1.1"
+    ip2 = "192.168.1.2"
+    ips = [ip1, ip2]
+    ips_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_INET,
+                "proto": 0,
+                "flags": socket.AI_NUMERICHOST,
+            }
+            for ip in ips
+        ]
+
+    conn._resolve_host = _resolve_host
+
+    timeout_error = False
+    connected = False
+
+    async def create_connection(*args, **kwargs):
+        nonlocal timeout_error, connected
+
+        ip = args[1]
+
+        ips_tried.append(ip)
+
+        if ip == ip1:
+            timeout_error = True
+            raise asyncio.TimeoutError
+
+        if ip == ip2:
+            connected = True
+            tr = create_mocked_conn(loop)
+            pr = create_mocked_conn(loop)
+
+            def get_extra_info(param):
+                if param == "sslcontext":
+                    return True
+
+                if param == "ssl_object":
+                    s = create_mocked_conn(loop)
+                    s.getpeercert.return_value = b"foo"
+                    return s
+
+                assert False
+
+            tr.get_extra_info = get_extra_info
+            return tr, pr
+
+        assert False
+
+    conn._loop.create_connection = create_connection
+
+    established_connection = await conn.connect(req, [], ClientTimeout())
+    assert ips == ips_tried
+
+    assert timeout_error
+    assert connected
+
+    established_connection.close()
+
+
 async def test_tcp_connector_resolve_host(loop: Any) -> None:
     conn = aiohttp.TCPConnector(use_dns_cache=True)