From ef2b7e5373f726ad81e8e5983d3df72ea12d4107 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 8 Jul 2026 08:02:35 -0500 Subject: [PATCH] PYTHON-5919 Recognize PyOpenSSL EOF errors as SystemOverloadedError Pool._handle_connection_error only exempted stdlib ssl.SSLEOFError/ SSLZeroReturnError from the SSL-error exclusion check. Under PyOpenSSL, a rate limiter closing the socket during the TLS handshake raises OpenSSL.SSL.SysCallError/ZeroReturnError instead, so the SystemOverloadedError label was never added and the pool was cleared. --- pymongo/asynchronous/pool.py | 4 +-- pymongo/pyopenssl_context.py | 4 +++ pymongo/ssl_support.py | 9 +++++- pymongo/synchronous/pool.py | 4 +-- test/asynchronous/test_pooling.py | 54 +++++++++++++++++++++++++++++++ test/test_pooling.py | 54 +++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 5 deletions(-) diff --git a/pymongo/asynchronous/pool.py b/pymongo/asynchronous/pool.py index 4ed3b85dbf..212f35f0d1 100644 --- a/pymongo/asynchronous/pool.py +++ b/pymongo/asynchronous/pool.py @@ -20,7 +20,6 @@ import logging import os import socket -import ssl import sys import time import weakref @@ -88,6 +87,7 @@ from pymongo.server_api import _add_to_command from pymongo.server_type import SERVER_TYPE from pymongo.socket_checker import SocketChecker +from pymongo.ssl_support import SSL_EOF_ERRORS if TYPE_CHECKING: from bson import CodecOptions @@ -976,7 +976,7 @@ def _handle_connection_error(self, error: BaseException) -> None: if isinstance(error.__cause__, (_CertificateError, SSLErrors, socket.gaierror)): # End of file errors are excluded, because the server may have disconnected # during the handshake. - if not isinstance(error.__cause__, (ssl.SSLEOFError, ssl.SSLZeroReturnError)): + if not isinstance(error.__cause__, SSL_EOF_ERRORS): return error._add_error_label("SystemOverloadedError") error._add_error_label("RetryableError") diff --git a/pymongo/pyopenssl_context.py b/pymongo/pyopenssl_context.py index 93990c250b..c33603dea3 100644 --- a/pymongo/pyopenssl_context.py +++ b/pymongo/pyopenssl_context.py @@ -102,6 +102,10 @@ def _ragged_eof(exc: BaseException) -> bool: return exc.args == (-1, "Unexpected EOF") +# PyOpenSSL's equivalent of stdlib's ssl.SSLEOFError/ssl.SSLZeroReturnError. +EOF_ERRORS = (_SSL.SysCallError, _SSL.ZeroReturnError) + + # https://github.com/pyca/pyopenssl/issues/168 # https://github.com/pyca/pyopenssl/issues/176 # https://docs.python.org/3/library/ssl.html#notes-on-non-blocking-sockets diff --git a/pymongo/ssl_support.py b/pymongo/ssl_support.py index 2e87ebe907..65196ef945 100644 --- a/pymongo/ssl_support.py +++ b/pymongo/ssl_support.py @@ -50,7 +50,7 @@ # CPython ssl module constants to configure certificate verification # at a high level. This is legacy behavior, but requires us to # import the ssl module even if we're only using it for this purpose. - import ssl as _stdlibssl # noqa: F401 + import ssl as _stdlibssl from ssl import CERT_NONE, CERT_REQUIRED IPADDR_SAFE = True @@ -68,11 +68,17 @@ _pyssl.BLOCKING_IO_WRITE_ERROR, _ssl.BLOCKING_IO_WRITE_ERROR, ) + SSL_EOF_ERRORS: tuple = ( # type: ignore[type-arg] + _stdlibssl.SSLEOFError, + _stdlibssl.SSLZeroReturnError, + *_pyssl.EOF_ERRORS, + ) else: PYSSLError = _ssl.SSLError BLOCKING_IO_ERRORS: tuple = _ssl.BLOCKING_IO_ERRORS # type: ignore[type-arg, no-redef] BLOCKING_IO_READ_ERROR: tuple = (_ssl.BLOCKING_IO_READ_ERROR,) # type: ignore[type-arg, no-redef] BLOCKING_IO_WRITE_ERROR: tuple = (_ssl.BLOCKING_IO_WRITE_ERROR,) # type: ignore[type-arg, no-redef] + SSL_EOF_ERRORS: tuple = (_stdlibssl.SSLEOFError, _stdlibssl.SSLZeroReturnError) # type: ignore[type-arg, no-redef] SSLError = _ssl.SSLError BLOCKING_IO_LOOKUP_ERROR = BLOCKING_IO_READ_ERROR @@ -138,6 +144,7 @@ class SSLError(Exception): # type: ignore IPADDR_SAFE = False BLOCKING_IO_ERRORS: tuple = () # type: ignore[type-arg, no-redef] + SSL_EOF_ERRORS: tuple = () # type: ignore[type-arg, no-redef] def _has_sni(is_sync: bool) -> bool: # noqa: ARG001 return False diff --git a/pymongo/synchronous/pool.py b/pymongo/synchronous/pool.py index 1006735444..f34a3341f3 100644 --- a/pymongo/synchronous/pool.py +++ b/pymongo/synchronous/pool.py @@ -20,7 +20,6 @@ import logging import os import socket -import ssl import sys import time import weakref @@ -85,6 +84,7 @@ from pymongo.server_api import _add_to_command from pymongo.server_type import SERVER_TYPE from pymongo.socket_checker import SocketChecker +from pymongo.ssl_support import SSL_EOF_ERRORS from pymongo.synchronous.client_session import _validate_session_write_concern from pymongo.synchronous.command_runner import run_command from pymongo.synchronous.helpers import _handle_reauth @@ -972,7 +972,7 @@ def _handle_connection_error(self, error: BaseException) -> None: if isinstance(error.__cause__, (_CertificateError, SSLErrors, socket.gaierror)): # End of file errors are excluded, because the server may have disconnected # during the handshake. - if not isinstance(error.__cause__, (ssl.SSLEOFError, ssl.SSLZeroReturnError)): + if not isinstance(error.__cause__, SSL_EOF_ERRORS): return error._add_error_label("SystemOverloadedError") error._add_error_label("RetryableError") diff --git a/test/asynchronous/test_pooling.py b/test/asynchronous/test_pooling.py index 96b603ec10..af9a1bbd00 100644 --- a/test/asynchronous/test_pooling.py +++ b/test/asynchronous/test_pooling.py @@ -22,6 +22,7 @@ import platform import random import socket +import ssl import sys import time @@ -41,6 +42,13 @@ from test.asynchronous.helpers import ConcurrentRunner from test.utils_shared import delay +try: + import OpenSSL + + _HAVE_PYOPENSSL = True +except ImportError: + _HAVE_PYOPENSSL = False + _IS_SYNC = False @@ -651,5 +659,51 @@ async def test_max_pool_size_with_connection_failure(self): self.assertNotIn("waiting for socket from pool", str(context.exception)) +class TestPoolHandleConnectionError(unittest.TestCase): + """PYTHON-5919: PyOpenSSL raises OpenSSL.SSL.SysCallError/ZeroReturnError + (not ssl.SSLEOFError/ssl.SSLZeroReturnError) when the server closes the + socket during the TLS handshake, e.g. when an ingress rate limiter rejects + a connection. Pool._handle_connection_error must recognize these as + handshake-EOF errors and still add the SystemOverloadedError label. + """ + + def _make_pool(self): + return Pool(("localhost", 27017), PoolOptions()) + + def test_stdlib_ssl_eof_error_is_labeled_overloaded(self): + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ssl.SSLEOFError("EOF occurred in violation of protocol") + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + def test_pyopenssl_syscall_error_is_labeled_overloaded(self): + from OpenSSL.SSL import SysCallError + + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = SysCallError(-1, "Unexpected EOF") + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + def test_pyopenssl_zero_return_error_is_labeled_overloaded(self): + from OpenSSL.SSL import ZeroReturnError + + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ZeroReturnError() + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + def test_certificate_error_is_not_labeled_overloaded(self): + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ssl.SSLCertVerificationError("certificate verify failed") + pool._handle_connection_error(err) + self.assertFalse(err.has_error_label("SystemOverloadedError")) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_pooling.py b/test/test_pooling.py index 47266dd166..d63f48477c 100644 --- a/test/test_pooling.py +++ b/test/test_pooling.py @@ -22,6 +22,7 @@ import platform import random import socket +import ssl import sys import time @@ -41,6 +42,13 @@ from test.helpers import ConcurrentRunner from test.utils_shared import delay +try: + import OpenSSL + + _HAVE_PYOPENSSL = True +except ImportError: + _HAVE_PYOPENSSL = False + _IS_SYNC = True @@ -649,5 +657,51 @@ def test_max_pool_size_with_connection_failure(self): self.assertNotIn("waiting for socket from pool", str(context.exception)) +class TestPoolHandleConnectionError(unittest.TestCase): + """PYTHON-5919: PyOpenSSL raises OpenSSL.SSL.SysCallError/ZeroReturnError + (not ssl.SSLEOFError/ssl.SSLZeroReturnError) when the server closes the + socket during the TLS handshake, e.g. when an ingress rate limiter rejects + a connection. Pool._handle_connection_error must recognize these as + handshake-EOF errors and still add the SystemOverloadedError label. + """ + + def _make_pool(self): + return Pool(("localhost", 27017), PoolOptions()) + + def test_stdlib_ssl_eof_error_is_labeled_overloaded(self): + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ssl.SSLEOFError("EOF occurred in violation of protocol") + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + def test_pyopenssl_syscall_error_is_labeled_overloaded(self): + from OpenSSL.SSL import SysCallError + + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = SysCallError(-1, "Unexpected EOF") + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + def test_pyopenssl_zero_return_error_is_labeled_overloaded(self): + from OpenSSL.SSL import ZeroReturnError + + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ZeroReturnError() + pool._handle_connection_error(err) + self.assertTrue(err.has_error_label("SystemOverloadedError")) + + def test_certificate_error_is_not_labeled_overloaded(self): + pool = self._make_pool() + err = AutoReconnect("connection closed") + err.__cause__ = ssl.SSLCertVerificationError("certificate verify failed") + pool._handle_connection_error(err) + self.assertFalse(err.has_error_label("SystemOverloadedError")) + + if __name__ == "__main__": unittest.main()