Skip to content

Commit 8efe84e

Browse files
authored
[PR #11094/50bb06b backport][3.12] Fix SSL shutdown timeout for streaming connections (#11095)
1 parent c7e03ef commit 8efe84e

File tree

9 files changed

+272
-18
lines changed

9 files changed

+272
-18
lines changed

CHANGES/11091.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ``ssl_shutdown_timeout`` parameter to :py:class:`~aiohttp.ClientSession` and :py:class:`~aiohttp.TCPConnector` to control the grace period for SSL shutdown handshake on TLS connections. This helps prevent "connection reset" errors on the server side while avoiding excessive delays during connector cleanup. Note: This parameter only takes effect on Python 3.11+ -- by :user:`bdraco`.

CHANGES/11094.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
11091.feature.rst

aiohttp/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ def __init__(
303303
max_field_size: int = 8190,
304304
fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
305305
middlewares: Sequence[ClientMiddlewareType] = (),
306+
ssl_shutdown_timeout: Optional[float] = 0.1,
306307
) -> None:
307308
# We initialise _connector to None immediately, as it's referenced in __del__()
308309
# and could cause issues if an exception occurs during initialisation.
@@ -361,7 +362,7 @@ def __init__(
361362
)
362363

363364
if connector is None:
364-
connector = TCPConnector(loop=loop)
365+
connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout)
365366

366367
if connector._loop is not loop:
367368
raise RuntimeError("Session and connector has to use same event loop")

aiohttp/connector.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,12 @@ class TCPConnector(BaseConnector):
879879
socket_factory - A SocketFactoryType function that, if supplied,
880880
will be used to create sockets given an
881881
AddrInfoType.
882+
ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS
883+
connections. Default is 0.1 seconds. This usually
884+
allows for a clean SSL shutdown by notifying the
885+
remote peer of connection closure, while avoiding
886+
excessive delays during connector cleanup.
887+
Note: Only takes effect on Python 3.11+.
882888
"""
883889

884890
allowed_protocol_schema_set = HIGH_LEVEL_SCHEMA_SET | frozenset({"tcp"})
@@ -905,6 +911,7 @@ def __init__(
905911
happy_eyeballs_delay: Optional[float] = 0.25,
906912
interleave: Optional[int] = None,
907913
socket_factory: Optional[SocketFactoryType] = None,
914+
ssl_shutdown_timeout: Optional[float] = 0.1,
908915
):
909916
super().__init__(
910917
keepalive_timeout=keepalive_timeout,
@@ -932,6 +939,7 @@ def __init__(
932939
self._interleave = interleave
933940
self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set()
934941
self._socket_factory = socket_factory
942+
self._ssl_shutdown_timeout = ssl_shutdown_timeout
935943

936944
def _close(self) -> List[Awaitable[object]]:
937945
"""Close all ongoing DNS calls."""
@@ -1176,6 +1184,13 @@ async def _wrap_create_connection(
11761184
loop=self._loop,
11771185
socket_factory=self._socket_factory,
11781186
)
1187+
# Add ssl_shutdown_timeout for Python 3.11+ when SSL is used
1188+
if (
1189+
kwargs.get("ssl")
1190+
and self._ssl_shutdown_timeout is not None
1191+
and sys.version_info >= (3, 11)
1192+
):
1193+
kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout
11791194
return await self._loop.create_connection(*args, **kwargs, sock=sock)
11801195
except cert_errors as exc:
11811196
raise ClientConnectorCertificateError(req.connection_key, exc) from exc
@@ -1314,13 +1329,27 @@ async def _start_tls_connection(
13141329
timeout.sock_connect, ceil_threshold=timeout.ceil_threshold
13151330
):
13161331
try:
1317-
tls_transport = await self._loop.start_tls(
1318-
underlying_transport,
1319-
tls_proto,
1320-
sslcontext,
1321-
server_hostname=req.server_hostname or req.host,
1322-
ssl_handshake_timeout=timeout.total,
1323-
)
1332+
# ssl_shutdown_timeout is only available in Python 3.11+
1333+
if (
1334+
sys.version_info >= (3, 11)
1335+
and self._ssl_shutdown_timeout is not None
1336+
):
1337+
tls_transport = await self._loop.start_tls(
1338+
underlying_transport,
1339+
tls_proto,
1340+
sslcontext,
1341+
server_hostname=req.server_hostname or req.host,
1342+
ssl_handshake_timeout=timeout.total,
1343+
ssl_shutdown_timeout=self._ssl_shutdown_timeout,
1344+
)
1345+
else:
1346+
tls_transport = await self._loop.start_tls(
1347+
underlying_transport,
1348+
tls_proto,
1349+
sslcontext,
1350+
server_hostname=req.server_hostname or req.host,
1351+
ssl_handshake_timeout=timeout.total,
1352+
)
13241353
except BaseException:
13251354
# We need to close the underlying transport since
13261355
# `start_tls()` probably failed before it had a

docs/client_reference.rst

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ The client session supports the context manager protocol for self closing.
5757
read_bufsize=2**16, \
5858
max_line_size=8190, \
5959
max_field_size=8190, \
60-
fallback_charset_resolver=lambda r, b: "utf-8")
60+
fallback_charset_resolver=lambda r, b: "utf-8", \
61+
ssl_shutdown_timeout=0.1)
6162

6263
The class for creating client sessions and making requests.
6364

@@ -256,6 +257,16 @@ The client session supports the context manager protocol for self closing.
256257

257258
.. versionadded:: 3.8.6
258259

260+
:param float ssl_shutdown_timeout: Grace period for SSL shutdown handshake on TLS
261+
connections (``0.1`` seconds by default). This usually provides sufficient time
262+
to notify the remote peer of connection closure, helping prevent broken
263+
connections on the server side, while minimizing delays during connector
264+
cleanup. This timeout is passed to the underlying :class:`TCPConnector`
265+
when one is created automatically. Note: This parameter only takes effect
266+
on Python 3.11+.
267+
268+
.. versionadded:: 3.12.5
269+
259270
.. attribute:: closed
260271

261272
``True`` if the session has been closed, ``False`` otherwise.
@@ -1185,7 +1196,7 @@ is controlled by *force_close* constructor's parameter).
11851196
force_close=False, limit=100, limit_per_host=0, \
11861197
enable_cleanup_closed=False, timeout_ceil_threshold=5, \
11871198
happy_eyeballs_delay=0.25, interleave=None, loop=None, \
1188-
socket_factory=None)
1199+
socket_factory=None, ssl_shutdown_timeout=0.1)
11891200

11901201
Connector for working with *HTTP* and *HTTPS* via *TCP* sockets.
11911202

@@ -1312,6 +1323,16 @@ is controlled by *force_close* constructor's parameter).
13121323

13131324
.. versionadded:: 3.12
13141325

1326+
:param float ssl_shutdown_timeout: Grace period for SSL shutdown on TLS
1327+
connections (``0.1`` seconds by default). This parameter balances two
1328+
important considerations: usually providing sufficient time to notify
1329+
the remote server (which helps prevent "connection reset" errors),
1330+
while avoiding unnecessary delays during connector cleanup.
1331+
The default value provides a reasonable compromise for most use cases.
1332+
Note: This parameter only takes effect on Python 3.11+.
1333+
1334+
.. versionadded:: 3.12.5
1335+
13151336
.. attribute:: family
13161337

13171338
*TCP* socket family e.g. :data:`socket.AF_INET` or

tests/test_client_functional.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import tarfile
1313
import time
1414
import zipfile
15+
from contextlib import suppress
1516
from typing import (
1617
Any,
1718
AsyncIterator,
@@ -685,6 +686,70 @@ async def handler(request):
685686
assert txt == "Test message"
686687

687688

689+
@pytest.mark.skipif(
690+
sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+"
691+
)
692+
async def test_ssl_client_shutdown_timeout(
693+
aiohttp_server: AiohttpServer,
694+
ssl_ctx: ssl.SSLContext,
695+
aiohttp_client: AiohttpClient,
696+
client_ssl_ctx: ssl.SSLContext,
697+
) -> None:
698+
# Test that ssl_shutdown_timeout is properly used during connection closure
699+
700+
connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1)
701+
702+
async def streaming_handler(request: web.Request) -> NoReturn:
703+
# Create a streaming response that continuously sends data
704+
response = web.StreamResponse()
705+
await response.prepare(request)
706+
707+
# Keep sending data until connection is closed
708+
while True:
709+
await response.write(b"data chunk\n")
710+
await asyncio.sleep(0.01) # Small delay between chunks
711+
712+
assert False, "not reached"
713+
714+
app = web.Application()
715+
app.router.add_route("GET", "/stream", streaming_handler)
716+
server = await aiohttp_server(app, ssl=ssl_ctx)
717+
client = await aiohttp_client(server, connector=connector)
718+
719+
# Verify the connector has the correct timeout
720+
assert connector._ssl_shutdown_timeout == 0.1
721+
722+
# Start a streaming request to establish SSL connection with active data transfer
723+
resp = await client.get("/stream")
724+
assert resp.status == 200
725+
726+
# Create a background task that continuously reads data
727+
async def read_loop() -> None:
728+
while True:
729+
# Read "data chunk\n"
730+
await resp.content.read(11)
731+
732+
read_task = asyncio.create_task(read_loop())
733+
await asyncio.sleep(0) # Yield control to ensure read_task starts
734+
735+
# Record the time before closing
736+
start_time = time.monotonic()
737+
738+
# Now close the connector while the stream is still active
739+
# This will test the ssl_shutdown_timeout during an active connection
740+
await connector.close()
741+
742+
# Verify the connection was closed within a reasonable time
743+
# Should be close to ssl_shutdown_timeout (0.1s) but allow some margin
744+
elapsed = time.monotonic() - start_time
745+
assert elapsed < 0.3, f"Connection closure took too long: {elapsed}s"
746+
747+
read_task.cancel()
748+
with suppress(asyncio.CancelledError):
749+
await read_task
750+
assert read_task.done(), "Read task should be cancelled after connection closure"
751+
752+
688753
async def test_ssl_client_alpn(
689754
aiohttp_server: AiohttpServer,
690755
aiohttp_client: AiohttpClient,

tests/test_client_session.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,35 @@ async def test_create_connector(create_session, loop, mocker) -> None:
310310
assert connector.close.called
311311

312312

313-
def test_connector_loop(loop) -> None:
313+
async def test_ssl_shutdown_timeout_passed_to_connector() -> None:
314+
# Test default value
315+
async with ClientSession() as session:
316+
assert isinstance(session.connector, TCPConnector)
317+
assert session.connector._ssl_shutdown_timeout == 0.1
318+
319+
# Test custom value
320+
async with ClientSession(ssl_shutdown_timeout=1.0) as session:
321+
assert isinstance(session.connector, TCPConnector)
322+
assert session.connector._ssl_shutdown_timeout == 1.0
323+
324+
# Test None value
325+
async with ClientSession(ssl_shutdown_timeout=None) as session:
326+
assert isinstance(session.connector, TCPConnector)
327+
assert session.connector._ssl_shutdown_timeout is None
328+
329+
# Test that it doesn't affect when custom connector is provided
330+
custom_conn = TCPConnector(ssl_shutdown_timeout=2.0)
331+
async with ClientSession(
332+
connector=custom_conn, ssl_shutdown_timeout=1.0
333+
) as session:
334+
assert session.connector is not None
335+
assert isinstance(session.connector, TCPConnector)
336+
assert (
337+
session.connector._ssl_shutdown_timeout == 2.0
338+
) # Should use connector's value
339+
340+
341+
def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None:
314342
with contextlib.ExitStack() as stack:
315343
another_loop = asyncio.new_event_loop()
316344
stack.enter_context(contextlib.closing(another_loop))

tests/test_connector.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,104 @@ async def test_tcp_connector_ctor() -> None:
20022002
await conn.close()
20032003

20042004

2005+
async def test_tcp_connector_ssl_shutdown_timeout(
2006+
loop: asyncio.AbstractEventLoop,
2007+
) -> None:
2008+
# Test default value
2009+
conn = aiohttp.TCPConnector()
2010+
assert conn._ssl_shutdown_timeout == 0.1
2011+
await conn.close()
2012+
2013+
# Test custom value
2014+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0)
2015+
assert conn._ssl_shutdown_timeout == 1.0
2016+
await conn.close()
2017+
2018+
# Test None value
2019+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None)
2020+
assert conn._ssl_shutdown_timeout is None
2021+
await conn.close()
2022+
2023+
2024+
@pytest.mark.skipif(
2025+
sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+"
2026+
)
2027+
async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection(
2028+
loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock
2029+
) -> None:
2030+
# Test that ssl_shutdown_timeout is passed to create_connection for SSL connections
2031+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2032+
2033+
with mock.patch.object(
2034+
conn._loop, "create_connection", autospec=True, spec_set=True
2035+
) as create_connection:
2036+
create_connection.return_value = mock.Mock(), mock.Mock()
2037+
2038+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2039+
2040+
with closing(await conn.connect(req, [], ClientTimeout())):
2041+
assert create_connection.call_args.kwargs["ssl_shutdown_timeout"] == 2.5
2042+
2043+
await conn.close()
2044+
2045+
# Test with None value
2046+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None)
2047+
2048+
with mock.patch.object(
2049+
conn._loop, "create_connection", autospec=True, spec_set=True
2050+
) as create_connection:
2051+
create_connection.return_value = mock.Mock(), mock.Mock()
2052+
2053+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2054+
2055+
with closing(await conn.connect(req, [], ClientTimeout())):
2056+
# When ssl_shutdown_timeout is None, it should not be in kwargs
2057+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2058+
2059+
await conn.close()
2060+
2061+
# Test that ssl_shutdown_timeout is NOT passed for non-SSL connections
2062+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2063+
2064+
with mock.patch.object(
2065+
conn._loop, "create_connection", autospec=True, spec_set=True
2066+
) as create_connection:
2067+
create_connection.return_value = mock.Mock(), mock.Mock()
2068+
2069+
req = ClientRequest("GET", URL("http://example.com"), loop=loop)
2070+
2071+
with closing(await conn.connect(req, [], ClientTimeout())):
2072+
# For non-SSL connections, ssl_shutdown_timeout should not be passed
2073+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2074+
2075+
await conn.close()
2076+
2077+
2078+
@pytest.mark.skipif(sys.version_info >= (3, 11), reason="Test for Python < 3.11")
2079+
async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311(
2080+
loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock
2081+
) -> None:
2082+
# Test that ssl_shutdown_timeout is NOT passed to create_connection on Python < 3.11
2083+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2084+
2085+
with mock.patch.object(
2086+
conn._loop, "create_connection", autospec=True, spec_set=True
2087+
) as create_connection:
2088+
create_connection.return_value = mock.Mock(), mock.Mock()
2089+
2090+
# Test with HTTPS
2091+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2092+
with closing(await conn.connect(req, [], ClientTimeout())):
2093+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2094+
2095+
# Test with HTTP
2096+
req = ClientRequest("GET", URL("http://example.com"), loop=loop)
2097+
with closing(await conn.connect(req, [], ClientTimeout())):
2098+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2099+
2100+
await conn.close()
2101+
2102+
20052103
async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None:
20062104
conn = aiohttp.TCPConnector()
20072105
assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"}

0 commit comments

Comments
 (0)