Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: dbapi exception mapping for dbapi's #12869

Merged
merged 4 commits into from
Feb 2, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions superset/db_engine_specs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Optional,
Pattern,
Tuple,
Type,
TYPE_CHECKING,
Union,
)
Expand Down Expand Up @@ -177,6 +178,35 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
),
}

@classmethod
def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]:
"""
Each engine can implement and converge it's own specific exceptions into
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar nit: "its" not "it's"

Copy link
Member Author

@dpgaspar dpgaspar Feb 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, yes that's a good point, a simple way of doing is:

if not new_exception:
    return SupersetDBAPIError(str(exception))

But can have broader implications, I want to place the base for this, and just interfere on the defined exceptions for Elasticsearch and clickhouse (for now).

SQLAlchemy DBAPI exceptions

Note: On python 3.9 this method can be changed to a classmethod property
without the need of implementing a metaclass type

:return: A map of driver specific exception to superset custom exceptions
"""
return {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dpgaspar you should be able to build this automatically, since the SQLAlchemy dialect exposts the DB API module, which should have the exceptions at the top level with standard names.

Something like this (untested):

dbapi = cls.get_engine(database).dialect.dbapi()

return {
  getattr(dbapi, name): getattr(superset.exceptions, f"SupersetDBAPI{name}"
  for name in {"ProgrammingError", "DatabaseError", etc}
}

Maybe have this as the base method and allow subclasses to define their own?


@classmethod
def get_dbapi_mapped_exception(cls, exception: Exception) -> Exception:
"""
Get a superset custom DBAPI exception from the driver specific exception.

Override if the engine needs to perform extra changes to the exception, for
example change the exception message or implement custom more complex logic

:param exception: The driver specific exception
:return: Superset custom DBAPI exception
"""
new_exception = cls.get_dbapi_exception_mapping().get(type(exception))
if not new_exception:
return exception
return new_exception(str(exception))

@classmethod
def is_db_column_type_match(
cls, db_column_type: Optional[str], target_column_type: utils.GenericDataType
Expand Down Expand Up @@ -314,9 +344,12 @@ def fetch_data(
"""
if cls.arraysize:
cursor.arraysize = cls.arraysize
if cls.limit_method == LimitMethod.FETCH_MANY and limit:
return cursor.fetchmany(limit)
return cursor.fetchall()
try:
if cls.limit_method == LimitMethod.FETCH_MANY and limit:
return cursor.fetchmany(limit)
return cursor.fetchall()
except Exception as ex:
raise cls.get_dbapi_mapped_exception(ex)

@classmethod
def expand_data(
Expand Down Expand Up @@ -902,7 +935,10 @@ def execute(cls, cursor: Any, query: str, **kwargs: Any) -> None:
"""
if cls.arraysize:
cursor.arraysize = cls.arraysize
cursor.execute(query)
try:
cursor.execute(query)
except Exception as ex:
raise cls.get_dbapi_mapped_exception(ex)

@classmethod
def make_label_compatible(cls, label: str) -> Union[str, quoted_name]:
Expand Down
18 changes: 17 additions & 1 deletion superset/db_engine_specs/clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from typing import Optional
from typing import Dict, Optional, Type

from superset.db_engine_specs.base import BaseEngineSpec
from superset.db_engine_specs.exceptions import SupersetDBAPIDatabaseError
from superset.utils import core as utils


Expand Down Expand Up @@ -45,6 +46,21 @@ class ClickHouseEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
"P1Y": "toStartOfYear(toDateTime({col}))",
}

@classmethod
def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]:
from urllib3.exceptions import NewConnectionError

return {NewConnectionError: SupersetDBAPIDatabaseError}

@classmethod
def get_dbapi_mapped_exception(cls, exception: Exception) -> Exception:
new_exception = cls.get_dbapi_exception_mapping().get(type(exception))
if new_exception == SupersetDBAPIDatabaseError:
return SupersetDBAPIDatabaseError("Connection failed")
if not new_exception:
return exception
return new_exception(str(exception))

@classmethod
def convert_dttm(cls, target_type: str, dttm: datetime) -> Optional[str]:
tt = target_type.upper()
Expand Down
17 changes: 16 additions & 1 deletion superset/db_engine_specs/elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from typing import Dict, Optional
from typing import Dict, Optional, Type

from superset.db_engine_specs.base import BaseEngineSpec
from superset.db_engine_specs.exceptions import (
SupersetDBAPIDatabaseError,
SupersetDBAPIOperationalError,
SupersetDBAPIProgrammingError,
)
from superset.utils import core as utils


Expand All @@ -41,6 +46,16 @@ class ElasticSearchEngineSpec(BaseEngineSpec): # pylint: disable=abstract-metho

type_code_map: Dict[int, str] = {} # loaded from get_datatype only if needed

@classmethod
def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], Type[Exception]]:
import es.exceptions as es_exceptions

return {
es_exceptions.DatabaseError: SupersetDBAPIDatabaseError,
es_exceptions.OperationalError: SupersetDBAPIOperationalError,
es_exceptions.ProgrammingError: SupersetDBAPIProgrammingError,
}

@classmethod
def convert_dttm(cls, target_type: str, dttm: datetime) -> Optional[str]:
if target_type.upper() == utils.TemporalType.DATETIME:
Expand Down
41 changes: 41 additions & 0 deletions superset/db_engine_specs/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
from superset.exceptions import SupersetException


class SupersetDBAPIError(SupersetException):
pass


class SupersetDBAPIDataError(SupersetDBAPIError):
pass


class SupersetDBAPIDatabaseError(SupersetDBAPIError):
pass


class SupersetDBAPIDisconnectionError(SupersetDBAPIError):
pass


class SupersetDBAPIOperationalError(SupersetDBAPIError):
pass


class SupersetDBAPIProgrammingError(SupersetDBAPIError):
pass
15 changes: 15 additions & 0 deletions tests/db_engine_specs/clickhouse_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest import mock

import pytest

from superset.db_engine_specs.clickhouse import ClickHouseEngineSpec
from superset.db_engine_specs.exceptions import SupersetDBAPIDatabaseError
from tests.db_engine_specs.base_tests import TestDbEngineSpec


Expand All @@ -30,3 +35,13 @@ def test_convert_dttm(self):
ClickHouseEngineSpec.convert_dttm("DATETIME", dttm),
"toDateTime('2019-01-02 03:04:05')",
)

def test_execute_connection_error(self):
from urllib3.exceptions import NewConnectionError

cursor = mock.Mock()
cursor.execute.side_effect = NewConnectionError(
"Dummypool", message="Exception with sensitive data"
)
with pytest.raises(SupersetDBAPIDatabaseError) as ex:
ClickHouseEngineSpec.execute(cursor, "SELECT col1 from table1")