Skip to content

Commit 50bb06b

Browse files
authored
Fix SSL shutdown timeout for streaming connections (#11094)
1 parent b1da65e commit 50bb06b

File tree

9 files changed

+272
-17
lines changed

9 files changed

+272
-17
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
@@ -297,6 +297,7 @@ def __init__(
297297
max_field_size: int = 8190,
298298
fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
299299
middlewares: Sequence[ClientMiddlewareType] = (),
300+
ssl_shutdown_timeout: Optional[float] = 0.1,
300301
) -> None:
301302
# We initialise _connector to None immediately, as it's referenced in __del__()
302303
# and could cause issues if an exception occurs during initialisation.
@@ -323,7 +324,7 @@ def __init__(
323324
self._timeout = timeout
324325

325326
if connector is None:
326-
connector = TCPConnector()
327+
connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout)
327328
# Initialize these three attrs before raising any exception,
328329
# they are used in __del__
329330
self._connector = connector

aiohttp/connector.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,12 @@ class TCPConnector(BaseConnector):
836836
socket_factory - A SocketFactoryType function that, if supplied,
837837
will be used to create sockets given an
838838
AddrInfoType.
839+
ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS
840+
connections. Default is 0.1 seconds. This usually
841+
allows for a clean SSL shutdown by notifying the
842+
remote peer of connection closure, while avoiding
843+
excessive delays during connector cleanup.
844+
Note: Only takes effect on Python 3.11+.
839845
"""
840846

841847
allowed_protocol_schema_set = HIGH_LEVEL_SCHEMA_SET | frozenset({"tcp"})
@@ -858,6 +864,7 @@ def __init__(
858864
happy_eyeballs_delay: Optional[float] = 0.25,
859865
interleave: Optional[int] = None,
860866
socket_factory: Optional[SocketFactoryType] = None,
867+
ssl_shutdown_timeout: Optional[float] = 0.1,
861868
):
862869
super().__init__(
863870
keepalive_timeout=keepalive_timeout,
@@ -889,6 +896,7 @@ def __init__(
889896
self._interleave = interleave
890897
self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set()
891898
self._socket_factory = socket_factory
899+
self._ssl_shutdown_timeout = ssl_shutdown_timeout
892900

893901
def _close_immediately(self) -> List[Awaitable[object]]:
894902
for fut in chain.from_iterable(self._throttle_dns_futures.values()):
@@ -1131,6 +1139,13 @@ async def _wrap_create_connection(
11311139
loop=self._loop,
11321140
socket_factory=self._socket_factory,
11331141
)
1142+
# Add ssl_shutdown_timeout for Python 3.11+ when SSL is used
1143+
if (
1144+
kwargs.get("ssl")
1145+
and self._ssl_shutdown_timeout is not None
1146+
and sys.version_info >= (3, 11)
1147+
):
1148+
kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout
11341149
return await self._loop.create_connection(*args, **kwargs, sock=sock)
11351150
except cert_errors as exc:
11361151
raise ClientConnectorCertificateError(req.connection_key, exc) from exc
@@ -1204,13 +1219,27 @@ async def _start_tls_connection(
12041219
timeout.sock_connect, ceil_threshold=timeout.ceil_threshold
12051220
):
12061221
try:
1207-
tls_transport = await self._loop.start_tls(
1208-
underlying_transport,
1209-
tls_proto,
1210-
sslcontext,
1211-
server_hostname=req.server_hostname or req.host,
1212-
ssl_handshake_timeout=timeout.total,
1213-
)
1222+
# ssl_shutdown_timeout is only available in Python 3.11+
1223+
if (
1224+
sys.version_info >= (3, 11)
1225+
and self._ssl_shutdown_timeout is not None
1226+
):
1227+
tls_transport = await self._loop.start_tls(
1228+
underlying_transport,
1229+
tls_proto,
1230+
sslcontext,
1231+
server_hostname=req.server_hostname or req.host,
1232+
ssl_handshake_timeout=timeout.total,
1233+
ssl_shutdown_timeout=self._ssl_shutdown_timeout,
1234+
)
1235+
else:
1236+
tls_transport = await self._loop.start_tls(
1237+
underlying_transport,
1238+
tls_proto,
1239+
sslcontext,
1240+
server_hostname=req.server_hostname or req.host,
1241+
ssl_handshake_timeout=timeout.total,
1242+
)
12141243
except BaseException:
12151244
# We need to close the underlying transport since
12161245
# `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

@@ -240,6 +241,16 @@ The client session supports the context manager protocol for self closing.
240241

241242
.. versionadded:: 3.8.6
242243

244+
:param float ssl_shutdown_timeout: Grace period for SSL shutdown handshake on TLS
245+
connections (``0.1`` seconds by default). This usually provides sufficient time
246+
to notify the remote peer of connection closure, helping prevent broken
247+
connections on the server side, while minimizing delays during connector
248+
cleanup. This timeout is passed to the underlying :class:`TCPConnector`
249+
when one is created automatically. Note: This parameter only takes effect
250+
on Python 3.11+.
251+
252+
.. versionadded:: 3.12.5
253+
243254
.. attribute:: closed
244255

245256
``True`` if the session has been closed, ``False`` otherwise.
@@ -1169,7 +1180,7 @@ is controlled by *force_close* constructor's parameter).
11691180
force_close=False, limit=100, limit_per_host=0, \
11701181
enable_cleanup_closed=False, timeout_ceil_threshold=5, \
11711182
happy_eyeballs_delay=0.25, interleave=None, loop=None, \
1172-
socket_factory=None)
1183+
socket_factory=None, ssl_shutdown_timeout=0.1)
11731184

11741185
Connector for working with *HTTP* and *HTTPS* via *TCP* sockets.
11751186

@@ -1296,6 +1307,16 @@ is controlled by *force_close* constructor's parameter).
12961307

12971308
.. versionadded:: 3.12
12981309

1310+
:param float ssl_shutdown_timeout: Grace period for SSL shutdown on TLS
1311+
connections (``0.1`` seconds by default). This parameter balances two
1312+
important considerations: usually providing sufficient time to notify
1313+
the remote server (which helps prevent "connection reset" errors),
1314+
while avoiding unnecessary delays during connector cleanup.
1315+
The default value provides a reasonable compromise for most use cases.
1316+
Note: This parameter only takes effect on Python 3.11+.
1317+
1318+
.. versionadded:: 3.12.5
1319+
12991320
.. attribute:: family
13001321

13011322
*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,
@@ -704,6 +705,70 @@ async def handler(request: web.Request) -> web.Response:
704705
assert txt == "Test message"
705706

706707

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

tests/test_client_session.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,34 @@ async def test_create_connector(
347347
assert m.called
348348

349349

350+
async def test_ssl_shutdown_timeout_passed_to_connector() -> None:
351+
# Test default value
352+
async with ClientSession() as session:
353+
assert isinstance(session.connector, TCPConnector)
354+
assert session.connector._ssl_shutdown_timeout == 0.1
355+
356+
# Test custom value
357+
async with ClientSession(ssl_shutdown_timeout=1.0) as session:
358+
assert isinstance(session.connector, TCPConnector)
359+
assert session.connector._ssl_shutdown_timeout == 1.0
360+
361+
# Test None value
362+
async with ClientSession(ssl_shutdown_timeout=None) as session:
363+
assert isinstance(session.connector, TCPConnector)
364+
assert session.connector._ssl_shutdown_timeout is None
365+
366+
# Test that it doesn't affect when custom connector is provided
367+
custom_conn = TCPConnector(ssl_shutdown_timeout=2.0)
368+
async with ClientSession(
369+
connector=custom_conn, ssl_shutdown_timeout=1.0
370+
) as session:
371+
assert session.connector is not None
372+
assert isinstance(session.connector, TCPConnector)
373+
assert (
374+
session.connector._ssl_shutdown_timeout == 2.0
375+
) # Should use connector's value
376+
377+
350378
def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None:
351379
with contextlib.ExitStack() as stack:
352380
another_loop = asyncio.new_event_loop()

tests/test_connector.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,6 +2061,104 @@ async def test_tcp_connector_ctor(loop: asyncio.AbstractEventLoop) -> None:
20612061
await conn.close()
20622062

20632063

2064+
async def test_tcp_connector_ssl_shutdown_timeout(
2065+
loop: asyncio.AbstractEventLoop,
2066+
) -> None:
2067+
# Test default value
2068+
conn = aiohttp.TCPConnector()
2069+
assert conn._ssl_shutdown_timeout == 0.1
2070+
await conn.close()
2071+
2072+
# Test custom value
2073+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0)
2074+
assert conn._ssl_shutdown_timeout == 1.0
2075+
await conn.close()
2076+
2077+
# Test None value
2078+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None)
2079+
assert conn._ssl_shutdown_timeout is None
2080+
await conn.close()
2081+
2082+
2083+
@pytest.mark.skipif(
2084+
sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+"
2085+
)
2086+
async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection(
2087+
loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock
2088+
) -> None:
2089+
# Test that ssl_shutdown_timeout is passed to create_connection for SSL connections
2090+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2091+
2092+
with mock.patch.object(
2093+
conn._loop, "create_connection", autospec=True, spec_set=True
2094+
) as create_connection:
2095+
create_connection.return_value = mock.Mock(), mock.Mock()
2096+
2097+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2098+
2099+
with closing(await conn.connect(req, [], ClientTimeout())):
2100+
assert create_connection.call_args.kwargs["ssl_shutdown_timeout"] == 2.5
2101+
2102+
await conn.close()
2103+
2104+
# Test with None value
2105+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None)
2106+
2107+
with mock.patch.object(
2108+
conn._loop, "create_connection", autospec=True, spec_set=True
2109+
) as create_connection:
2110+
create_connection.return_value = mock.Mock(), mock.Mock()
2111+
2112+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2113+
2114+
with closing(await conn.connect(req, [], ClientTimeout())):
2115+
# When ssl_shutdown_timeout is None, it should not be in kwargs
2116+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2117+
2118+
await conn.close()
2119+
2120+
# Test that ssl_shutdown_timeout is NOT passed for non-SSL connections
2121+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2122+
2123+
with mock.patch.object(
2124+
conn._loop, "create_connection", autospec=True, spec_set=True
2125+
) as create_connection:
2126+
create_connection.return_value = mock.Mock(), mock.Mock()
2127+
2128+
req = ClientRequest("GET", URL("http://example.com"), loop=loop)
2129+
2130+
with closing(await conn.connect(req, [], ClientTimeout())):
2131+
# For non-SSL connections, ssl_shutdown_timeout should not be passed
2132+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2133+
2134+
await conn.close()
2135+
2136+
2137+
@pytest.mark.skipif(sys.version_info >= (3, 11), reason="Test for Python < 3.11")
2138+
async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311(
2139+
loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock
2140+
) -> None:
2141+
# Test that ssl_shutdown_timeout is NOT passed to create_connection on Python < 3.11
2142+
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5)
2143+
2144+
with mock.patch.object(
2145+
conn._loop, "create_connection", autospec=True, spec_set=True
2146+
) as create_connection:
2147+
create_connection.return_value = mock.Mock(), mock.Mock()
2148+
2149+
# Test with HTTPS
2150+
req = ClientRequest("GET", URL("https://example.com"), loop=loop)
2151+
with closing(await conn.connect(req, [], ClientTimeout())):
2152+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2153+
2154+
# Test with HTTP
2155+
req = ClientRequest("GET", URL("http://example.com"), loop=loop)
2156+
with closing(await conn.connect(req, [], ClientTimeout())):
2157+
assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs
2158+
2159+
await conn.close()
2160+
2161+
20642162
async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None:
20652163
conn = aiohttp.TCPConnector()
20662164
assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"}

0 commit comments

Comments
 (0)