feat: initial work to make v1 API compatible with SIP-40 and SIP-41 (#13960)

* WIP

* Use errorhandler

* Add response schema

* Fix status on HTTPException

* s/found/encountered/g

* Fix test

* Fix lint

* Fix lint and test
This commit is contained in:
Beto Dealmeida 2021-04-06 22:06:32 -07:00 committed by GitHub
parent 1638e6e932
commit a82d72fef6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 282 additions and 26 deletions

View File

@ -113,3 +113,21 @@ The host might be down, and cannot be reached on the provided port.
The host provided when adding a new database doesn't seem to be up. The host provided when adding a new database doesn't seem to be up.
Additionally, it cannot be reached on the provided port. Please check that Additionally, it cannot be reached on the provided port. Please check that
there are no firewall rules preventing access to the host. there are no firewall rules preventing access to the host.
## Issue 1010
```
Superset encountered an error while running a command.
```
Something unexpected happened, and Superset encountered an error while
running a command. Please reach out to your administrator.
## Issue 1011
```
Superset encountered an unexpected error.
```
Someething unexpected happened in the Superset backend. Please reach out
to your administrator.

View File

@ -28,6 +28,8 @@ export const ErrorTypeEnum = {
GENERIC_DB_ENGINE_ERROR: 'GENERIC_DB_ENGINE_ERROR', GENERIC_DB_ENGINE_ERROR: 'GENERIC_DB_ENGINE_ERROR',
COLUMN_DOES_NOT_EXIST_ERROR: 'COLUMN_DOES_NOT_EXIST_ERROR', COLUMN_DOES_NOT_EXIST_ERROR: 'COLUMN_DOES_NOT_EXIST_ERROR',
TABLE_DOES_NOT_EXIST_ERROR: 'TABLE_DOES_NOT_EXIST_ERROR', TABLE_DOES_NOT_EXIST_ERROR: 'TABLE_DOES_NOT_EXIST_ERROR',
TEST_CONNECTION_PORT_CLOSED_ERROR: 'TEST_CONNECTION_PORT_CLOSED_ERROR',
TEST_CONNECTION_HOST_DOWN_ERROR: 'TEST_CONNECTION_HOST_DOWN_ERROR',
// Viz errors // Viz errors
VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR', VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR',
@ -48,8 +50,10 @@ export const ErrorTypeEnum = {
MISSING_TEMPLATE_PARAMS_ERROR: 'MISSING_TEMPLATE_PARAMS_ERROR', MISSING_TEMPLATE_PARAMS_ERROR: 'MISSING_TEMPLATE_PARAMS_ERROR',
TEST_CONNECTION_INVALID_HOSTNAME_ERROR: TEST_CONNECTION_INVALID_HOSTNAME_ERROR:
'TEST_CONNECTION_INVALID_HOSTNAME_ERROR', 'TEST_CONNECTION_INVALID_HOSTNAME_ERROR',
TEST_CONNECTION_PORT_CLOSED_ERROR: 'TEST_CONNECTION_PORT_CLOSED_ERROR',
TEST_CONNECTION_HOST_DOWN_ERROR: 'TEST_CONNECTION_HOST_DOWN_ERROR', // Generic errors
GENERIC_COMMAND_ERROR: 'GENERIC_COMMAND_ERROR',
GENERIC_BACKEND_ERROR: 'GENERIC_BACKEND_ERROR',
} as const; } as const;
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];

View File

@ -41,7 +41,6 @@ from superset.databases.commands.exceptions import (
DatabaseImportError, DatabaseImportError,
DatabaseInvalidError, DatabaseInvalidError,
DatabaseNotFoundError, DatabaseNotFoundError,
DatabaseTestConnectionFailedError,
DatabaseUpdateFailedError, DatabaseUpdateFailedError,
) )
from superset.databases.commands.export import ExportDatabasesCommand from superset.databases.commands.export import ExportDatabasesCommand
@ -64,7 +63,6 @@ from superset.databases.schemas import (
TableMetadataResponseSchema, TableMetadataResponseSchema,
) )
from superset.databases.utils import get_table_metadata from superset.databases.utils import get_table_metadata
from superset.exceptions import SupersetErrorException
from superset.extensions import security_manager from superset.extensions import security_manager
from superset.models.core import Database from superset.models.core import Database
from superset.typing import FlaskResponse from superset.typing import FlaskResponse
@ -558,7 +556,6 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
@expose("/test_connection", methods=["POST"]) @expose("/test_connection", methods=["POST"])
@protect() @protect()
@safe
@statsd_metrics @statsd_metrics
@event_logger.log_this_with_context( @event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
@ -604,13 +601,8 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
# This validates custom Schema with custom validations # This validates custom Schema with custom validations
except ValidationError as error: except ValidationError as error:
return self.response_400(message=error.messages) return self.response_400(message=error.messages)
try: TestConnectionDatabaseCommand(g.user, item).run()
TestConnectionDatabaseCommand(g.user, item).run() return self.response(200, message="OK")
return self.response(200, message="OK")
except DatabaseTestConnectionFailedError as ex:
return self.response_422(message=str(ex))
except SupersetErrorException as ex:
return self.response(ex.status, message=ex.error.message)
@expose("/<int:pk>/related_objects/", methods=["GET"]) @expose("/<int:pk>/related_objects/", methods=["GET"])
@protect() @protect()

View File

@ -118,6 +118,7 @@ class DatabaseDeleteFailedReportsExistError(DatabaseDeleteFailedError):
class DatabaseTestConnectionFailedError(CommandException): class DatabaseTestConnectionFailedError(CommandException):
status = 422
message = _("Connection failed, please check your connection settings") message = _("Connection failed, please check your connection settings")

View File

@ -39,6 +39,8 @@ class SupersetErrorType(str, Enum):
GENERIC_DB_ENGINE_ERROR = "GENERIC_DB_ENGINE_ERROR" GENERIC_DB_ENGINE_ERROR = "GENERIC_DB_ENGINE_ERROR"
COLUMN_DOES_NOT_EXIST_ERROR = "COLUMN_DOES_NOT_EXIST_ERROR" COLUMN_DOES_NOT_EXIST_ERROR = "COLUMN_DOES_NOT_EXIST_ERROR"
TABLE_DOES_NOT_EXIST_ERROR = "TABLE_DOES_NOT_EXIST_ERROR" TABLE_DOES_NOT_EXIST_ERROR = "TABLE_DOES_NOT_EXIST_ERROR"
TEST_CONNECTION_PORT_CLOSED_ERROR = "TEST_CONNECTION_PORT_CLOSED_ERROR"
TEST_CONNECTION_HOST_DOWN_ERROR = "TEST_CONNECTION_HOST_DOWN_ERROR"
# Viz errors # Viz errors
VIZ_GET_DF_ERROR = "VIZ_GET_DF_ERROR" VIZ_GET_DF_ERROR = "VIZ_GET_DF_ERROR"
@ -57,8 +59,10 @@ class SupersetErrorType(str, Enum):
# Sql Lab errors # Sql Lab errors
MISSING_TEMPLATE_PARAMS_ERROR = "MISSING_TEMPLATE_PARAMS_ERROR" MISSING_TEMPLATE_PARAMS_ERROR = "MISSING_TEMPLATE_PARAMS_ERROR"
TEST_CONNECTION_INVALID_HOSTNAME_ERROR = "TEST_CONNECTION_INVALID_HOSTNAME_ERROR" TEST_CONNECTION_INVALID_HOSTNAME_ERROR = "TEST_CONNECTION_INVALID_HOSTNAME_ERROR"
TEST_CONNECTION_PORT_CLOSED_ERROR = "TEST_CONNECTION_PORT_CLOSED_ERROR"
TEST_CONNECTION_HOST_DOWN_ERROR = "TEST_CONNECTION_HOST_DOWN_ERROR" # Generic errors
GENERIC_COMMAND_ERROR = "GENERIC_COMMAND_ERROR"
GENERIC_BACKEND_ERROR = "GENERIC_BACKEND_ERROR"
ERROR_TYPES_TO_ISSUE_CODES_MAPPING = { ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
@ -135,6 +139,20 @@ ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
), ),
}, },
], ],
SupersetErrorType.GENERIC_COMMAND_ERROR: [
{
"code": 1010,
"message": _(
"Issue 1010 - Superset encountered an error while running a command."
),
},
],
SupersetErrorType.GENERIC_BACKEND_ERROR: [
{
"code": 1011,
"message": _("Issue 1011 - Superset encountered an unexpected error."),
},
],
} }

View File

@ -54,6 +54,14 @@ class SupersetErrorException(SupersetException):
) )
class SupersetErrorsException(SupersetException):
"""Exceptions with multiple SupersetErrorType associated with them"""
def __init__(self, errors: List[SupersetError]) -> None:
super().__init__(str(errors))
self.errors = errors
class SupersetTimeoutException(SupersetErrorException): class SupersetTimeoutException(SupersetErrorException):
status = 408 status = 408
@ -97,13 +105,9 @@ class SupersetSecurityException(SupersetException):
self.payload = payload self.payload = payload
class SupersetVizException(SupersetException): class SupersetVizException(SupersetErrorsException):
status = 400 status = 400
def __init__(self, errors: List[SupersetError]) -> None:
super().__init__(str(errors))
self.errors = errors
class NoDataException(SupersetException): class NoDataException(SupersetException):
status = 400 status = 400

49
superset/schemas.py Normal file
View File

@ -0,0 +1,49 @@
# 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.errors import SupersetErrorType
error_payload_content = {
"application/json": {
"schema": {
"type": "object",
"properties": {
# SIP-40 error payload
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"message": {"type": "string"},
"error_type": {
"type": "string",
"enum": [enum.value for enum in SupersetErrorType],
},
"level": {
"type": "string",
"enum": ["info", "warning", "error"],
},
"extra": {"type": "object"},
},
},
},
# Old-style message payload
"message": {"type": "string"},
},
},
},
}

View File

@ -46,11 +46,13 @@ from superset import (
get_feature_flags, get_feature_flags,
security_manager, security_manager,
) )
from superset.commands.exceptions import CommandException, CommandInvalidError
from superset.connectors.sqla import models from superset.connectors.sqla import models
from superset.datasets.commands.exceptions import get_dataset_exist_error_msg from superset.datasets.commands.exceptions import get_dataset_exist_error_msg
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import ( from superset.exceptions import (
SupersetErrorException, SupersetErrorException,
SupersetErrorsException,
SupersetException, SupersetException,
SupersetSecurityException, SupersetSecurityException,
) )
@ -132,7 +134,7 @@ def json_errors_response(
return Response( return Response(
json.dumps(payload, default=utils.json_iso_dttm_ser, ignore_nan=True), json.dumps(payload, default=utils.json_iso_dttm_ser, ignore_nan=True),
status=status, status=status,
mimetype="application/json", mimetype="application/json; charset=utf-8",
) )
@ -330,6 +332,81 @@ def common_bootstrap_payload() -> Dict[str, Any]:
} }
# pylint: disable=invalid-name
def get_error_level_from_status_code(status: int) -> ErrorLevel:
if status < 400:
return ErrorLevel.INFO
if status < 500:
return ErrorLevel.WARNING
return ErrorLevel.ERROR
# SIP-40 compatible error responses; make sure APIs raise
# SupersetErrorException or SupersetErrorsException
@superset_app.errorhandler(SupersetErrorException)
def show_superset_error(ex: SupersetErrorException) -> FlaskResponse:
logger.warning(ex)
return json_errors_response(errors=[ex.error], status=ex.status)
@superset_app.errorhandler(SupersetErrorsException)
def show_superset_errors(ex: SupersetErrorsException) -> FlaskResponse:
logger.warning(ex)
return json_errors_response(errors=ex.errors, status=ex.status)
@superset_app.errorhandler(HTTPException)
def show_http_exception(ex: HTTPException) -> FlaskResponse:
logger.warning(ex)
return json_errors_response(
errors=[
SupersetError(
message=utils.error_msg_from_exception(ex),
error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
level=ErrorLevel.ERROR,
extra={},
),
],
status=ex.code or 500,
)
# Temporary handler for CommandException; if an API raises a
# CommandException it should be fixed to map it to SupersetErrorException
# or SupersetErrorsException, with a specific status code and error type
@superset_app.errorhandler(CommandException)
def show_command_errors(ex: CommandException) -> FlaskResponse:
logger.warning(ex)
extra = ex.normalized_messages() if isinstance(ex, CommandInvalidError) else {}
return json_errors_response(
errors=[
SupersetError(
message=ex.message,
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
level=get_error_level_from_status_code(ex.status),
extra=extra,
),
],
status=ex.status,
)
# Catch-all, to ensure all errors from the backend conform to SIP-40
@superset_app.errorhandler(Exception)
def show_unexpected_exception(ex: Exception) -> FlaskResponse:
logger.warning(ex)
return json_errors_response(
errors=[
SupersetError(
message=utils.error_msg_from_exception(ex),
error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
level=ErrorLevel.ERROR,
extra={},
),
],
)
@superset_app.context_processor @superset_app.context_processor
def get_common_bootstrap_data() -> Dict[str, Any]: def get_common_bootstrap_data() -> Dict[str, Any]:
def serialize_bootstrap_data() -> str: def serialize_bootstrap_data() -> str:

View File

@ -35,6 +35,7 @@ from superset.extensions import db, event_logger, security_manager
from superset.models.core import FavStar from superset.models.core import FavStar
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.schemas import error_payload_content
from superset.sql_lab import Query as SqllabQuery from superset.sql_lab import Query as SqllabQuery
from superset.stats_logger import BaseStatsLogger from superset.stats_logger import BaseStatsLogger
from superset.typing import FlaskResponse from superset.typing import FlaskResponse
@ -77,7 +78,12 @@ def statsd_metrics(f: Callable[..., Any]) -> Callable[..., Any]:
""" """
def wraps(self: "BaseSupersetModelRestApi", *args: Any, **kwargs: Any) -> Response: def wraps(self: "BaseSupersetModelRestApi", *args: Any, **kwargs: Any) -> Response:
duration, response = time_function(f, self, *args, **kwargs) try:
duration, response = time_function(f, self, *args, **kwargs)
except Exception as ex:
self.incr_stats("error", f.__name__)
raise ex
self.send_stats_metrics(response, f.__name__, duration) self.send_stats_metrics(response, f.__name__, duration)
return response return response
@ -198,6 +204,18 @@ class BaseSupersetModelRestApi(ModelRestApi):
list_columns: List[str] list_columns: List[str]
show_columns: List[str] show_columns: List[str]
responses = {
"400": {"description": "Bad request", "content": error_payload_content},
"401": {"description": "Unauthorized", "content": error_payload_content},
"403": {"description": "Forbidden", "content": error_payload_content},
"404": {"description": "Not found", "content": error_payload_content},
"422": {
"description": "Could not process entity",
"content": error_payload_content,
},
"500": {"description": "Fatal error", "content": error_payload_content},
}
def __init__(self) -> None: def __init__(self) -> None:
# Setup statsd # Setup statsd
self.stats_logger = BaseStatsLogger() self.stats_logger = BaseStatsLogger()

View File

@ -820,7 +820,21 @@ class TestDatabaseApi(SupersetTestCase):
self.assertEqual(rv.headers["Content-Type"], "application/json; charset=utf-8") self.assertEqual(rv.headers["Content-Type"], "application/json; charset=utf-8")
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = { expected_response = {
"message": "Could not load database driver: BaseEngineSpec", "errors": [
{
"message": "Could not load database driver: BaseEngineSpec",
"error_type": "GENERIC_COMMAND_ERROR",
"level": "warning",
"extra": {
"issue_codes": [
{
"code": 1010,
"message": "Issue 1010 - Superset encountered an error while running a command.",
}
]
},
}
]
} }
self.assertEqual(response, expected_response) self.assertEqual(response, expected_response)
@ -835,7 +849,21 @@ class TestDatabaseApi(SupersetTestCase):
self.assertEqual(rv.headers["Content-Type"], "application/json; charset=utf-8") self.assertEqual(rv.headers["Content-Type"], "application/json; charset=utf-8")
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = { expected_response = {
"message": "Could not load database driver: MssqlEngineSpec", "errors": [
{
"message": "Could not load database driver: MssqlEngineSpec",
"error_type": "GENERIC_COMMAND_ERROR",
"level": "warning",
"extra": {
"issue_codes": [
{
"code": 1010,
"message": "Issue 1010 - Superset encountered an error while running a command.",
}
]
},
}
]
} }
self.assertEqual(response, expected_response) self.assertEqual(response, expected_response)
@ -888,7 +916,22 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.headers["Content-Type"] == "application/json; charset=utf-8" assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = { expected_response = {
"message": 'Unable to resolve hostname "invalidhostname".', "errors": [
{
"message": 'Unable to resolve hostname "invalidhostname".',
"error_type": "TEST_CONNECTION_INVALID_HOSTNAME_ERROR",
"level": "error",
"extra": {
"hostname": "invalidhostname",
"issue_codes": [
{
"code": 1007,
"message": "Issue 1007 - The hostname provided can't be resolved.",
}
],
},
}
]
} }
assert response == expected_response assert response == expected_response
@ -919,7 +962,23 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.headers["Content-Type"] == "application/json; charset=utf-8" assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = { expected_response = {
"message": "The host localhost is up, but the port 12345 is closed.", "errors": [
{
"message": "The host localhost is up, but the port 12345 is closed.",
"error_type": "TEST_CONNECTION_PORT_CLOSED_ERROR",
"level": "error",
"extra": {
"hostname": "localhost",
"port": 12345,
"issue_codes": [
{
"code": 1008,
"message": "Issue 1008 - The port is closed.",
}
],
},
}
]
} }
assert response == expected_response assert response == expected_response
@ -950,7 +1009,23 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.headers["Content-Type"] == "application/json; charset=utf-8" assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_response = { expected_response = {
"message": "The host localhost might be down, ond can't be reached on port 12345.", "errors": [
{
"message": "The host localhost might be down, ond can't be reached on port 12345.",
"error_type": "TEST_CONNECTION_HOST_DOWN_ERROR",
"level": "error",
"extra": {
"hostname": "localhost",
"port": 12345,
"issue_codes": [
{
"code": 1009,
"message": "Issue 1009 - The host might be down, and can't be reached on the provided port.",
}
],
},
}
]
} }
assert response == expected_response assert response == expected_response