diff --git a/CHANGES/7297.feature b/CHANGES/7297.feature new file mode 100644 index 00000000000..91d769a4b32 --- /dev/null +++ b/CHANGES/7297.feature @@ -0,0 +1 @@ +Added a feature to retry closed connections automatically for idempotent methods. -- by :user:`Dreamsorcerer` diff --git a/aiohttp/client.py b/aiohttp/client.py index f4323a9935b..d08211bd00e 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -167,6 +167,9 @@ class ClientTimeout: # 5 Minute default read timeout DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60) +# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2 +IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"}) + _RetType = TypeVar("_RetType") _CharsetResolver = Callable[[ClientResponse, bytes], str] @@ -507,6 +510,8 @@ async def _request( timer = tm.timer() try: with timer: + # https://www.rfc-editor.org/rfc/rfc9112.html#name-retrying-requests + retry_persistent_connection = method in IDEMPOTENT_METHODS while True: url, auth_from_url = strip_auth_from_url(url) if auth and auth_from_url: @@ -614,6 +619,11 @@ async def _request( except BaseException: conn.close() raise + except (ClientOSError, ServerDisconnectedError): + if retry_persistent_connection: + retry_persistent_connection = False + continue + raise except ClientError: raise except OSError as exc: diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 585085127db..654788afa72 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -8,6 +8,8 @@ import pathlib import socket import ssl +import sys +import time from typing import Any, AsyncIterator from unittest import mock @@ -214,6 +216,67 @@ async def handler(request): assert 0 == len(client._session.connector._conns) +async def test_keepalive_timeout_async_sleep() -> None: + async def handler(request): + body = await request.read() + assert b"" == body + return web.Response(body=b"OK") + + app = web.Application() + app.router.add_route("GET", "/", handler) + + runner = web.AppRunner(app, tcp_keepalive=True, keepalive_timeout=0.001) + await runner.setup() + + port = unused_port() + site = web.TCPSite(runner, host="localhost", port=port) + await site.start() + + try: + async with aiohttp.client.ClientSession() as sess: + resp1 = await sess.get(f"http://localhost:{port}/") + await resp1.read() + # wait for server keepalive_timeout + await asyncio.sleep(0.01) + resp2 = await sess.get(f"http://localhost:{port}/") + await resp2.read() + finally: + await asyncio.gather(runner.shutdown(), site.stop()) + + +@pytest.mark.skipif( + sys.version_info[:2] == (3, 11), + reason="https://github.com/pytest-dev/pytest/issues/10763", +) +async def test_keepalive_timeout_sync_sleep() -> None: + async def handler(request): + body = await request.read() + assert b"" == body + return web.Response(body=b"OK") + + app = web.Application() + app.router.add_route("GET", "/", handler) + + runner = web.AppRunner(app, tcp_keepalive=True, keepalive_timeout=0.001) + await runner.setup() + + port = unused_port() + site = web.TCPSite(runner, host="localhost", port=port) + await site.start() + + try: + async with aiohttp.client.ClientSession() as sess: + resp1 = await sess.get(f"http://localhost:{port}/") + await resp1.read() + # wait for server keepalive_timeout + # time.sleep is a more challenging scenario than asyncio.sleep + time.sleep(0.01) + resp2 = await sess.get(f"http://localhost:{port}/") + await resp2.read() + finally: + await asyncio.gather(runner.shutdown(), site.stop()) + + async def test_release_early(aiohttp_client) -> None: async def handler(request): await request.read() @@ -3043,21 +3106,20 @@ def connection_lost(self, exc): addr = server.sockets[0].getsockname() - connector = aiohttp.TCPConnector(limit=1) - session = aiohttp.ClientSession(connector=connector) + async with aiohttp.TCPConnector(limit=1) as connector: + async with aiohttp.ClientSession(connector=connector) as session: + url = "http://{}:{}/".format(*addr) - url = "http://{}:{}/".format(*addr) + r = await session.request("GET", url) + await r.read() + assert 1 == len(connector._conns) + closed_conn = next(iter(connector._conns.values())) - r = await session.request("GET", url) - await r.read() - assert 1 == len(connector._conns) + await session.request("GET", url) + assert 1 == len(connector._conns) + new_conn = next(iter(connector._conns.values())) + assert closed_conn is not new_conn - with pytest.raises(aiohttp.ClientConnectionError): - await session.request("GET", url) - assert 0 == len(connector._conns) - - await session.close() - await connector.close() server.close() await server.wait_closed()