diff options
Diffstat (limited to 'oslo_db/sqlalchemy/exc_filters.py')
-rw-r--r-- | oslo_db/sqlalchemy/exc_filters.py | 358 |
1 files changed, 358 insertions, 0 deletions
diff --git a/oslo_db/sqlalchemy/exc_filters.py b/oslo_db/sqlalchemy/exc_filters.py new file mode 100644 index 0000000..efdbb2f --- /dev/null +++ b/oslo_db/sqlalchemy/exc_filters.py @@ -0,0 +1,358 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Define exception redefinitions for SQLAlchemy DBAPI exceptions.""" + +import collections +import logging +import re + +from sqlalchemy import exc as sqla_exc + +from oslo_db._i18n import _LE +from oslo_db import exception +from oslo_db.sqlalchemy import compat + + +LOG = logging.getLogger(__name__) + + +_registry = collections.defaultdict( + lambda: collections.defaultdict( + list + ) +) + + +def filters(dbname, exception_type, regex): + """Mark a function as receiving a filtered exception. + + :param dbname: string database name, e.g. 'mysql' + :param exception_type: a SQLAlchemy database exception class, which + extends from :class:`sqlalchemy.exc.DBAPIError`. + :param regex: a string, or a tuple of strings, that will be processed + as matching regular expressions. + + """ + def _receive(fn): + _registry[dbname][exception_type].extend( + (fn, re.compile(reg)) + for reg in + ((regex,) if not isinstance(regex, tuple) else regex) + ) + return fn + return _receive + + +# NOTE(zzzeek) - for Postgresql, catch both OperationalError, as the +# actual error is +# psycopg2.extensions.TransactionRollbackError(OperationalError), +# as well as sqlalchemy.exc.DBAPIError, as SQLAlchemy will reraise it +# as this until issue #3075 is fixed. +@filters("mysql", sqla_exc.OperationalError, r"^.*\b1213\b.*Deadlock found.*") +@filters("mysql", sqla_exc.OperationalError, + r"^.*\b1205\b.*Lock wait timeout exceeded.*") +@filters("mysql", sqla_exc.InternalError, r"^.*\b1213\b.*Deadlock found.*") +@filters("postgresql", sqla_exc.OperationalError, r"^.*deadlock detected.*") +@filters("postgresql", sqla_exc.DBAPIError, r"^.*deadlock detected.*") +@filters("ibm_db_sa", sqla_exc.DBAPIError, r"^.*SQL0911N.*") +def _deadlock_error(operational_error, match, engine_name, is_disconnect): + """Filter for MySQL or Postgresql deadlock error. + + NOTE(comstud): In current versions of DB backends, Deadlock violation + messages follow the structure: + + mysql+mysqldb: + (OperationalError) (1213, 'Deadlock found when trying to get lock; try ' + 'restarting transaction') <query_str> <query_args> + + mysql+mysqlconnector: + (InternalError) 1213 (40001): Deadlock found when trying to get lock; try + restarting transaction + + postgresql: + (TransactionRollbackError) deadlock detected <deadlock_details> + + + ibm_db_sa: + SQL0911N The current transaction has been rolled back because of a + deadlock or timeout <deadlock details> + + """ + raise exception.DBDeadlock(operational_error) + + +@filters("mysql", sqla_exc.IntegrityError, + r"^.*\b1062\b.*Duplicate entry '(?P<value>[^']+)'" + r" for key '(?P<columns>[^']+)'.*$") +# NOTE(pkholkin): the first regex is suitable only for PostgreSQL 9.x versions +# the second regex is suitable for PostgreSQL 8.x versions +@filters("postgresql", sqla_exc.IntegrityError, + (r'^.*duplicate\s+key.*"(?P<columns>[^"]+)"\s*\n.*' + r'Key\s+\((?P<key>.*)\)=\((?P<value>.*)\)\s+already\s+exists.*$', + r"^.*duplicate\s+key.*\"(?P<columns>[^\"]+)\"\s*\n.*$")) +def _default_dupe_key_error(integrity_error, match, engine_name, + is_disconnect): + """Filter for MySQL or Postgresql duplicate key error. + + note(boris-42): In current versions of DB backends unique constraint + violation messages follow the structure: + + postgres: + 1 column - (IntegrityError) duplicate key value violates unique + constraint "users_c1_key" + N columns - (IntegrityError) duplicate key value violates unique + constraint "name_of_our_constraint" + + mysql+mysqldb: + 1 column - (IntegrityError) (1062, "Duplicate entry 'value_of_c1' for key + 'c1'") + N columns - (IntegrityError) (1062, "Duplicate entry 'values joined + with -' for key 'name_of_our_constraint'") + + mysql+mysqlconnector: + 1 column - (IntegrityError) 1062 (23000): Duplicate entry 'value_of_c1' for + key 'c1' + N columns - (IntegrityError) 1062 (23000): Duplicate entry 'values + joined with -' for key 'name_of_our_constraint' + + + + """ + + columns = match.group('columns') + + # note(vsergeyev): UniqueConstraint name convention: "uniq_t0c10c2" + # where `t` it is table name and columns `c1`, `c2` + # are in UniqueConstraint. + uniqbase = "uniq_" + if not columns.startswith(uniqbase): + if engine_name == "postgresql": + columns = [columns[columns.index("_") + 1:columns.rindex("_")]] + else: + columns = [columns] + else: + columns = columns[len(uniqbase):].split("0")[1:] + + value = match.groupdict().get('value') + + raise exception.DBDuplicateEntry(columns, integrity_error, value) + + +@filters("sqlite", sqla_exc.IntegrityError, + (r"^.*columns?(?P<columns>[^)]+)(is|are)\s+not\s+unique$", + r"^.*UNIQUE\s+constraint\s+failed:\s+(?P<columns>.+)$", + r"^.*PRIMARY\s+KEY\s+must\s+be\s+unique.*$")) +def _sqlite_dupe_key_error(integrity_error, match, engine_name, is_disconnect): + """Filter for SQLite duplicate key error. + + note(boris-42): In current versions of DB backends unique constraint + violation messages follow the structure: + + sqlite: + 1 column - (IntegrityError) column c1 is not unique + N columns - (IntegrityError) column c1, c2, ..., N are not unique + + sqlite since 3.7.16: + 1 column - (IntegrityError) UNIQUE constraint failed: tbl.k1 + N columns - (IntegrityError) UNIQUE constraint failed: tbl.k1, tbl.k2 + + sqlite since 3.8.2: + (IntegrityError) PRIMARY KEY must be unique + + """ + columns = [] + # NOTE(ochuprykov): We can get here by last filter in which there are no + # groups. Trying to access the substring that matched by + # the group will lead to IndexError. In this case just + # pass empty list to exception.DBDuplicateEntry + try: + columns = match.group('columns') + columns = [c.split('.')[-1] for c in columns.strip().split(", ")] + except IndexError: + pass + + raise exception.DBDuplicateEntry(columns, integrity_error) + + +@filters("sqlite", sqla_exc.IntegrityError, + r"(?i).*foreign key constraint failed") +@filters("postgresql", sqla_exc.IntegrityError, + r".*on table \"(?P<table>[^\"]+)\" violates " + "foreign key constraint \"(?P<constraint>[^\"]+)\"\s*\n" + "DETAIL: Key \((?P<key>.+)\)=\(.+\) " + "is not present in table " + "\"(?P<key_table>[^\"]+)\".") +@filters("mysql", sqla_exc.IntegrityError, + r".* 'Cannot add or update a child row: " + 'a foreign key constraint fails \([`"].+[`"]\.[`"](?P<table>.+)[`"], ' + 'CONSTRAINT [`"](?P<constraint>.+)[`"] FOREIGN KEY ' + '\([`"](?P<key>.+)[`"]\) REFERENCES [`"](?P<key_table>.+)[`"] ') +def _foreign_key_error(integrity_error, match, engine_name, is_disconnect): + """Filter for foreign key errors.""" + + try: + table = match.group("table") + except IndexError: + table = None + try: + constraint = match.group("constraint") + except IndexError: + constraint = None + try: + key = match.group("key") + except IndexError: + key = None + try: + key_table = match.group("key_table") + except IndexError: + key_table = None + + raise exception.DBReferenceError(table, constraint, key, key_table, + integrity_error) + + +@filters("ibm_db_sa", sqla_exc.IntegrityError, r"^.*SQL0803N.*$") +def _db2_dupe_key_error(integrity_error, match, engine_name, is_disconnect): + """Filter for DB2 duplicate key errors. + + N columns - (IntegrityError) SQL0803N One or more values in the INSERT + statement, UPDATE statement, or foreign key update caused by a + DELETE statement are not valid because the primary key, unique + constraint or unique index identified by "2" constrains table + "NOVA.KEY_PAIRS" from having duplicate values for the index + key. + + """ + + # NOTE(mriedem): The ibm_db_sa integrity error message doesn't provide the + # columns so we have to omit that from the DBDuplicateEntry error. + raise exception.DBDuplicateEntry([], integrity_error) + + +@filters("mysql", sqla_exc.DBAPIError, r".*\b1146\b") +def _raise_mysql_table_doesnt_exist_asis( + error, match, engine_name, is_disconnect): + """Raise MySQL error 1146 as is. + + Raise MySQL error 1146 as is, so that it does not conflict with + the MySQL dialect's checking a table not existing. + """ + + raise error + + +@filters("*", sqla_exc.OperationalError, r".*") +def _raise_operational_errors_directly_filter(operational_error, + match, engine_name, + is_disconnect): + """Filter for all remaining OperationalError classes and apply. + + Filter for all remaining OperationalError classes and apply + special rules. + """ + if is_disconnect: + # operational errors that represent disconnect + # should be wrapped + raise exception.DBConnectionError(operational_error) + else: + # NOTE(comstud): A lot of code is checking for OperationalError + # so let's not wrap it for now. + raise operational_error + + +@filters("mysql", sqla_exc.OperationalError, r".*\(.*(?:2002|2003|2006|2013)") +@filters("ibm_db_sa", sqla_exc.OperationalError, r".*(?:30081)") +def _is_db_connection_error(operational_error, match, engine_name, + is_disconnect): + """Detect the exception as indicating a recoverable error on connect.""" + raise exception.DBConnectionError(operational_error) + + +@filters("*", sqla_exc.DBAPIError, r".*") +def _raise_for_remaining_DBAPIError(error, match, engine_name, is_disconnect): + """Filter for remaining DBAPIErrors. + + Filter for remaining DBAPIErrors and wrap if they represent + a disconnect error. + """ + if is_disconnect: + raise exception.DBConnectionError(error) + else: + LOG.exception( + _LE('DBAPIError exception wrapped from %s') % error) + raise exception.DBError(error) + + +@filters('*', UnicodeEncodeError, r".*") +def _raise_for_unicode_encode(error, match, engine_name, is_disconnect): + raise exception.DBInvalidUnicodeParameter() + + +@filters("*", Exception, r".*") +def _raise_for_all_others(error, match, engine_name, is_disconnect): + LOG.exception(_LE('DB exception wrapped.')) + raise exception.DBError(error) + + +def handler(context): + """Iterate through available filters and invoke those which match. + + The first one which raises wins. The order in which the filters + are attempted is sorted by specificity - dialect name or "*", + exception class per method resolution order (``__mro__``). + Method resolution order is used so that filter rules indicating a + more specific exception class are attempted first. + + """ + def _dialect_registries(engine): + if engine.dialect.name in _registry: + yield _registry[engine.dialect.name] + if '*' in _registry: + yield _registry['*'] + + for per_dialect in _dialect_registries(context.engine): + for exc in ( + context.sqlalchemy_exception, + context.original_exception): + for super_ in exc.__class__.__mro__: + if super_ in per_dialect: + regexp_reg = per_dialect[super_] + for fn, regexp in regexp_reg: + match = regexp.match(exc.args[0]) + if match: + try: + fn( + exc, + match, + context.engine.dialect.name, + context.is_disconnect) + except exception.DBConnectionError: + context.is_disconnect = True + raise + + +def register_engine(engine): + compat.handle_error(engine, handler) + + +def handle_connect_error(engine): + """Handle connect error. + + Provide a special context that will allow on-connect errors + to be treated within the filtering context. + + This routine is dependent on SQLAlchemy version, as version 1.0.0 + provides this functionality natively. + + """ + with compat.handle_connect_context(handler, engine): + return engine.connect() |