]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- The engine-level error handling and wrapping routines will now
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 5 Dec 2014 17:12:44 +0000 (12:12 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 5 Dec 2014 17:12:44 +0000 (12:12 -0500)
take effect in all engine connection use cases, including
when user-custom connect routines are used via the
:paramref:`.create_engine.creator` parameter, as well as when
the :class:`.Connection` encounters a connection error on
revalidation.
fixes #3266

doc/build/changelog/changelog_10.rst
doc/build/changelog/migration_10.rst
lib/sqlalchemy/engine/base.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/engine/strategies.py
lib/sqlalchemy/engine/threadlocal.py
lib/sqlalchemy/events.py
test/engine/test_parseconnect.py

index b71ecc15db20e708dcb362ac68a938de5daff7e9..b8b51382166bcaa0e8942956120d0be6c4567c7b 100644 (file)
     series as well.  For changes that are specific to 1.0 with an emphasis
     on compatibility concerns, see :doc:`/changelog/migration_10`.
 
+    .. change::
+        :tags: bug, engine
+        :tickets: 3266
+
+        The engine-level error handling and wrapping routines will now
+        take effect in all engine connection use cases, including
+        when user-custom connect routines are used via the
+        :paramref:`.create_engine.creator` parameter, as well as when
+        the :class:`.Connection` encounters a connection error on
+        revalidation.
+
+        .. seealso::
+
+            :ref:`change_3266`
+
     .. change::
         :tags: feature, oracle
 
index 27a4fae4c6bf25f9e52abc4f71006f94ba77b242..15e066a75bae274cb1ac6b31d8db0bb4e727f551 100644 (file)
@@ -872,6 +872,29 @@ labeled uniquely.
 
 :ticket:`3170`
 
+.. _change_3266:
+
+DBAPI exception wrapping and handle_error() event improvements
+--------------------------------------------------------------
+
+SQLAlchemy's wrapping of DBAPI exceptions was not taking place in the
+case where a :class:`.Connection` object was invalidated, and then tried
+to reconnect and encountered an error; this has been resolved.
+
+Additionally, the recently added :meth:`.ConnectionEvents.handle_error`
+event is now invoked for errors that occur upon initial connect, upon
+reconnect, and when :func:`.create_engine` is used given a custom connection
+function via :paramref:`.create_engine.creator`.
+
+The :class:`.ExceptionContext` object has a new datamember
+:attr:`.ExceptionContext.engine` that will always refer to the :class:`.Engine`
+in use, in those cases when the :class:`.Connection` object is not available
+(e.g. on initial connect).
+
+
+:ticket:`3266`
+
+
 .. _behavioral_changes_orm_10:
 
 Behavioral Changes - ORM
index dd82be1d165065a67c42e3c3c1dda9ac6d49f4fe..901ab07eb34cec3196459fecb8c2ec92589fc715 100644 (file)
@@ -276,7 +276,7 @@ class Connection(Connectable):
                 raise exc.InvalidRequestError(
                     "Can't reconnect until invalid "
                     "transaction is rolled back")
-            self.__connection = self.engine.raw_connection()
+            self.__connection = self.engine.raw_connection(self)
             self.__invalid = False
             return self.__connection
         raise exc.ResourceClosedError("This Connection is closed")
@@ -1194,7 +1194,8 @@ class Connection(Connectable):
 
                 # new handle_error event
                 ctx = ExceptionContextImpl(
-                    e, sqlalchemy_exception, self, cursor, statement,
+                    e, sqlalchemy_exception, self.engine,
+                    self, cursor, statement,
                     parameters, context, self._is_disconnect)
 
                 for fn in self.dispatch.handle_error:
@@ -1242,6 +1243,58 @@ class Connection(Connectable):
             if self.should_close_with_result:
                 self.close()
 
+    @classmethod
+    def _handle_dbapi_exception_noconnection(
+            cls, e, dialect, engine, connection):
+        exc_info = sys.exc_info()
+
+        is_disconnect = dialect.is_disconnect(e, None, None)
+
+        should_wrap = isinstance(e, dialect.dbapi.Error)
+
+        if should_wrap:
+            sqlalchemy_exception = exc.DBAPIError.instance(
+                None,
+                None,
+                e,
+                dialect.dbapi.Error,
+                connection_invalidated=is_disconnect)
+        else:
+            sqlalchemy_exception = None
+
+        newraise = None
+
+        if engine._has_events:
+            ctx = ExceptionContextImpl(
+                e, sqlalchemy_exception, engine, connection, None, None,
+                None, None, is_disconnect)
+            for fn in engine.dispatch.handle_error:
+                try:
+                    # handler returns an exception;
+                    # call next handler in a chain
+                    per_fn = fn(ctx)
+                    if per_fn is not None:
+                        ctx.chained_exception = newraise = per_fn
+                except Exception as _raised:
+                    # handler raises an exception - stop processing
+                    newraise = _raised
+                    break
+
+            if sqlalchemy_exception and \
+                    is_disconnect != ctx.is_disconnect:
+                sqlalchemy_exception.connection_invalidated = \
+                    is_disconnect = ctx.is_disconnect
+
+        if newraise:
+            util.raise_from_cause(newraise, exc_info)
+        elif should_wrap:
+            util.raise_from_cause(
+                sqlalchemy_exception,
+                exc_info
+            )
+        else:
+            util.reraise(*exc_info)
+
     def default_schema_name(self):
         return self.engine.dialect.get_default_schema_name(self)
 
@@ -1320,8 +1373,9 @@ class ExceptionContextImpl(ExceptionContext):
     """Implement the :class:`.ExceptionContext` interface."""
 
     def __init__(self, exception, sqlalchemy_exception,
-                 connection, cursor, statement, parameters,
+                 engine, connection, cursor, statement, parameters,
                  context, is_disconnect):
+        self.engine = engine
         self.connection = connection
         self.sqlalchemy_exception = sqlalchemy_exception
         self.original_exception = exception
@@ -1898,7 +1952,15 @@ class Engine(Connectable, log.Identified):
         """
         return self.run_callable(self.dialect.has_table, table_name, schema)
 
-    def raw_connection(self):
+    def _wrap_pool_connect(self, fn, connection=None):
+        dialect = self.dialect
+        try:
+            return fn()
+        except dialect.dbapi.Error as e:
+            Connection._handle_dbapi_exception_noconnection(
+                e, dialect, self, connection)
+
+    def raw_connection(self, _connection=None):
         """Return a "raw" DBAPI connection from the connection pool.
 
         The returned object is a proxied version of the DBAPI
@@ -1914,8 +1976,8 @@ class Engine(Connectable, log.Identified):
         :meth:`.Engine.connect` method.
 
         """
-
-        return self.pool.unique_connection()
+        return self._wrap_pool_connect(
+            self.pool.unique_connection, _connection)
 
 
 class OptionEngine(Engine):
index 0ad2efae00d17563bbcac2fe85dd8f25ed880714..5f66e54b57169238859f8125f1da7b7ca2f303dd 100644 (file)
@@ -917,7 +917,23 @@ class ExceptionContext(object):
     connection = None
     """The :class:`.Connection` in use during the exception.
 
-    This member is always present.
+    This member is present, except in the case of a failure when
+    first connecting.
+
+    .. seealso::
+
+        :attr:`.ExceptionContext.engine`
+
+
+    """
+
+    engine = None
+    """The :class:`.Engine` in use during the exception.
+
+    This member should always be present, even in the case of a failure
+    when first connecting.
+
+    .. versionadded:: 1.0.0
 
     """
 
index 398ef8df60a3eacc4f91017cd83eadface986679..fd665ad0327e2586607210d38ecfbf70386687f8 100644 (file)
@@ -86,16 +86,7 @@ class DefaultEngineStrategy(EngineStrategy):
         pool = pop_kwarg('pool', None)
         if pool is None:
             def connect():
-                try:
-                    return dialect.connect(*cargs, **cparams)
-                except dialect.dbapi.Error as e:
-                    invalidated = dialect.is_disconnect(e, None, None)
-                    util.raise_from_cause(
-                        exc.DBAPIError.instance(
-                            None, None, e, dialect.dbapi.Error,
-                            connection_invalidated=invalidated
-                        )
-                    )
+                return dialect.connect(*cargs, **cparams)
 
             creator = pop_kwarg('creator', connect)
 
index 637523a0e887cbd8e986c29390946edf022d2da3..71caac62659744523c81ae8adad4fbca44789b61 100644 (file)
@@ -59,7 +59,7 @@ class TLEngine(base.Engine):
             # guards against pool-level reapers, if desired.
             # or not connection.connection.is_valid:
             connection = self._tl_connection_cls(
-                self, self.pool.connect(), **kw)
+                self, self._wrap_pool_connect(self.pool.connect), **kw)
             self._connections.conn = weakref.ref(connection)
 
         return connection._increment_connect()
index c144902cd57b17f5d07df3eda7337fe1d2e1ad1f..8600c20f52711b2e97bd3bf52a1843458fcad078 100644 (file)
@@ -739,6 +739,12 @@ class ConnectionEvents(event.Events):
         .. versionadded:: 0.9.7 Added the
             :meth:`.ConnectionEvents.handle_error` hook.
 
+        .. versionchanged:: 1.0.0 The :meth:`.handle_error` event is now
+           invoked when an :class:`.Engine` fails during the initial
+           call to :meth:`.Engine.connect`, as well as when a
+           :class:`.Connection` object encounters an error during a
+           reconnect operation.
+
         .. versionchanged:: 1.0.0 The :meth:`.handle_error` event is
            not fired off when a dialect makes use of the
            ``skip_user_error_events`` execution option.   This is used
index d8f202f99059be2dfef9ea87121843667790200f..72a089acaa934a598216e69a4136a2bbd0d51e7d 100644 (file)
@@ -6,6 +6,7 @@ import sqlalchemy as tsa
 from sqlalchemy.testing import fixtures
 from sqlalchemy import testing
 from sqlalchemy.testing.mock import Mock, MagicMock
+from sqlalchemy import event
 
 dialect = None
 
@@ -240,7 +241,6 @@ class CreateEngineTest(fixtures.TestBase):
     def test_wraps_connect_in_dbapi(self):
         e = create_engine('sqlite://')
         sqlite3 = e.dialect.dbapi
-
         dbapi = MockDBAPI()
         dbapi.Error = sqlite3.Error,
         dbapi.ProgrammingError = sqlite3.ProgrammingError
@@ -252,6 +252,117 @@ class CreateEngineTest(fixtures.TestBase):
         except tsa.exc.DBAPIError as de:
             assert not de.connection_invalidated
 
+    @testing.requires.sqlite
+    def test_handle_error_event_connect(self):
+        e = create_engine('sqlite://')
+        dbapi = MockDBAPI()
+        sqlite3 = e.dialect.dbapi
+        dbapi.Error = sqlite3.Error,
+        dbapi.ProgrammingError = sqlite3.ProgrammingError
+        dbapi.connect = Mock(
+            side_effect=sqlite3.ProgrammingError("random error"))
+
+        class MySpecialException(Exception):
+            pass
+
+        eng = create_engine('sqlite://', module=dbapi)
+
+        @event.listens_for(eng, "handle_error")
+        def handle_error(ctx):
+            assert ctx.engine is eng
+            assert ctx.connection is None
+            raise MySpecialException("failed operation")
+
+        assert_raises(
+            MySpecialException,
+            eng.connect
+        )
+
+    @testing.requires.sqlite
+    def test_handle_error_event_reconnect(self):
+        e = create_engine('sqlite://')
+        dbapi = MockDBAPI()
+        sqlite3 = e.dialect.dbapi
+        dbapi.Error = sqlite3.Error,
+        dbapi.ProgrammingError = sqlite3.ProgrammingError
+
+        class MySpecialException(Exception):
+            pass
+
+        eng = create_engine('sqlite://', module=dbapi, _initialize=False)
+
+        @event.listens_for(eng, "handle_error")
+        def handle_error(ctx):
+            assert ctx.engine is eng
+            assert ctx.connection is conn
+            raise MySpecialException("failed operation")
+
+        conn = eng.connect()
+        conn.invalidate()
+
+        dbapi.connect = Mock(
+            side_effect=sqlite3.ProgrammingError("random error"))
+
+        assert_raises(
+            MySpecialException,
+            conn._revalidate_connection
+        )
+
+    @testing.requires.sqlite
+    def test_handle_error_custom_connect(self):
+        e = create_engine('sqlite://')
+
+        dbapi = MockDBAPI()
+        sqlite3 = e.dialect.dbapi
+        dbapi.Error = sqlite3.Error,
+        dbapi.ProgrammingError = sqlite3.ProgrammingError
+
+        class MySpecialException(Exception):
+            pass
+
+        def custom_connect():
+            raise sqlite3.ProgrammingError("random error")
+
+        eng = create_engine('sqlite://', module=dbapi, creator=custom_connect)
+
+        @event.listens_for(eng, "handle_error")
+        def handle_error(ctx):
+            assert ctx.engine is eng
+            assert ctx.connection is None
+            raise MySpecialException("failed operation")
+
+        assert_raises(
+            MySpecialException,
+            eng.connect
+        )
+
+    @testing.requires.sqlite
+    def test_handle_error_event_connect_invalidate_flag(self):
+        e = create_engine('sqlite://')
+        dbapi = MockDBAPI()
+        sqlite3 = e.dialect.dbapi
+        dbapi.Error = sqlite3.Error,
+        dbapi.ProgrammingError = sqlite3.ProgrammingError
+        dbapi.connect = Mock(
+            side_effect=sqlite3.ProgrammingError(
+                "Cannot operate on a closed database."))
+
+        class MySpecialException(Exception):
+            pass
+
+        eng = create_engine('sqlite://', module=dbapi)
+
+        @event.listens_for(eng, "handle_error")
+        def handle_error(ctx):
+            assert ctx.is_disconnect
+            ctx.is_disconnect = False
+
+        try:
+            eng.connect()
+            assert False
+        except tsa.exc.DBAPIError as de:
+            assert not de.connection_invalidated
+
     @testing.requires.sqlite
     def test_dont_touch_non_dbapi_exception_on_connect(self):
         e = create_engine('sqlite://')