]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add disconnect error handling for mssqlpython dialect
authorSam Debruyn <sam@devbox.debruyn.lan>
Tue, 28 Jul 2026 18:07:23 +0000 (14:07 -0400)
committerMichael Bayer <mike_mp@zzzcomputing.com>
Wed, 12 Aug 2026 21:12:02 +0000 (21:12 +0000)
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

doc/build/changelog/unreleased_21/13441.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/mssql/mssqlpython.py
test/dialect/mssql/test_engine.py

diff --git a/doc/build/changelog/unreleased_21/13441.rst b/doc/build/changelog/unreleased_21/13441.rst
new file mode 100644 (file)
index 0000000..83b0a77
--- /dev/null
@@ -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.
index ff4dd3589f5960ec42fbb3d85d46a62f97450fa2..2eef40b41a9674d3313cfefa66da411bd97bf6c5 100644 (file)
@@ -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
index d0f45cffede2a2e6afd06bdc0b53f4767b5d5985..f906049ea318cd2f954ddc3f8d6288dca14f3d62 100644 (file)
@@ -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"