Skip to content
1 change: 1 addition & 0 deletions CHANGES/11091.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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`.
1 change: 1 addition & 0 deletions CHANGES/11094.feature.rst
3 changes: 2 additions & 1 deletion aiohttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ def __init__(
max_field_size: int = 8190,
fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
middlewares: Sequence[ClientMiddlewareType] = (),
ssl_shutdown_timeout: Optional[float] = 0.1,
) -> None:
# We initialise _connector to None immediately, as it's referenced in __del__()
# and could cause issues if an exception occurs during initialisation.
Expand All @@ -323,7 +324,7 @@ def __init__(
self._timeout = timeout

if connector is None:
connector = TCPConnector()
connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout)
# Initialize these three attrs before raising any exception,
# they are used in __del__
self._connector = connector
Expand Down
42 changes: 35 additions & 7 deletions aiohttp/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,12 @@ class TCPConnector(BaseConnector):
socket_factory - A SocketFactoryType function that, if supplied,
will be used to create sockets given an
AddrInfoType.
ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS
connections. Default is 0.1 seconds. This usually
allows for a clean SSL shutdown by notifying the
remote peer of connection closure, while avoiding
excessive delays during connector cleanup.
Note: Only takes effect on Python 3.11+.
"""

allowed_protocol_schema_set = HIGH_LEVEL_SCHEMA_SET | frozenset({"tcp"})
Expand All @@ -858,6 +864,7 @@ def __init__(
happy_eyeballs_delay: Optional[float] = 0.25,
interleave: Optional[int] = None,
socket_factory: Optional[SocketFactoryType] = None,
ssl_shutdown_timeout: Optional[float] = 0.1,
):
super().__init__(
keepalive_timeout=keepalive_timeout,
Expand Down Expand Up @@ -889,6 +896,7 @@ def __init__(
self._interleave = interleave
self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set()
self._socket_factory = socket_factory
self._ssl_shutdown_timeout = ssl_shutdown_timeout

def _close_immediately(self) -> List[Awaitable[object]]:
for fut in chain.from_iterable(self._throttle_dns_futures.values()):
Expand Down Expand Up @@ -1131,6 +1139,12 @@ async def _wrap_create_connection(
loop=self._loop,
socket_factory=self._socket_factory,
)
# Add ssl_shutdown_timeout for Python 3.11+ when SSL is used
if self._ssl_shutdown_timeout is not None and sys.version_info >= (
3,
11,
):
kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout
return await self._loop.create_connection(*args, **kwargs, sock=sock)
except cert_errors as exc:
raise ClientConnectorCertificateError(req.connection_key, exc) from exc
Expand Down Expand Up @@ -1204,13 +1218,27 @@ async def _start_tls_connection(
timeout.sock_connect, ceil_threshold=timeout.ceil_threshold
):
try:
tls_transport = await self._loop.start_tls(
underlying_transport,
tls_proto,
sslcontext,
server_hostname=req.server_hostname or req.host,
ssl_handshake_timeout=timeout.total,
)
# ssl_shutdown_timeout is only available in Python 3.11+
if (
sys.version_info >= (3, 11)
and self._ssl_shutdown_timeout is not None
):
tls_transport = await self._loop.start_tls(
underlying_transport,
tls_proto,
sslcontext,
server_hostname=req.server_hostname or req.host,
ssl_handshake_timeout=timeout.total,
ssl_shutdown_timeout=self._ssl_shutdown_timeout,
)
else:
tls_transport = await self._loop.start_tls(
underlying_transport,
tls_proto,
sslcontext,
server_hostname=req.server_hostname or req.host,
ssl_handshake_timeout=timeout.total,
)
except BaseException:
# We need to close the underlying transport since
# `start_tls()` probably failed before it had a
Expand Down
25 changes: 23 additions & 2 deletions docs/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ The client session supports the context manager protocol for self closing.
read_bufsize=2**16, \
max_line_size=8190, \
max_field_size=8190, \
fallback_charset_resolver=lambda r, b: "utf-8")
fallback_charset_resolver=lambda r, b: "utf-8", \
ssl_shutdown_timeout=0.1)

The class for creating client sessions and making requests.

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

.. versionadded:: 3.8.6

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

.. versionadded:: 3.12.5

.. attribute:: closed

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

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

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

.. versionadded:: 3.12

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

.. versionadded:: 3.12.5

.. attribute:: family

*TCP* socket family e.g. :data:`socket.AF_INET` or
Expand Down
65 changes: 65 additions & 0 deletions tests/test_client_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import tarfile
import time
import zipfile
from contextlib import suppress
from typing import (
Any,
AsyncIterator,
Expand Down Expand Up @@ -704,6 +705,70 @@ async def handler(request: web.Request) -> web.Response:
assert txt == "Test message"


@pytest.mark.skipif(
sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+"
)
async def test_ssl_client_shutdown_timeout(
aiohttp_server: AiohttpServer,
ssl_ctx: ssl.SSLContext,
aiohttp_client: AiohttpClient,
client_ssl_ctx: ssl.SSLContext,
) -> None:
# Test that ssl_shutdown_timeout is properly used during connection closure

connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1)

async def streaming_handler(request: web.Request) -> NoReturn:
# Create a streaming response that continuously sends data
response = web.StreamResponse()
await response.prepare(request)

# Keep sending data until connection is closed
while True:
await response.write(b"data chunk\n")
await asyncio.sleep(0.01) # Small delay between chunks

assert False, "not reached"

app = web.Application()
app.router.add_route("GET", "/stream", streaming_handler)
server = await aiohttp_server(app, ssl=ssl_ctx)
client = await aiohttp_client(server, connector=connector)

# Verify the connector has the correct timeout
assert connector._ssl_shutdown_timeout == 0.1

# Start a streaming request to establish SSL connection with active data transfer
resp = await client.get("/stream")
assert resp.status == 200

# Create a background task that continuously reads data
async def read_loop() -> None:
while True:
# Read "data chunk\n"
await resp.content.read(11)

read_task = asyncio.create_task(read_loop())
await asyncio.sleep(0) # Yield control to ensure read_task starts

# Record the time before closing
start_time = time.monotonic()

# Now close the connector while the stream is still active
# This will test the ssl_shutdown_timeout during an active connection
await connector.close()

# Verify the connection was closed within a reasonable time
# Should be close to ssl_shutdown_timeout (0.1s) but allow some margin
elapsed = time.monotonic() - start_time
assert elapsed < 0.3, f"Connection closure took too long: {elapsed}s"

read_task.cancel()
with suppress(asyncio.CancelledError):
await read_task
assert read_task.done(), "Read task should be cancelled after connection closure"


async def test_ssl_client_alpn(
aiohttp_server: AiohttpServer,
aiohttp_client: AiohttpClient,
Expand Down
28 changes: 28 additions & 0 deletions tests/test_client_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,34 @@ async def test_create_connector(
assert m.called


async def test_ssl_shutdown_timeout_passed_to_connector() -> None:
# Test default value
async with ClientSession() as session:
assert isinstance(session.connector, TCPConnector)
assert session.connector._ssl_shutdown_timeout == 0.1

# Test custom value
async with ClientSession(ssl_shutdown_timeout=1.0) as session:
assert isinstance(session.connector, TCPConnector)
assert session.connector._ssl_shutdown_timeout == 1.0

# Test None value
async with ClientSession(ssl_shutdown_timeout=None) as session:
assert isinstance(session.connector, TCPConnector)
assert session.connector._ssl_shutdown_timeout is None

# Test that it doesn't affect when custom connector is provided
custom_conn = TCPConnector(ssl_shutdown_timeout=2.0)
async with ClientSession(
connector=custom_conn, ssl_shutdown_timeout=1.0
) as session:
assert session.connector is not None
assert isinstance(session.connector, TCPConnector)
assert (
session.connector._ssl_shutdown_timeout == 2.0
) # Should use connector's value


def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None:
with contextlib.ExitStack() as stack:
another_loop = asyncio.new_event_loop()
Expand Down
19 changes: 19 additions & 0 deletions tests/test_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -2061,6 +2061,25 @@ async def test_tcp_connector_ctor(loop: asyncio.AbstractEventLoop) -> None:
await conn.close()


async def test_tcp_connector_ssl_shutdown_timeout(
loop: asyncio.AbstractEventLoop,
) -> None:
# Test default value
conn = aiohttp.TCPConnector()
assert conn._ssl_shutdown_timeout == 0.1
await conn.close()

# Test custom value
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0)
assert conn._ssl_shutdown_timeout == 1.0
await conn.close()

# Test None value
conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None)
assert conn._ssl_shutdown_timeout is None
await conn.close()


async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None:
conn = aiohttp.TCPConnector()
assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"}
Expand Down
25 changes: 18 additions & 7 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import gc
import socket
import ssl
import sys
import unittest
from unittest import mock

Expand Down Expand Up @@ -1044,13 +1045,23 @@ async def make_conn() -> aiohttp.TCPConnector:
)
)

tls_m.assert_called_with(
mock.ANY,
mock.ANY,
_SSL_CONTEXT_VERIFIED,
server_hostname="www.python.org",
ssl_handshake_timeout=mock.ANY,
)
if sys.version_info >= (3, 11):
tls_m.assert_called_with(
mock.ANY,
mock.ANY,
_SSL_CONTEXT_VERIFIED,
server_hostname="www.python.org",
ssl_handshake_timeout=mock.ANY,
ssl_shutdown_timeout=0.1,
)
else:
tls_m.assert_called_with(
mock.ANY,
mock.ANY,
_SSL_CONTEXT_VERIFIED,
server_hostname="www.python.org",
ssl_handshake_timeout=mock.ANY,
)

self.assertEqual(req.url.path, "/")
self.assertEqual(proxy_req.method, "CONNECT")
Expand Down
Loading