Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
150eb37
PYTHON-5272 Implement TLS session resumption for sync pool
blink1073 Jun 10, 2026
92a8230
PYTHON-5272 Add session reuse unit test for _configured_socket_interface
blink1073 Jun 10, 2026
2e6f985
PYTHON-5272 Extend TLS session resumption to asyncio path (Python 3.11+)
blink1073 Jun 10, 2026
17fbd40
PYTHON-5272 Update stale skip message for session reuse test
blink1073 Jun 10, 2026
04733ea
PYTHON-5272 Add changelog entry for TLS session resumption
blink1073 Jun 10, 2026
7bf71f0
PYTHON-5272 Expand changelog entry with per-case caching details
blink1073 Jun 10, 2026
7c2264e
PYTHON-5272 Fix changelog RST title underline length
blink1073 Jun 10, 2026
5958f59
PYTHON-5272 Reference closed CPython issue for async TLS session work…
blink1073 Jun 10, 2026
c9f62fe
PYTHON-5272 Fix concurrency bug in async SSL protocol patch restore
blink1073 Jun 10, 2026
dc856d2
PYTHON-5272 Fix PyPy failure: keep asyncio.sslproto import lazy
blink1073 Jun 10, 2026
2a12ea0
PYTHON-5272 Inject TLS session via sslobject_class instead of monkey-…
blink1073 Jun 11, 2026
eebb7c9
PYTHON-5272 Simplify async session injection to direct sslobject_clas…
blink1073 Jun 11, 2026
18f64e4
PYTHON-5272 Note async session injection is Python 3.11+ only; drop u…
blink1073 Jun 11, 2026
e99c4e3
PYTHON-5272 Replace _SSLSessionCache class with a plain list[Any]
blink1073 Jun 11, 2026
897e40a
PYTHON-5272 Add unit tests covering _get_ssl_session and async sessio…
blink1073 Jun 11, 2026
e5a371c
PYTHON-5272 Add coverage for async TLS session injection and save paths
blink1073 Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changelog
=========

Changes in Version 4.18.0
-------------------------

- Improved TLS connection performance by reusing TLS sessions across connections
to the same server, avoiding a full handshake on each new connection.
Session resumption is supported on all Python versions for synchronous clients
and on Python 3.11+ for async clients.

Changes in Version 4.17.0 (2026/04/20)
--------------------------------------

Expand Down
7 changes: 6 additions & 1 deletion pymongo/asynchronous/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ def __init__(
self._pending = 0
self._max_connecting = self.opts.max_connecting
self._client_id = client_id
self._ssl_session_cache: Optional[list[Any]] = (
[None] if self.opts._ssl_context is not None else None
)
# Log before publishing event to prevent potential listener preemption in tests
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
_debug_log(
Expand Down Expand Up @@ -1040,7 +1043,9 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A
)

try:
networking_interface = await _configured_protocol_interface(self.address, self.opts)
networking_interface = await _configured_protocol_interface(
self.address, self.opts, self._ssl_session_cache
)
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
except BaseException as error:
async with self.lock:
Expand Down
52 changes: 48 additions & 4 deletions pymongo/pool_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
from pymongo.pyopenssl_context import _sslConn
from pymongo.typings import _Address


def _get_ssl_session(ssl_sock: Any) -> Optional[Any]:
"""Return the TLS session from an SSL socket, handling both PyOpenSSL and stdlib ssl."""
if hasattr(ssl_sock, "get_session"):
return ssl_sock.get_session()
return getattr(ssl_sock, "session", None)


try:
from fcntl import F_GETFD, F_SETFD, FD_CLOEXEC, fcntl

Expand Down Expand Up @@ -298,7 +306,9 @@ async def _async_configured_socket(


async def _configured_protocol_interface(
address: _Address, options: PoolOptions
address: _Address,
options: PoolOptions,
ssl_session_cache: Optional[list[Any]] = None,
) -> AsyncNetworkingInterface:
"""Given (host, port) and PoolOptions, return a configured AsyncNetworkingInterface.

Expand All @@ -318,6 +328,22 @@ async def _configured_protocol_interface(
)

host = address[0]
# asyncio does not support TLS session resumption natively (cpython#79152,
# closed without a fix). On Python 3.11+ SSLProtocol.__init__ calls
# wrap_bio() synchronously before the first event-loop yield, so setting
# sslobject_class is race-free. Session injection is skipped on older
# Python versions. (The async path always uses stdlib ssl, never PyOpenSSL.)
if ssl_session_cache is not None and sys.version_info >= (3, 11):
session = ssl_session_cache[0]
if session is not None:
_session = session

class _SessionSSLObject(ssl.SSLObject):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.session = _session

ssl_context.sslobject_class = _SessionSSLObject # type: ignore[attr-defined]
try:
# We have to pass hostname / ip address to wrap_socket
# to use SSLContext.check_hostname.
Expand All @@ -337,6 +363,7 @@ async def _configured_protocol_interface(
# mismatch, will be turned into ServerSelectionTimeoutErrors later.
details = _get_timeout_details(options)
_raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details)

if (
ssl_context.verify_mode
and not ssl_context.check_hostname
Expand All @@ -348,6 +375,13 @@ async def _configured_protocol_interface(
transport.abort()
raise

if ssl_session_cache is not None:
ssl_obj = transport.get_extra_info("ssl_object")
if ssl_obj is not None:
new_session = ssl_obj.session
if new_session is not None:
ssl_session_cache[0] = new_session

return AsyncNetworkingInterface((transport, protocol))


Expand Down Expand Up @@ -470,7 +504,11 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
return ssl_sock


def _configured_socket_interface(address: _Address, options: PoolOptions) -> NetworkingInterface:
def _configured_socket_interface(
address: _Address,
options: PoolOptions,
ssl_session_cache: Optional[list[Any]] = None,
) -> NetworkingInterface:
"""Given (host, port) and PoolOptions, return a NetworkingInterface wrapping a configured socket.

Can raise socket.error, ConnectionFailure, or _CertificateError.
Expand All @@ -485,13 +523,14 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
return NetworkingInterface(sock)

host = address[0]
session = ssl_session_cache[0] if ssl_session_cache is not None else None
try:
# We have to pass hostname / ip address to wrap_socket
# to use SSLContext.check_hostname.
if _has_sni(True):
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host)
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host, session=session)
else:
ssl_sock = ssl_context.wrap_socket(sock)
ssl_sock = ssl_context.wrap_socket(sock, session=session)
except _CertificateError:
sock.close()
# Raise _CertificateError directly like we do after match_hostname
Expand All @@ -515,5 +554,10 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
ssl_sock.close()
raise

if ssl_session_cache is not None:
new_session = _get_ssl_session(ssl_sock)
if new_session is not None:
ssl_session_cache[0] = new_session

ssl_sock.settimeout(options.socket_timeout)
return NetworkingInterface(ssl_sock)
7 changes: 6 additions & 1 deletion pymongo/synchronous/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,9 @@ def __init__(
self._pending = 0
self._max_connecting = self.opts.max_connecting
self._client_id = client_id
self._ssl_session_cache: Optional[list[Any]] = (
[None] if self.opts._ssl_context is not None else None
)
# Log before publishing event to prevent potential listener preemption in tests
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
_debug_log(
Expand Down Expand Up @@ -1036,7 +1039,9 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect
)

try:
networking_interface = _configured_socket_interface(self.address, self.opts)
networking_interface = _configured_socket_interface(
self.address, self.opts, self._ssl_session_cache
)
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
except BaseException as error:
with self.lock:
Expand Down
Loading
Loading