Skip to content

Commit 5319a1b

Browse files
authored
PYTHON-5272 Implement TLS session resumption for connections (#2864)
1 parent 18757e1 commit 5319a1b

6 files changed

Lines changed: 619 additions & 6 deletions

File tree

doc/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
Changes in Version 4.18.0
5+
-------------------------
6+
7+
- Improved TLS connection performance by reusing TLS sessions across connections
8+
to the same server, avoiding a full handshake on each new connection.
9+
Session resumption is supported on all Python versions for synchronous clients
10+
and on Python 3.11+ for async clients.
11+
412
Changes in Version 4.17.0 (2026/04/20)
513
--------------------------------------
614

pymongo/asynchronous/pool.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,9 @@ def __init__(
754754
self._pending = 0
755755
self._max_connecting = self.opts.max_connecting
756756
self._client_id = client_id
757+
self._ssl_session_cache: Optional[list[Any]] = (
758+
[None] if self.opts._ssl_context is not None else None
759+
)
757760
# Log before publishing event to prevent potential listener preemption in tests
758761
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
759762
_debug_log(
@@ -1041,7 +1044,9 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A
10411044
listeners.publish_connection_created(self.address, conn_id)
10421045

10431046
try:
1044-
networking_interface = await _configured_protocol_interface(self.address, self.opts)
1047+
networking_interface = await _configured_protocol_interface(
1048+
self.address, self.opts, self._ssl_session_cache
1049+
)
10451050
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
10461051
except BaseException as error:
10471052
async with self.lock:

pymongo/pool_shared.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@
4747
from pymongo.pyopenssl_context import _sslConn
4848
from pymongo.typings import _Address
4949

50+
51+
def _get_ssl_session(ssl_sock: Any) -> Optional[Any]:
52+
"""Return the TLS session from an SSL socket, handling both PyOpenSSL and stdlib ssl."""
53+
if hasattr(ssl_sock, "get_session"):
54+
return ssl_sock.get_session()
55+
return getattr(ssl_sock, "session", None)
56+
57+
5058
try:
5159
from fcntl import F_GETFD, F_SETFD, FD_CLOEXEC, fcntl
5260

@@ -297,7 +305,9 @@ async def _async_configured_socket(
297305

298306

299307
async def _configured_protocol_interface(
300-
address: _Address, options: PoolOptions
308+
address: _Address,
309+
options: PoolOptions,
310+
ssl_session_cache: Optional[list[Any]] = None,
301311
) -> AsyncNetworkingInterface:
302312
"""Given (host, port) and PoolOptions, return a configured AsyncNetworkingInterface.
303313
@@ -317,6 +327,22 @@ async def _configured_protocol_interface(
317327
)
318328

319329
host = address[0]
330+
# asyncio does not support TLS session resumption natively (cpython#79152,
331+
# closed without a fix). On Python 3.11+ SSLProtocol.__init__ calls
332+
# wrap_bio() synchronously before the first event-loop yield, so setting
333+
# sslobject_class is race-free. Session injection is skipped on older
334+
# Python versions. (The async path always uses stdlib ssl, never PyOpenSSL.)
335+
if ssl_session_cache is not None and sys.version_info >= (3, 11):
336+
session = ssl_session_cache[0]
337+
if session is not None:
338+
_session = session
339+
340+
class _SessionSSLObject(ssl.SSLObject):
341+
def __init__(self, *args: Any, **kwargs: Any) -> None:
342+
super().__init__(*args, **kwargs)
343+
self.session = _session
344+
345+
ssl_context.sslobject_class = _SessionSSLObject # type: ignore[attr-defined]
320346
try:
321347
# We have to pass hostname / ip address to wrap_socket
322348
# to use SSLContext.check_hostname.
@@ -336,6 +362,7 @@ async def _configured_protocol_interface(
336362
# mismatch, will be turned into ServerSelectionTimeoutErrors later.
337363
details = _get_timeout_details(options)
338364
_raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details)
365+
339366
if (
340367
ssl_context.verify_mode
341368
and not ssl_context.check_hostname
@@ -347,6 +374,13 @@ async def _configured_protocol_interface(
347374
transport.abort()
348375
raise
349376

377+
if ssl_session_cache is not None:
378+
ssl_obj = transport.get_extra_info("ssl_object")
379+
if ssl_obj is not None:
380+
new_session = ssl_obj.session
381+
if new_session is not None:
382+
ssl_session_cache[0] = new_session
383+
350384
return AsyncNetworkingInterface((transport, protocol))
351385

352386

@@ -469,7 +503,11 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
469503
return ssl_sock
470504

471505

472-
def _configured_socket_interface(address: _Address, options: PoolOptions) -> NetworkingInterface:
506+
def _configured_socket_interface(
507+
address: _Address,
508+
options: PoolOptions,
509+
ssl_session_cache: Optional[list[Any]] = None,
510+
) -> NetworkingInterface:
473511
"""Given (host, port) and PoolOptions, return a NetworkingInterface wrapping a configured socket.
474512
475513
Can raise socket.error, ConnectionFailure, or _CertificateError.
@@ -484,13 +522,14 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
484522
return NetworkingInterface(sock)
485523

486524
host = address[0]
525+
session = ssl_session_cache[0] if ssl_session_cache is not None else None
487526
try:
488527
# We have to pass hostname / ip address to wrap_socket
489528
# to use SSLContext.check_hostname.
490529
if _has_sni(True):
491-
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host)
530+
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host, session=session)
492531
else:
493-
ssl_sock = ssl_context.wrap_socket(sock)
532+
ssl_sock = ssl_context.wrap_socket(sock, session=session)
494533
except _CertificateError:
495534
sock.close()
496535
# Raise _CertificateError directly like we do after match_hostname
@@ -514,5 +553,10 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
514553
ssl_sock.close()
515554
raise
516555

556+
if ssl_session_cache is not None:
557+
new_session = _get_ssl_session(ssl_sock)
558+
if new_session is not None:
559+
ssl_session_cache[0] = new_session
560+
517561
ssl_sock.settimeout(options.socket_timeout)
518562
return NetworkingInterface(ssl_sock)

pymongo/synchronous/pool.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,9 @@ def __init__(
752752
self._pending = 0
753753
self._max_connecting = self.opts.max_connecting
754754
self._client_id = client_id
755+
self._ssl_session_cache: Optional[list[Any]] = (
756+
[None] if self.opts._ssl_context is not None else None
757+
)
755758
# Log before publishing event to prevent potential listener preemption in tests
756759
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
757760
_debug_log(
@@ -1037,7 +1040,9 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect
10371040
listeners.publish_connection_created(self.address, conn_id)
10381041

10391042
try:
1040-
networking_interface = _configured_socket_interface(self.address, self.opts)
1043+
networking_interface = _configured_socket_interface(
1044+
self.address, self.opts, self._ssl_session_cache
1045+
)
10411046
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
10421047
except BaseException as error:
10431048
with self.lock:

0 commit comments

Comments
 (0)