From: Sam Debruyn Date: Tue, 28 Jul 2026 18:07:23 +0000 (-0400) Subject: Add disconnect error handling for mssqlpython dialect X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=4b486036a2661b0d0078a90765b3b435a8c3cefb;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add disconnect error handling for mssqlpython dialect Improved disconnect detection for the ``mssql+mssqlpython`` dialect. Connection-level failures such as a dropped or reset network connection are now recognized by consulting the ``driver_error`` attribute of the exception, in addition to the message-based checks that were already in place, so that the affected connection is invalidated and the pool "pre ping" feature is able to recycle it. Pull request courtesy Sam Debruyn. Fixes #13441 Closes: #13442 Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/13442 Pull-request-sha: f1d6b6a4a82afef454837d5f6081b73085af88aa Change-Id: Ic67732d6c3db16ea4595f322f6c10eaa8d6090fe --- diff --git a/doc/build/changelog/unreleased_21/13441.rst b/doc/build/changelog/unreleased_21/13441.rst new file mode 100644 index 0000000000..83b0a77be7 --- /dev/null +++ b/doc/build/changelog/unreleased_21/13441.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: bug, mssql + :tickets: 13441 + + Improved disconnect detection for the ``mssql+mssqlpython`` dialect. + Connection-level failures such as a dropped or reset network connection + are now recognized by consulting the ``driver_error`` attribute of the + exception, in addition to the message-based checks that were already in + place, so that the affected connection is invalidated and the pool + "pre ping" feature is able to recycle it. Pull request courtesy Sam + Debruyn. diff --git a/lib/sqlalchemy/dialects/mssql/mssqlpython.py b/lib/sqlalchemy/dialects/mssql/mssqlpython.py index ff4dd3589f..2eef40b41a 100644 --- a/lib/sqlalchemy/dialects/mssql/mssqlpython.py +++ b/lib/sqlalchemy/dialects/mssql/mssqlpython.py @@ -83,6 +83,24 @@ class MSDialect_mssqlpython(MSDialect): supports_native_decimal = True + _disconnect_driver_errors = frozenset( + { + # mssql-python converts the originating SQLSTATE into these + # values and exposes them on ``Exception.driver_error``. + "Disconnect error", # 01002 + "Client unable to establish connection", # 08001 + "Connection not open", # 08003 + "Connection failure during transaction", # 08007 + "Communication link failure", # 08S01 + # mssql-python does not map these SQLSTATE values to a + # dedicated driver error yet. + "An error occurred with SQLSTATE code: 08S02", + "An error occurred with SQLSTATE code: 10054", + "Connection timeout expired", # HYT01 + "Function sequence error", # HY010 + } + ) + # used by pyodbc _ms_numeric_pyodbc class _need_decimal_fix = True @@ -163,16 +181,25 @@ class MSDialect_mssqlpython(MSDialect): ], cursor: Optional[interfaces.DBAPICursor], ) -> bool: + if not isinstance(e, self.loaded_dbapi.Error): + return False + + if getattr(e, "driver_error", None) in self._disconnect_driver_errors: + return True + if isinstance(e, self.loaded_dbapi.ProgrammingError): return ( "The cursor's connection has been closed." in str(e) or "Attempt to use a closed connection." in str(e) or "Driver Error: Operation cannot be performed" in str(e) ) - elif isinstance(e, self.loaded_dbapi.InterfaceError): - return bool(re.search(r"Cannot .* on closed connection", str(e))) - else: - return False + + if isinstance(e, self.loaded_dbapi.InterfaceError): + return bool( + re.search(r"Cannot .* on (?:a )?closed connection", str(e)) + ) + + return False def retrieve_dbapi_version( self, dbapi: interfaces.DBAPIModule diff --git a/test/dialect/mssql/test_engine.py b/test/dialect/mssql/test_engine.py index d0f45cffed..f906049ea3 100644 --- a/test/dialect/mssql/test_engine.py +++ b/test/dialect/mssql/test_engine.py @@ -533,6 +533,93 @@ class ParseConnectTest(fixtures.TestBase): False, ) + @testing.fixture + def mssqlpython_dialect(self): + """dialect with a mocked out mssql_python DBAPI. + + the exception hierarchy mirrors that of mssql_python, where every + error carries the driver level and DDBC level messages both as + attributes and within the string form of the exception. + + """ + + class Error(Exception): + def __init__(self, driver_error, ddbc_error=""): + self.driver_error = driver_error + self.ddbc_error = ddbc_error + super().__init__( + f"Driver Error: {driver_error}; " + f"DDBC Error: {ddbc_error}" + ) + + dbapi = mock.Mock() + dbapi.Error = Error + dbapi.OperationalError = type("OperationalError", (Error,), {}) + dbapi.ProgrammingError = type("ProgrammingError", (Error,), {}) + dbapi.InterfaceError = type("InterfaceError", (Error,), {}) + + return mssqlpython.dialect(dbapi=dbapi) + + @testing.combinations( + ("OperationalError", "Disconnect error", True), + ("OperationalError", "Client unable to establish connection", True), + ("OperationalError", "Connection not open", True), + ("OperationalError", "Connection failure during transaction", True), + ("OperationalError", "Communication link failure", True), + ( + "OperationalError", + "An error occurred with SQLSTATE code: 08S02", + True, + ), + ( + "OperationalError", + "An error occurred with SQLSTATE code: 10054", + True, + ), + ("OperationalError", "Connection timeout expired", True), + ("OperationalError", "Function sequence error", True), + ("OperationalError", "Timeout expired", False), + ("OperationalError", "Syntax error or access violation", False), + ("ProgrammingError", "The cursor's connection has been closed.", True), + ("ProgrammingError", "Attempt to use a closed connection.", True), + ("ProgrammingError", "Operation cannot be performed", True), + ("ProgrammingError", "Invalid object name 'foo'", False), + ("InterfaceError", "Cannot rollback on a closed connection", True), + ("InterfaceError", "Cannot commit on closed connection", True), + ("InterfaceError", "Invalid connection attribute", False), + argnames="exc_cls_name,driver_error,expected", + ) + def test_mssqlpython_disconnect( + self, mssqlpython_dialect, exc_cls_name, driver_error, expected + ): + dialect = mssqlpython_dialect + error = getattr(dialect.loaded_dbapi, exc_cls_name)(driver_error) + + eq_(dialect.is_disconnect(error, None, None), expected) + + def test_mssqlpython_disconnect_ddbc_message_only( + self, mssqlpython_dialect + ): + """a disconnect phrase that occurs only in the DDBC level message of + an otherwise unrelated error is not a disconnect. + + """ + dialect = mssqlpython_dialect + error = dialect.loaded_dbapi.OperationalError( + "Syntax error or access violation", + "Communication link failure in query text", + ) + + eq_(dialect.is_disconnect(error, None, None), False) + + def test_mssqlpython_disconnect_not_dbapi_error(self, mssqlpython_dialect): + dialect = mssqlpython_dialect + + eq_( + dialect.is_disconnect(Exception("Disconnect error"), None, None), + False, + ) + class FastExecutemanyTest(fixtures.TestBase): __only_on__ = "mssql"