Skip to content

Commit 8a8913b

Browse files
brettdhDreamsorcererpre-commit-ci[bot]bdraco
authored
Fixed failure to try next host after single-host connection timeout (#7368)
Co-authored-by: Sam Bull <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <[email protected]>
1 parent 803d818 commit 8a8913b

File tree

7 files changed

+123
-5
lines changed

7 files changed

+123
-5
lines changed

CHANGES/7342.breaking.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed failure to try next host after single-host connection timeout -- by :user:`brettdh`.
2+
3+
The default client :class:`aiohttp.ClientTimeout` params has changed to include a ``sock_connect`` timeout of 30 seconds so that this correct behavior happens by default.

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Boris Feld
6363
Borys Vorona
6464
Boyi Chen
6565
Brett Cannon
66+
Brett Higgins
6667
Brian Bouterse
6768
Brian C. Lane
6869
Brian Muller

aiohttp/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ class ClientTimeout:
214214

215215

216216
# 5 Minute default read timeout
217-
DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60)
217+
DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60, sock_connect=30)
218218

219219
# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
220220
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})

aiohttp/connector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1243,7 +1243,7 @@ async def _create_direct_connection(
12431243
req=req,
12441244
client_error=client_error,
12451245
)
1246-
except ClientConnectorError as exc:
1246+
except (ClientConnectorError, asyncio.TimeoutError) as exc:
12471247
last_exc = exc
12481248
aiohappyeyeballs.pop_addr_infos_interleave(addr_infos, self._interleave)
12491249
continue

docs/client_quickstart.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ Timeouts
408408
Timeout settings are stored in :class:`ClientTimeout` data structure.
409409

410410
By default *aiohttp* uses a *total* 300 seconds (5min) timeout, it means that the
411-
whole operation should finish in 5 minutes.
411+
whole operation should finish in 5 minutes. In order to allow time for DNS fallback,
412+
the default ``sock_connect`` timeout is 30 seconds.
412413

413414
The value could be overridden by *timeout* parameter for the session (specified in seconds)::
414415

docs/client_reference.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,14 @@ The client session supports the context manager protocol for self closing.
150150
higher.
151151

152152
:param timeout: a :class:`ClientTimeout` settings structure, 300 seconds (5min)
153-
total timeout by default.
153+
total timeout, 30 seconds socket connect timeout by default.
154154

155155
.. versionadded:: 3.3
156156

157+
.. versionchanged:: 3.10.9
158+
159+
The default value for the ``sock_connect`` timeout has been changed to 30 seconds.
160+
157161
:param bool auto_decompress: Automatically decompress response body (``True`` by default).
158162

159163
.. versionadded:: 2.3
@@ -882,7 +886,7 @@ certification chaining.
882886
.. versionadded:: 3.7
883887

884888
:param timeout: a :class:`ClientTimeout` settings structure, 300 seconds (5min)
885-
total timeout by default.
889+
total timeout, 30 seconds socket connect timeout by default.
886890

887891
:param loop: :ref:`event loop<asyncio-event-loop>`
888892
used for processing HTTP requests.

tests/test_connector.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,115 @@ async def create_connection(
10631063
established_connection.close()
10641064

10651065

1066+
@pytest.mark.parametrize(
1067+
("request_url"),
1068+
[
1069+
("http://mocked.host"),
1070+
("https://mocked.host"),
1071+
],
1072+
)
1073+
async def test_tcp_connector_multiple_hosts_one_timeout(
1074+
loop: asyncio.AbstractEventLoop,
1075+
request_url: str,
1076+
) -> None:
1077+
conn = aiohttp.TCPConnector()
1078+
1079+
ip1 = "192.168.1.1"
1080+
ip2 = "192.168.1.2"
1081+
ips = [ip1, ip2]
1082+
ips_tried = []
1083+
ips_success = []
1084+
timeout_error = False
1085+
connected = False
1086+
1087+
req = ClientRequest(
1088+
"GET",
1089+
URL(request_url),
1090+
loop=loop,
1091+
)
1092+
1093+
async def _resolve_host(
1094+
host: str, port: int, traces: object = None
1095+
) -> List[ResolveResult]:
1096+
return [
1097+
{
1098+
"hostname": host,
1099+
"host": ip,
1100+
"port": port,
1101+
"family": socket.AF_INET6 if ":" in ip else socket.AF_INET,
1102+
"proto": 0,
1103+
"flags": socket.AI_NUMERICHOST,
1104+
}
1105+
for ip in ips
1106+
]
1107+
1108+
async def start_connection(
1109+
addr_infos: Sequence[AddrInfoType],
1110+
*,
1111+
interleave: Optional[int] = None,
1112+
**kwargs: object,
1113+
) -> socket.socket:
1114+
nonlocal timeout_error
1115+
1116+
addr_info = addr_infos[0]
1117+
addr_info_addr = addr_info[-1]
1118+
1119+
ip = addr_info_addr[0]
1120+
ips_tried.append(ip)
1121+
1122+
if ip == ip1:
1123+
timeout_error = True
1124+
raise asyncio.TimeoutError
1125+
1126+
if ip == ip2:
1127+
mock_socket = mock.create_autospec(
1128+
socket.socket, spec_set=True, instance=True
1129+
)
1130+
mock_socket.getpeername.return_value = addr_info_addr
1131+
return mock_socket # type: ignore[no-any-return]
1132+
1133+
assert False
1134+
1135+
async def create_connection(
1136+
*args: object, sock: Optional[socket.socket] = None, **kwargs: object
1137+
) -> Tuple[ResponseHandler, ResponseHandler]:
1138+
nonlocal connected
1139+
1140+
assert isinstance(sock, socket.socket)
1141+
addr_info = sock.getpeername()
1142+
ip = addr_info[0]
1143+
ips_success.append(ip)
1144+
connected = True
1145+
1146+
# Close the socket since we are not actually connecting
1147+
# and we don't want to leak it.
1148+
sock.close()
1149+
tr = create_mocked_conn(loop)
1150+
pr = create_mocked_conn(loop)
1151+
return tr, pr
1152+
1153+
with mock.patch.object(
1154+
conn, "_resolve_host", autospec=True, spec_set=True, side_effect=_resolve_host
1155+
), mock.patch.object(
1156+
conn._loop,
1157+
"create_connection",
1158+
autospec=True,
1159+
spec_set=True,
1160+
side_effect=create_connection,
1161+
), mock.patch(
1162+
"aiohttp.connector.aiohappyeyeballs.start_connection", start_connection
1163+
):
1164+
established_connection = await conn.connect(req, [], ClientTimeout())
1165+
1166+
assert ips_tried == ips
1167+
assert ips_success == [ip2]
1168+
1169+
assert timeout_error
1170+
assert connected
1171+
1172+
established_connection.close()
1173+
1174+
10661175
async def test_tcp_connector_resolve_host(loop: asyncio.AbstractEventLoop) -> None:
10671176
conn = aiohttp.TCPConnector(use_dns_cache=True)
10681177

0 commit comments

Comments
 (0)