Skip to content

Commit 4b45e4b

Browse files
authored
PYTHON-5919 Recognize PyOpenSSL EOF errors as SystemOverloadedError (#2921)
1 parent 64e24c9 commit 4b45e4b

6 files changed

Lines changed: 124 additions & 5 deletions

File tree

pymongo/asynchronous/pool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import logging
2121
import os
2222
import socket
23-
import ssl
2423
import sys
2524
import time
2625
import weakref
@@ -88,6 +87,7 @@
8887
from pymongo.server_api import _add_to_command
8988
from pymongo.server_type import SERVER_TYPE
9089
from pymongo.socket_checker import SocketChecker
90+
from pymongo.ssl_support import SSL_EOF_ERRORS
9191

9292
if TYPE_CHECKING:
9393
from bson import CodecOptions
@@ -976,7 +976,7 @@ def _handle_connection_error(self, error: BaseException) -> None:
976976
if isinstance(error.__cause__, (_CertificateError, SSLErrors, socket.gaierror)):
977977
# End of file errors are excluded, because the server may have disconnected
978978
# during the handshake.
979-
if not isinstance(error.__cause__, (ssl.SSLEOFError, ssl.SSLZeroReturnError)):
979+
if not isinstance(error.__cause__, SSL_EOF_ERRORS):
980980
return
981981
error._add_error_label("SystemOverloadedError")
982982
error._add_error_label("RetryableError")

pymongo/pyopenssl_context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ def _ragged_eof(exc: BaseException) -> bool:
102102
return exc.args == (-1, "Unexpected EOF")
103103

104104

105+
# PyOpenSSL's equivalent of stdlib's ssl.SSLEOFError/ssl.SSLZeroReturnError.
106+
EOF_ERRORS = (_SSL.SysCallError, _SSL.ZeroReturnError)
107+
108+
105109
# https://github.com/pyca/pyopenssl/issues/168
106110
# https://github.com/pyca/pyopenssl/issues/176
107111
# https://docs.python.org/3/library/ssl.html#notes-on-non-blocking-sockets

pymongo/ssl_support.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
# CPython ssl module constants to configure certificate verification
5151
# at a high level. This is legacy behavior, but requires us to
5252
# import the ssl module even if we're only using it for this purpose.
53-
import ssl as _stdlibssl # noqa: F401
53+
import ssl as _stdlibssl
5454
from ssl import CERT_NONE, CERT_REQUIRED
5555

5656
IPADDR_SAFE = True
@@ -68,11 +68,17 @@
6868
_pyssl.BLOCKING_IO_WRITE_ERROR,
6969
_ssl.BLOCKING_IO_WRITE_ERROR,
7070
)
71+
SSL_EOF_ERRORS: tuple = ( # type: ignore[type-arg]
72+
_stdlibssl.SSLEOFError,
73+
_stdlibssl.SSLZeroReturnError,
74+
*_pyssl.EOF_ERRORS,
75+
)
7176
else:
7277
PYSSLError = _ssl.SSLError
7378
BLOCKING_IO_ERRORS: tuple = _ssl.BLOCKING_IO_ERRORS # type: ignore[type-arg, no-redef]
7479
BLOCKING_IO_READ_ERROR: tuple = (_ssl.BLOCKING_IO_READ_ERROR,) # type: ignore[type-arg, no-redef]
7580
BLOCKING_IO_WRITE_ERROR: tuple = (_ssl.BLOCKING_IO_WRITE_ERROR,) # type: ignore[type-arg, no-redef]
81+
SSL_EOF_ERRORS: tuple = (_stdlibssl.SSLEOFError, _stdlibssl.SSLZeroReturnError) # type: ignore[type-arg, no-redef]
7682
SSLError = _ssl.SSLError
7783
BLOCKING_IO_LOOKUP_ERROR = BLOCKING_IO_READ_ERROR
7884

@@ -138,6 +144,7 @@ class SSLError(Exception): # type: ignore
138144

139145
IPADDR_SAFE = False
140146
BLOCKING_IO_ERRORS: tuple = () # type: ignore[type-arg, no-redef]
147+
SSL_EOF_ERRORS: tuple = () # type: ignore[type-arg, no-redef]
141148

142149
def _has_sni(is_sync: bool) -> bool: # noqa: ARG001
143150
return False

pymongo/synchronous/pool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import logging
2121
import os
2222
import socket
23-
import ssl
2423
import sys
2524
import time
2625
import weakref
@@ -85,6 +84,7 @@
8584
from pymongo.server_api import _add_to_command
8685
from pymongo.server_type import SERVER_TYPE
8786
from pymongo.socket_checker import SocketChecker
87+
from pymongo.ssl_support import SSL_EOF_ERRORS
8888
from pymongo.synchronous.client_session import _validate_session_write_concern
8989
from pymongo.synchronous.command_runner import run_command
9090
from pymongo.synchronous.helpers import _handle_reauth
@@ -972,7 +972,7 @@ def _handle_connection_error(self, error: BaseException) -> None:
972972
if isinstance(error.__cause__, (_CertificateError, SSLErrors, socket.gaierror)):
973973
# End of file errors are excluded, because the server may have disconnected
974974
# during the handshake.
975-
if not isinstance(error.__cause__, (ssl.SSLEOFError, ssl.SSLZeroReturnError)):
975+
if not isinstance(error.__cause__, SSL_EOF_ERRORS):
976976
return
977977
error._add_error_label("SystemOverloadedError")
978978
error._add_error_label("RetryableError")

test/asynchronous/test_pooling.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import platform
2323
import random
2424
import socket
25+
import ssl
2526
import sys
2627
import time
2728

@@ -41,6 +42,13 @@
4142
from test.asynchronous.helpers import ConcurrentRunner
4243
from test.utils_shared import delay
4344

45+
try:
46+
import OpenSSL
47+
48+
_HAVE_PYOPENSSL = True
49+
except ImportError:
50+
_HAVE_PYOPENSSL = False
51+
4452
_IS_SYNC = False
4553

4654

@@ -651,5 +659,51 @@ async def test_max_pool_size_with_connection_failure(self):
651659
self.assertNotIn("waiting for socket from pool", str(context.exception))
652660

653661

662+
class TestPoolHandleConnectionError(unittest.TestCase):
663+
"""PYTHON-5919: PyOpenSSL raises OpenSSL.SSL.SysCallError/ZeroReturnError
664+
(not ssl.SSLEOFError/ssl.SSLZeroReturnError) when the server closes the
665+
socket during the TLS handshake, e.g. when an ingress rate limiter rejects
666+
a connection. Pool._handle_connection_error must recognize these as
667+
handshake-EOF errors and still add the SystemOverloadedError label.
668+
"""
669+
670+
def _make_pool(self):
671+
return Pool(("localhost", 27017), PoolOptions())
672+
673+
def test_stdlib_ssl_eof_error_is_labeled_overloaded(self):
674+
pool = self._make_pool()
675+
err = AutoReconnect("connection closed")
676+
err.__cause__ = ssl.SSLEOFError("EOF occurred in violation of protocol")
677+
pool._handle_connection_error(err)
678+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
679+
680+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
681+
def test_pyopenssl_syscall_error_is_labeled_overloaded(self):
682+
from OpenSSL.SSL import SysCallError
683+
684+
pool = self._make_pool()
685+
err = AutoReconnect("connection closed")
686+
err.__cause__ = SysCallError(-1, "Unexpected EOF")
687+
pool._handle_connection_error(err)
688+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
689+
690+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
691+
def test_pyopenssl_zero_return_error_is_labeled_overloaded(self):
692+
from OpenSSL.SSL import ZeroReturnError
693+
694+
pool = self._make_pool()
695+
err = AutoReconnect("connection closed")
696+
err.__cause__ = ZeroReturnError()
697+
pool._handle_connection_error(err)
698+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
699+
700+
def test_certificate_error_is_not_labeled_overloaded(self):
701+
pool = self._make_pool()
702+
err = AutoReconnect("connection closed")
703+
err.__cause__ = ssl.SSLCertVerificationError("certificate verify failed")
704+
pool._handle_connection_error(err)
705+
self.assertFalse(err.has_error_label("SystemOverloadedError"))
706+
707+
654708
if __name__ == "__main__":
655709
unittest.main()

test/test_pooling.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import platform
2323
import random
2424
import socket
25+
import ssl
2526
import sys
2627
import time
2728

@@ -41,6 +42,13 @@
4142
from test.helpers import ConcurrentRunner
4243
from test.utils_shared import delay
4344

45+
try:
46+
import OpenSSL
47+
48+
_HAVE_PYOPENSSL = True
49+
except ImportError:
50+
_HAVE_PYOPENSSL = False
51+
4452
_IS_SYNC = True
4553

4654

@@ -649,5 +657,51 @@ def test_max_pool_size_with_connection_failure(self):
649657
self.assertNotIn("waiting for socket from pool", str(context.exception))
650658

651659

660+
class TestPoolHandleConnectionError(unittest.TestCase):
661+
"""PYTHON-5919: PyOpenSSL raises OpenSSL.SSL.SysCallError/ZeroReturnError
662+
(not ssl.SSLEOFError/ssl.SSLZeroReturnError) when the server closes the
663+
socket during the TLS handshake, e.g. when an ingress rate limiter rejects
664+
a connection. Pool._handle_connection_error must recognize these as
665+
handshake-EOF errors and still add the SystemOverloadedError label.
666+
"""
667+
668+
def _make_pool(self):
669+
return Pool(("localhost", 27017), PoolOptions())
670+
671+
def test_stdlib_ssl_eof_error_is_labeled_overloaded(self):
672+
pool = self._make_pool()
673+
err = AutoReconnect("connection closed")
674+
err.__cause__ = ssl.SSLEOFError("EOF occurred in violation of protocol")
675+
pool._handle_connection_error(err)
676+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
677+
678+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
679+
def test_pyopenssl_syscall_error_is_labeled_overloaded(self):
680+
from OpenSSL.SSL import SysCallError
681+
682+
pool = self._make_pool()
683+
err = AutoReconnect("connection closed")
684+
err.__cause__ = SysCallError(-1, "Unexpected EOF")
685+
pool._handle_connection_error(err)
686+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
687+
688+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
689+
def test_pyopenssl_zero_return_error_is_labeled_overloaded(self):
690+
from OpenSSL.SSL import ZeroReturnError
691+
692+
pool = self._make_pool()
693+
err = AutoReconnect("connection closed")
694+
err.__cause__ = ZeroReturnError()
695+
pool._handle_connection_error(err)
696+
self.assertTrue(err.has_error_label("SystemOverloadedError"))
697+
698+
def test_certificate_error_is_not_labeled_overloaded(self):
699+
pool = self._make_pool()
700+
err = AutoReconnect("connection closed")
701+
err.__cause__ = ssl.SSLCertVerificationError("certificate verify failed")
702+
pool._handle_connection_error(err)
703+
self.assertFalse(err.has_error_label("SystemOverloadedError"))
704+
705+
652706
if __name__ == "__main__":
653707
unittest.main()

0 commit comments

Comments
 (0)