This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e749efcb97 fix: refactor view error handling into a separate module 
(#29330)
e749efcb97 is described below

commit e749efcb970a41d8e6282a7cb0a92e4f68453da2
Author: Maxime Beauchemin <[email protected]>
AuthorDate: Tue Jul 9 10:16:40 2024 -0700

    fix: refactor view error handling into a separate module (#29330)
---
 superset/databases/api.py           |   4 +-
 superset/initialization/__init__.py |   3 +
 superset/utils/core.py              |   2 +-
 superset/views/api.py               |   3 +-
 superset/views/base.py              | 192 +------------------------------
 superset/views/base_api.py          |   2 +-
 superset/views/core.py              |  12 +-
 superset/views/datasource/views.py  |   2 +-
 superset/views/error_handling.py    | 222 ++++++++++++++++++++++++++++++++++++
 9 files changed, 236 insertions(+), 206 deletions(-)

diff --git a/superset/databases/api.py b/superset/databases/api.py
index 3a672eb766..af5ce255ae 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -123,13 +123,13 @@ from superset.utils import json
 from superset.utils.core import error_msg_from_exception, 
parse_js_uri_path_item
 from superset.utils.oauth2 import decode_oauth2_state
 from superset.utils.ssh_tunnel import mask_password_info
-from superset.views.base import json_errors_response
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
     requires_form_data,
     requires_json,
     statsd_metrics,
 )
+from superset.views.error_handling import json_error_response
 
 logger = logging.getLogger(__name__)
 
@@ -458,7 +458,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         except DatabaseConnectionFailedError as ex:
             return self.response_422(message=str(ex))
         except SupersetErrorsException as ex:
-            return json_errors_response(errors=ex.errors, status=ex.status)
+            return json_error_response(ex.errors, status=ex.status)
         except DatabaseCreateFailedError as ex:
             logger.error(
                 "Error creating model %s: %s",
diff --git a/superset/initialization/__init__.py 
b/superset/initialization/__init__.py
index f074eaf293..7c35fd17a7 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -174,6 +174,7 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         from superset.views.database.views import DatabaseView
         from superset.views.datasource.views import DatasetEditor, Datasource
         from superset.views.dynamic_plugins import DynamicPluginsView
+        from superset.views.error_handling import set_app_error_handlers
         from superset.views.explore import ExplorePermalinkView, ExploreView
         from superset.views.key_value import KV
         from superset.views.log.api import LogRestApi
@@ -188,6 +189,8 @@ class SupersetAppInitializer:  # pylint: 
disable=too-many-public-methods
         from superset.views.tags import TagModelView, TagView
         from superset.views.users.api import CurrentUserRestApi, UserRestApi
 
+        set_app_error_handlers(self.superset_app)
+
         #
         # Setup API views
         #
diff --git a/superset/utils/core.py b/superset/utils/core.py
index d01dd517ef..b2f09aac2d 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -433,7 +433,7 @@ def error_msg_from_exception(ex: Exception) -> str:
             msg = ex.message.get("message")  # type: ignore
         elif ex.message:
             msg = ex.message
-    return msg or str(ex)
+    return str(msg) or str(ex)
 
 
 def markdown(raw: str, markup_wrap: bool | None = False) -> str:
diff --git a/superset/views/api.py b/superset/views/api.py
index 8ef59cb482..736c0b5548 100644
--- a/superset/views/api.py
+++ b/superset/views/api.py
@@ -34,7 +34,8 @@ from superset.models.slice import Slice
 from superset.superset_typing import FlaskResponse
 from superset.utils import json
 from superset.utils.date_parser import get_since_until
-from superset.views.base import api, BaseSupersetView, handle_api_exception
+from superset.views.base import api, BaseSupersetView
+from superset.views.error_handling import handle_api_exception
 
 if TYPE_CHECKING:
     from superset.common.query_context_factory import QueryContextFactory
diff --git a/superset/views/base.py b/superset/views/base.py
index b836c5076c..f809d616c8 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -16,14 +16,12 @@
 # under the License.
 from __future__ import annotations
 
-import dataclasses
 import functools
 import logging
 import os
 import traceback
 from datetime import datetime
-from importlib.resources import files
-from typing import Any, Callable, cast
+from typing import Any, Callable
 
 import yaml
 from babel import Locale
@@ -33,9 +31,7 @@ from flask import (
     g,
     get_flashed_messages,
     redirect,
-    request,
     Response,
-    send_file,
     session,
 )
 from flask_appbuilder import BaseView, expose, Model, ModelView
@@ -52,11 +48,8 @@ from flask_appbuilder.security.sqla.models import User
 from flask_appbuilder.widgets import ListWidget
 from flask_babel import get_locale, gettext as __
 from flask_jwt_extended.exceptions import NoAuthorizationError
-from flask_wtf.csrf import CSRFError
 from flask_wtf.form import FlaskForm
-from sqlalchemy import exc
 from sqlalchemy.orm import Query
-from werkzeug.exceptions import HTTPException
 from wtforms.fields.core import Field, UnboundField
 
 from superset import (
@@ -68,17 +61,9 @@ from superset import (
     is_feature_enabled,
     security_manager,
 )
-from superset.commands.exceptions import CommandException, CommandInvalidError
 from superset.connectors.sqla import models
 from superset.db_engine_specs import get_available_engine_specs
 from superset.db_engine_specs.gsheets import GSheetsEngineSpec
-from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
-from superset.exceptions import (
-    SupersetErrorException,
-    SupersetErrorsException,
-    SupersetException,
-    SupersetSecurityException,
-)
 from superset.extensions import cache_manager
 from superset.models.helpers import ImportExportMixin
 from superset.reports.models import ReportRecipientType
@@ -86,6 +71,7 @@ from superset.superset_typing import FlaskResponse
 from superset.translations.utils import get_language_pack
 from superset.utils import core as utils, json
 from superset.utils.filters import get_dataset_access_filters
+from superset.views.error_handling import json_error_response
 
 from .utils import bootstrap_user_data
 
@@ -146,35 +132,6 @@ def get_error_msg() -> str:
     return error_msg
 
 
-def json_error_response(
-    msg: str | None = None,
-    status: int = 500,
-    payload: dict[str, Any] | None = None,
-) -> FlaskResponse:
-    payload = payload or {"error": f"{msg}"}
-
-    return Response(
-        json.dumps(payload, default=json.json_iso_dttm_ser, ignore_nan=True),
-        status=status,
-        mimetype="application/json",
-    )
-
-
-def json_errors_response(
-    errors: list[SupersetError],
-    status: int = 500,
-    payload: dict[str, Any] | None = None,
-) -> FlaskResponse:
-    payload = payload or {}
-
-    payload["errors"] = [dataclasses.asdict(error) for error in errors]
-    return Response(
-        json.dumps(payload, default=json.json_iso_dttm_ser, ignore_nan=True),
-        status=status,
-        mimetype="application/json; charset=utf-8",
-    )
-
-
 def json_success(json_msg: str, status: int = 200) -> FlaskResponse:
     return Response(json_msg, status=status, mimetype="application/json")
 
@@ -243,50 +200,6 @@ def api(f: Callable[..., FlaskResponse]) -> Callable[..., 
FlaskResponse]:
     return functools.update_wrapper(wraps, f)
 
 
-def handle_api_exception(
-    f: Callable[..., FlaskResponse],
-) -> Callable[..., FlaskResponse]:
-    """
-    A decorator to catch superset exceptions. Use it after the @api decorator 
above
-    so superset exception handler is triggered before the handler for generic
-    exceptions.
-    """
-
-    def wraps(self: BaseSupersetView, *args: Any, **kwargs: Any) -> 
FlaskResponse:
-        try:
-            return f(self, *args, **kwargs)
-        except SupersetSecurityException as ex:
-            logger.warning("SupersetSecurityException", exc_info=True)
-            return json_errors_response(
-                errors=[ex.error], status=ex.status, payload=ex.payload
-            )
-        except SupersetErrorsException as ex:
-            logger.warning(ex, exc_info=True)
-            return json_errors_response(errors=ex.errors, status=ex.status)
-        except SupersetErrorException as ex:
-            logger.warning("SupersetErrorException", exc_info=True)
-            return json_errors_response(errors=[ex.error], status=ex.status)
-        except SupersetException as ex:
-            if ex.status >= 500:
-                logger.exception(ex)
-            return json_error_response(
-                utils.error_msg_from_exception(ex), status=ex.status
-            )
-        except HTTPException as ex:
-            logger.exception(ex)
-            return json_error_response(
-                utils.error_msg_from_exception(ex), status=cast(int, ex.code)
-            )
-        except (exc.IntegrityError, exc.DatabaseError, exc.DataError) as ex:
-            logger.exception(ex)
-            return json_error_response(utils.error_msg_from_exception(ex), 
status=422)
-        except Exception as ex:  # pylint: disable=broad-except
-            logger.exception(ex)
-            return json_error_response(utils.error_msg_from_exception(ex))
-
-    return functools.update_wrapper(wraps, f)
-
-
 class BaseSupersetView(BaseView):
     @staticmethod
     def json_response(obj: Any, status: int = 200) -> FlaskResponse:
@@ -439,107 +352,6 @@ def common_bootstrap_payload() -> dict[str, Any]:
     }
 
 
-def get_error_level_from_status_code(  # pylint: disable=invalid-name
-    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("SupersetErrorException", exc_info=True)
-    return json_errors_response(errors=[ex.error], status=ex.status)
-
-
-@superset_app.errorhandler(SupersetErrorsException)
-def show_superset_errors(ex: SupersetErrorsException) -> FlaskResponse:
-    logger.warning("SupersetErrorsException", exc_info=True)
-    return json_errors_response(errors=ex.errors, status=ex.status)
-
-
-# Redirect to login if the CSRF token is expired
-@superset_app.errorhandler(CSRFError)
-def refresh_csrf_token(ex: CSRFError) -> FlaskResponse:
-    logger.warning("Refresh CSRF token error", exc_info=True)
-
-    if request.is_json:
-        return show_http_exception(ex)
-
-    return redirect(appbuilder.get_url_for_login)
-
-
-@superset_app.errorhandler(HTTPException)
-def show_http_exception(ex: HTTPException) -> FlaskResponse:
-    logger.warning("HTTPException", exc_info=True)
-    if (
-        "text/html" in request.accept_mimetypes
-        and not config["DEBUG"]
-        and ex.code in {404, 500}
-    ):
-        path = files("superset") / f"static/assets/{ex.code}.html"
-        return send_file(path, max_age=0), ex.code
-
-    return json_errors_response(
-        errors=[
-            SupersetError(
-                message=utils.error_msg_from_exception(ex),
-                error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
-                level=ErrorLevel.ERROR,
-            ),
-        ],
-        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("CommandException", exc_info=True)
-    if "text/html" in request.accept_mimetypes and not config["DEBUG"]:
-        path = files("superset") / "static/assets/500.html"
-        return send_file(path, max_age=0), 500
-
-    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.exception(ex)
-    if "text/html" in request.accept_mimetypes and not config["DEBUG"]:
-        path = files("superset") / "static/assets/500.html"
-        return send_file(path, max_age=0), 500
-
-    return json_errors_response(
-        errors=[
-            SupersetError(
-                message=utils.error_msg_from_exception(ex),
-                error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
-                level=ErrorLevel.ERROR,
-            ),
-        ],
-    )
-
-
 @superset_app.context_processor
 def get_common_bootstrap_data() -> dict[str, Any]:
     def serialize_bootstrap_data() -> str:
diff --git a/superset/views/base_api.py b/superset/views/base_api.py
index 9436ce6390..5c71147517 100644
--- a/superset/views/base_api.py
+++ b/superset/views/base_api.py
@@ -42,7 +42,7 @@ from superset.sql_lab import Query as SqllabQuery
 from superset.superset_typing import FlaskResponse
 from superset.tags.models import Tag
 from superset.utils.core import get_user_id, time_function
-from superset.views.base import handle_api_exception
+from superset.views.error_handling import handle_api_exception
 
 logger = logging.getLogger(__name__)
 get_related_schema = {
diff --git a/superset/views/core.py b/superset/views/core.py
index 75a04dedc7..56221b646a 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -23,7 +23,7 @@ from datetime import datetime
 from typing import Any, Callable, cast
 from urllib import parse
 
-from flask import abort, flash, g, redirect, render_template, request, Response
+from flask import abort, flash, g, redirect, request, Response
 from flask_appbuilder import expose
 from flask_appbuilder.security.decorators import (
     has_access,
@@ -85,11 +85,10 @@ from superset.views.base import (
     data_payload_response,
     deprecated,
     generate_download_headers,
-    get_error_msg,
-    handle_api_exception,
     json_error_response,
     json_success,
 )
+from superset.views.error_handling import handle_api_exception
 from superset.views.utils import (
     bootstrap_user_data,
     check_datasource_perms,
@@ -888,13 +887,6 @@ class Superset(BaseSupersetView):
         datasource.raise_for_access()
         return 
json_success(json.dumps(sanitize_datasource_data(datasource.data)))
 
-    @app.errorhandler(500)
-    def show_traceback(self) -> FlaskResponse:
-        return (
-            render_template("superset/traceback.html", 
error_msg=get_error_msg()),
-            500,
-        )
-
     @event_logger.log_this
     @expose("/welcome/")
     def welcome(self) -> FlaskResponse:
diff --git a/superset/views/datasource/views.py 
b/superset/views/datasource/views.py
index 377579cf05..fd0dc2590a 100644
--- a/superset/views/datasource/views.py
+++ b/superset/views/datasource/views.py
@@ -44,7 +44,6 @@ from superset.views.base import (
     api,
     BaseSupersetView,
     deprecated,
-    handle_api_exception,
     json_error_response,
 )
 from superset.views.datasource.schemas import (
@@ -55,6 +54,7 @@ from superset.views.datasource.schemas import (
     SamplesRequestSchema,
 )
 from superset.views.datasource.utils import get_samples
+from superset.views.error_handling import handle_api_exception
 from superset.views.utils import sanitize_datasource_data
 
 
diff --git a/superset/views/error_handling.py b/superset/views/error_handling.py
new file mode 100644
index 0000000000..b9d0a41086
--- /dev/null
+++ b/superset/views/error_handling.py
@@ -0,0 +1,222 @@
+# 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 __future__ import annotations
+
+import dataclasses
+import functools
+import logging
+import typing
+from importlib.resources import files
+from typing import Any, Callable, cast
+
+from flask import (
+    Flask,
+    redirect,
+    request,
+    Response,
+    send_file,
+)
+from flask_wtf.csrf import CSRFError
+from sqlalchemy import exc
+from werkzeug.exceptions import HTTPException
+
+from superset import appbuilder
+from superset.commands.exceptions import CommandException, CommandInvalidError
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import (
+    SupersetErrorException,
+    SupersetErrorsException,
+    SupersetException,
+    SupersetSecurityException,
+)
+from superset.superset_typing import FlaskResponse
+from superset.utils import core as utils, json
+
+if typing.TYPE_CHECKING:
+    from superset.views.base import BaseSupersetView
+
+
+logger = logging.getLogger(__name__)
+
+JSON_MIMETYPE = "application/json; charset=utf-8"
+
+
+def get_error_level_from_status(
+    status_code: int,
+) -> ErrorLevel:
+    if status_code < 400:
+        return ErrorLevel.INFO
+    if status_code < 500:
+        return ErrorLevel.WARNING
+    return ErrorLevel.ERROR
+
+
+def json_error_response(
+    error_details: str | SupersetError | list[SupersetError] | None = None,
+    status: int = 500,
+    payload: dict[str, Any] | None = None,
+) -> FlaskResponse:
+    payload = payload or {}
+
+    if isinstance(error_details, list):
+        payload["errors"] = [dataclasses.asdict(error) for error in 
error_details]
+    elif isinstance(error_details, SupersetError):
+        payload["errors"] = [dataclasses.asdict(error_details)]
+    elif isinstance(error_details, str):
+        payload["error"] = error_details
+
+    return Response(
+        json.dumps(payload, default=json.json_iso_dttm_ser, ignore_nan=True),
+        status=status,
+        mimetype=JSON_MIMETYPE,
+    )
+
+
+def handle_api_exception(
+    f: Callable[..., FlaskResponse],
+) -> Callable[..., FlaskResponse]:
+    """
+    A decorator to catch superset exceptions. Use it after the @api decorator 
above
+    so superset exception handler is triggered before the handler for generic
+    exceptions.
+    """
+
+    def wraps(self: BaseSupersetView, *args: Any, **kwargs: Any) -> 
FlaskResponse:
+        try:
+            return f(self, *args, **kwargs)
+        except SupersetSecurityException as ex:
+            logger.warning("SupersetSecurityException", exc_info=True)
+            return json_error_response([ex.error], status=ex.status, 
payload=ex.payload)
+        except SupersetErrorsException as ex:
+            logger.warning(ex, exc_info=True)
+            return json_error_response(ex.errors, status=ex.status)
+        except SupersetErrorException as ex:
+            logger.warning("SupersetErrorException", exc_info=True)
+            return json_error_response([ex.error], status=ex.status)
+        except SupersetException as ex:
+            if ex.status >= 500:
+                logger.exception(ex)
+            return json_error_response(
+                utils.error_msg_from_exception(ex), status=ex.status
+            )
+        except HTTPException as ex:
+            logger.exception(ex)
+            return json_error_response(
+                utils.error_msg_from_exception(ex), status=cast(int, ex.code)
+            )
+        except (exc.IntegrityError, exc.DatabaseError, exc.DataError) as ex:
+            logger.exception(ex)
+            return json_error_response(utils.error_msg_from_exception(ex), 
status=422)
+        except Exception as ex:  # pylint: disable=broad-except
+            logger.exception(ex)
+            return json_error_response(utils.error_msg_from_exception(ex))
+
+    return functools.update_wrapper(wraps, f)
+
+
+def set_app_error_handlers(app: Flask) -> None:
+    """
+    Set up error handlers for the Flask app
+    Refer to SIP-40 and SIP-41 for more details on the error handling strategy
+    """
+
+    @app.errorhandler(SupersetErrorException)
+    def show_superset_error(ex: SupersetErrorException) -> FlaskResponse:
+        logger.warning("SupersetErrorException", exc_info=True)
+        return json_error_response([ex.error], status=ex.status)
+
+    @app.errorhandler(SupersetErrorsException)
+    def show_superset_errors(ex: SupersetErrorsException) -> FlaskResponse:
+        logger.warning("SupersetErrorsException", exc_info=True)
+        return json_error_response(ex.errors, status=ex.status)
+
+    @app.errorhandler(CSRFError)
+    def refresh_csrf_token(ex: CSRFError) -> FlaskResponse:
+        """Redirect to login if the CSRF token is expired"""
+        logger.warning("Refresh CSRF token error", exc_info=True)
+
+        if request.is_json:
+            return show_http_exception(ex)
+
+        return redirect(appbuilder.get_url_for_login)
+
+    @app.errorhandler(HTTPException)
+    def show_http_exception(ex: HTTPException) -> FlaskResponse:
+        logger.warning("HTTPException", exc_info=True)
+        if (
+            "text/html" in request.accept_mimetypes
+            and not app.config["DEBUG"]
+            and ex.code in {404, 500}
+        ):
+            path = files("superset") / f"static/assets/{ex.code}.html"
+            return send_file(path, max_age=0), ex.code
+
+        return json_error_response(
+            [
+                SupersetError(
+                    message=utils.error_msg_from_exception(ex),
+                    error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
+                    level=ErrorLevel.ERROR,
+                ),
+            ],
+            status=ex.code or 500,
+        )
+
+    @app.errorhandler(CommandException)
+    def show_command_errors(ex: CommandException) -> FlaskResponse:
+        """
+        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
+        """
+        logger.warning("CommandException", exc_info=True)
+        if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
+            path = files("superset") / "static/assets/500.html"
+            return send_file(path, max_age=0), 500
+
+        extra = ex.normalized_messages() if isinstance(ex, 
CommandInvalidError) else {}
+        return json_error_response(
+            [
+                SupersetError(
+                    message=ex.message,
+                    error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
+                    level=get_error_level_from_status(ex.status),
+                    extra=extra,
+                ),
+            ],
+            status=ex.status,
+        )
+
+    @app.errorhandler(Exception)
+    @app.errorhandler(500)
+    def show_unexpected_exception(ex: Exception) -> FlaskResponse:
+        """Catch-all, to ensure all errors from the backend conform to 
SIP-40"""
+        logger.warning("Exception", exc_info=True)
+        logger.exception(ex)
+        if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
+            path = files("superset") / "static/assets/500.html"
+            return send_file(path, max_age=0), 500
+
+        return json_error_response(
+            [
+                SupersetError(
+                    message=utils.error_msg_from_exception(ex),
+                    error_type=SupersetErrorType.GENERIC_BACKEND_ERROR,
+                    level=ErrorLevel.ERROR,
+                ),
+            ],
+        )

Reply via email to