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

diegopucci 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 19db0353a9 feat(db): custom database error messages (#34674)
19db0353a9 is described below

commit 19db0353a974342be669428dde6de0e8ef81e052
Author: Damian Pendrak <[email protected]>
AuthorDate: Wed Oct 1 13:29:57 2025 +0200

    feat(db): custom database error messages (#34674)
---
 docs/docs/configuration/databases.mdx              |  42 +++
 .../components/ErrorMessage/CustomDocLink.test.tsx |  39 +++
 .../src/components/ErrorMessage/CustomDocLink.tsx  |  33 ++
 .../ErrorMessage/DatabaseErrorMessage.test.tsx     |  57 ++++
 .../ErrorMessage/DatabaseErrorMessage.tsx          |  29 +-
 superset/commands/database/test_connection.py      |   4 +-
 superset/commands/database/validate.py             |   4 +-
 superset/config.py                                 |   8 +
 superset/connectors/sqla/models.py                 |   5 +-
 superset/custom_database_errors.py                 |  83 +++++
 superset/db_engine_specs/base.py                   |  29 +-
 superset/db_engine_specs/databricks.py             |  15 +-
 superset/models/helpers.py                         |   5 +-
 superset/sql_lab.py                                |   4 +-
 tests/unit_tests/db_engine_specs/test_base.py      | 355 +++++++++++++++++++++
 15 files changed, 695 insertions(+), 17 deletions(-)

diff --git a/docs/docs/configuration/databases.mdx 
b/docs/docs/configuration/databases.mdx
index 0be119b8f3..4d8a0e1a4c 100644
--- a/docs/docs/configuration/databases.mdx
+++ b/docs/docs/configuration/databases.mdx
@@ -1769,6 +1769,48 @@ You can use the `Extra` field in the **Edit Databases** 
form to configure SSL:
      }
 }
 ```
+##### Custom Error Messages
+You can use the `CUSTOM_DATABASE_ERRORS` in the 
`superset/custom_database_errors.py` file or overwrite it in your config file 
to configure custom error messages for database exceptions.
+
+This feature lets you transform raw database errors into user-friendly 
messages, optionally including documentation links and hiding default error 
codes.
+
+Provide an empty string as the first value to keep the original error message. 
This way, you can add just a link to the documentation
+**Example usage:**
+```Python
+CUSTOM_DATABASE_ERRORS = {
+    "database_name": {
+        re.compile('permission denied for view'): (
+            __(
+                'Permission denied'
+            ),
+            SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+            {
+                "custom_doc_links": [
+                    {
+                        "url": "https://example.com/docs/1";,
+                        "label": "Check documentation"
+                    },
+                ],
+                "show_issue_info": False,
+            }
+        )
+    },
+    "examples": {
+        re.compile(r'message="(?P<message>[^"]*)"'): (
+            __(
+                'Unexpected error: "%(message)s"'
+            ),
+            SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+            {}
+        )
+    }
+}
+```
+
+**Options:**
+
+- ``custom_doc_links``: List of documentation links to display with the error.
+- ``show_issue_info``: Set to ``False`` to hide default error codes.
 
 ## Misc
 
diff --git 
a/superset-frontend/src/components/ErrorMessage/CustomDocLink.test.tsx 
b/superset-frontend/src/components/ErrorMessage/CustomDocLink.test.tsx
new file mode 100644
index 0000000000..1b881bba79
--- /dev/null
+++ b/superset-frontend/src/components/ErrorMessage/CustomDocLink.test.tsx
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+import { render, screen } from 'spec/helpers/testing-library';
+import { CustomDocLink } from './CustomDocLink';
+
+const mockedProps = {
+  url: 'https://superset.apache.org/docs/',
+  label: 'Superset Docs',
+};
+
+test('should render the label', () => {
+  render(<CustomDocLink {...mockedProps} />);
+  expect(screen.getByText('Superset Docs')).toBeInTheDocument();
+});
+
+test('should render the link with correct attributes', () => {
+  render(<CustomDocLink {...mockedProps} />);
+  const link = screen.getByRole('link');
+  expect(link).toHaveAttribute('href', mockedProps.url);
+  expect(link).toHaveAttribute('target', '_blank');
+  expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+});
diff --git a/superset-frontend/src/components/ErrorMessage/CustomDocLink.tsx 
b/superset-frontend/src/components/ErrorMessage/CustomDocLink.tsx
new file mode 100644
index 0000000000..0ddc4c224a
--- /dev/null
+++ b/superset-frontend/src/components/ErrorMessage/CustomDocLink.tsx
@@ -0,0 +1,33 @@
+/**
+ * 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.
+ */
+
+import { Flex, Icons } from '@superset-ui/core/components';
+
+export type CustomDocLinkProps = {
+  url: string;
+  label: string;
+};
+
+export const CustomDocLink = ({ url, label }: CustomDocLinkProps) => (
+  <a href={url} target="_blank" rel="noopener noreferrer">
+    <Flex align="center" gap={4}>
+      {label} <Icons.Full iconSize="m" />
+    </Flex>
+  </a>
+);
diff --git 
a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx 
b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx
index 4136708122..d09337c72e 100644
--- 
a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx
+++ 
b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.test.tsx
@@ -53,6 +53,38 @@ const mockedProps = {
   subtitle: 'Error message',
 };
 
+const mockedPropsWithCustomError = {
+  ...mockedProps,
+  error: {
+    ...mockedProps.error,
+    extra: {
+      ...mockedProps.error.extra,
+      custom_doc_links: [
+        {
+          label: 'Custom Doc Link 1',
+          url: 'https://example.com/custom-doc-1',
+        },
+        {
+          label: 'Custom Doc Link 2',
+          url: 'https://example.com/custom-doc-2',
+        },
+      ],
+      show_issue_info: false,
+    },
+  },
+};
+
+const mockedPropsWithCustomErrorAndBadLinks = {
+  ...mockedProps,
+  error: {
+    ...mockedProps.error,
+    extra: {
+      ...mockedProps.error.extra,
+      custom_doc_links: true,
+    },
+  },
+};
+
 test('should render', () => {
   const nullExtraProps = {
     ...mockedProps,
@@ -112,3 +144,28 @@ test('should NOT render the owners', () => {
     screen.queryByText('Chart Owners: Owner A, Owner B'),
   ).not.toBeInTheDocument();
 });
+
+test('should render custom documentation links when provided', () => {
+  render(<DatabaseErrorMessage {...mockedPropsWithCustomError} />, {
+    useRedux: true,
+  });
+  expect(screen.getByText('Custom Doc Link 1')).toBeInTheDocument();
+  expect(screen.getByText('Custom Doc Link 2')).toBeInTheDocument();
+});
+
+test('should NOT render see more button when show_issue_info is false', () => {
+  render(<DatabaseErrorMessage {...mockedPropsWithCustomError} />, {
+    useRedux: true,
+  });
+  const button = screen.queryByText('See more');
+  expect(button).not.toBeInTheDocument();
+});
+
+test('should render message when wrong value provided for custom_doc_urls', () 
=> {
+  // @ts-ignore
+  render(<DatabaseErrorMessage {...mockedPropsWithCustomErrorAndBadLinks} />, {
+    useRedux: true,
+  });
+  const button = screen.queryByText('Error message');
+  expect(button).toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx 
b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx
index dcbd5fca5e..f139703a49 100644
--- a/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx
+++ b/superset-frontend/src/components/ErrorMessage/DatabaseErrorMessage.tsx
@@ -22,6 +22,7 @@ import { t, tn } from '@superset-ui/core';
 import type { ErrorMessageComponentProps } from './types';
 import { IssueCode } from './IssueCode';
 import { ErrorAlert } from './ErrorAlert';
+import { CustomDocLink, CustomDocLinkProps } from './CustomDocLink';
 
 interface DatabaseErrorExtra {
   owners?: string[];
@@ -30,6 +31,8 @@ interface DatabaseErrorExtra {
     message: string;
   }[];
   engine_name: string | null;
+  custom_doc_links?: CustomDocLinkProps[];
+  show_issue_info?: boolean;
 }
 
 export function DatabaseErrorMessage({
@@ -40,20 +43,32 @@ export function DatabaseErrorMessage({
 
   const isVisualization = ['dashboard', 'explore'].includes(source || '');
   const [firstLine, ...remainingLines] = message.split('\n');
-  const alertMessage = firstLine;
   const alertDescription =
     remainingLines.length > 0 ? remainingLines.join('\n') : null;
+  let alertMessage: ReactNode = firstLine;
 
-  const body = extra && (
+  if (Array.isArray(extra?.custom_doc_links)) {
+    alertMessage = (
+      <>
+        {firstLine}
+        {extra.custom_doc_links.map(link => (
+          <div key={link.url}>
+            <CustomDocLink {...link} />
+          </div>
+        ))}
+      </>
+    );
+  }
+
+  const body = extra && extra.show_issue_info !== false && (
     <>
       <p>
         {t('This may be triggered by:')}
         <br />
-        {extra.issue_codes
-          ?.map<ReactNode>(issueCode => (
-            <IssueCode {...issueCode} key={issueCode.code} />
-          ))
-          .reduce((prev, curr) => [prev, <br />, curr])}
+        {extra.issue_codes?.flatMap((issueCode, idx, arr) => [
+          <IssueCode {...issueCode} key={issueCode.code} />,
+          idx < arr.length - 1 ? <br key={`br-${issueCode.code}`} /> : null,
+        ])}
       </p>
       {isVisualization && extra.owners && (
         <>
diff --git a/superset/commands/database/test_connection.py 
b/superset/commands/database/test_connection.py
index 1e5fb8db44..1bbb0013a4 100644
--- a/superset/commands/database/test_connection.py
+++ b/superset/commands/database/test_connection.py
@@ -189,7 +189,9 @@ class TestConnectionDatabaseCommand(BaseCommand):
                 engine=database.db_engine_spec.__name__,
             )
             # check for custom errors (wrong username, wrong password, etc)
-            errors = database.db_engine_spec.extract_errors(ex, self._context)
+            errors = database.db_engine_spec.extract_errors(
+                ex, self._context, database_name=database.unique_name
+            )
             raise SupersetErrorsException(errors, status=400) from ex
         except OAuth2RedirectError:
             raise
diff --git a/superset/commands/database/validate.py 
b/superset/commands/database/validate.py
index 41f5ce3e19..29c9497140 100644
--- a/superset/commands/database/validate.py
+++ b/superset/commands/database/validate.py
@@ -124,7 +124,9 @@ class ValidateDatabaseParametersCommand(BaseCommand):
                     "username": url.username,
                     "database": url.database,
                 }
-                errors = database.db_engine_spec.extract_errors(ex, context)
+                errors = database.db_engine_spec.extract_errors(
+                    ex, context, database_name=database.unique_name
+                )
                 raise DatabaseTestConnectionFailedError(errors, status=400) 
from ex
 
         if not alive:
diff --git a/superset/config.py b/superset/config.py
index 600b562f56..1707597405 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2196,6 +2196,14 @@ CATALOGS_SIMPLIFIED_MIGRATION: bool = False
 # keeping a web API call open for this long.
 SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: bool = False
 
+# CUSTOM_DATABASE_ERRORS: Configure custom error messages for database 
exceptions
+# in superset/custom_database_errors.py.
+# Transform raw database errors into user-friendly messages with optional 
documentation
+try:
+    from superset.custom_database_errors import CUSTOM_DATABASE_ERRORS
+except ImportError:
+    CUSTOM_DATABASE_ERRORS = {}
+
 
 LOCAL_EXTENSIONS: list[str] = []
 EXTENSIONS_PATH: str | None = None
diff --git a/superset/connectors/sqla/models.py 
b/superset/connectors/sqla/models.py
index b0e71a7fa8..f799a65880 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1648,7 +1648,10 @@ class SqlaTable(
             )
             db_engine_spec = self.db_engine_spec
             errors = [
-                dataclasses.asdict(error) for error in 
db_engine_spec.extract_errors(ex)
+                dataclasses.asdict(error)
+                for error in db_engine_spec.extract_errors(
+                    ex, database_name=self.database.unique_name
+                )
             ]
             error_message = utils.error_msg_from_exception(ex)
 
diff --git a/superset/custom_database_errors.py 
b/superset/custom_database_errors.py
new file mode 100644
index 0000000000..41991856f5
--- /dev/null
+++ b/superset/custom_database_errors.py
@@ -0,0 +1,83 @@
+# 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.
+
+import re
+from typing import Any
+
+from flask_babel import gettext as __
+
+from superset.errors import SupersetErrorType
+
+# CUSTOM_DATABASE_ERRORS: Configure custom error messages for database 
exceptions.
+# Transform raw database errors into user-friendly messages with optional 
documentation
+# links using custom_doc_links. Set show_issue_info=False to hide default 
error codes.
+# Example:
+# CUSTOM_DATABASE_ERRORS = {
+#     "database_name": {
+#         re.compile('permission denied for view'): (
+#             __(
+#                 'Permission denied'
+#             ),
+#             SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+#             {
+#                 "custom_doc_links": [
+#                     {
+#                         "url": "https://example.com/docs/1";,
+#                         "label": "Check documentation"
+#                     },
+#                 ],
+#                 "show_issue_info": False,
+#             }
+#         )
+#     },
+#     "examples": {
+#         re.compile(r'message="(?P<message>[^"]*)"'): (
+#             __(
+#                 'Unexpected error: "%(message)s"'
+#             ),
+#             SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+#             {}
+#         )
+#     }
+# }
+
+CUSTOM_DATABASE_ERRORS: dict[
+    str, dict[re.Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]]
+] = {
+    "examples": {
+        re.compile("no such table: a"): (
+            __("This is custom error message for a"),
+            SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+            {
+                "custom_doc_links": [
+                    {
+                        "url": "https://example.com/docs/1";,
+                        "label": "Custom documentation link",
+                    },
+                ],
+                "show_issue_info": False,
+            },
+        ),
+        re.compile("no such table: b"): (
+            __("This is custom error message for b"),
+            SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+            {
+                "show_issue_info": True,
+            },
+        ),
+    }
+}
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index f5e35d5814..74ba20ee9c 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -1326,21 +1326,44 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
         """Extract error message for queries"""
         return utils.error_msg_from_exception(ex)
 
+    @classmethod
+    def get_database_custom_errors(
+        cls, database_name: str | None
+    ) -> dict[Any, tuple[str, SupersetErrorType, dict[str, Any]]]:
+        config_custom_errors = app.config.get("CUSTOM_DATABASE_ERRORS", {})
+        if not isinstance(config_custom_errors, dict):
+            return {}
+
+        if database_name and database_name in config_custom_errors:
+            database_errors = config_custom_errors[database_name]
+            if isinstance(database_errors, dict):
+                return database_errors
+        return {}
+
     @classmethod
     def extract_errors(
-        cls, ex: Exception, context: dict[str, Any] | None = None
+        cls,
+        ex: Exception,
+        context: dict[str, Any] | None = None,
+        database_name: str | None = None,
     ) -> list[SupersetError]:
         raw_message = cls._extract_error_message(ex)
 
         context = context or {}
-        for regex, (message, error_type, extra) in cls.custom_errors.items():
+        db_engine_custom_errors = cls.get_database_custom_errors(database_name)
+
+        for regex, (message, error_type, extra) in [
+            *db_engine_custom_errors.items(),
+            *cls.custom_errors.items(),
+        ]:
             if match := regex.search(raw_message):
                 params = {**context, **match.groupdict()}
                 extra["engine_name"] = cls.engine_name
+                formatted_message = (message % params) if message else 
raw_message
                 return [
                     SupersetError(
                         error_type=error_type,
-                        message=message % params,
+                        message=formatted_message,
                         level=ErrorLevel.ERROR,
                         extra=extra,
                     )
diff --git a/superset/db_engine_specs/databricks.py 
b/superset/db_engine_specs/databricks.py
index 55ec2d1479..fc3cb8a552 100644
--- a/superset/db_engine_specs/databricks.py
+++ b/superset/db_engine_specs/databricks.py
@@ -296,11 +296,15 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
 
     @classmethod
     def extract_errors(
-        cls, ex: Exception, context: dict[str, Any] | None = None
+        cls,
+        ex: Exception,
+        context: dict[str, Any] | None = None,
+        database_name: str | None = None,
     ) -> list[SupersetError]:
         raw_message = cls._extract_error_message(ex)
 
         context = context or {}
+
         # access_token isn't currently parseable from the
         # databricks error response, but adding it in here
         # for reference if their error message changes
@@ -308,7 +312,14 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
         for key, value in cls.context_key_mapping.items():
             context[key] = context.get(value)
 
-        for regex, (message, error_type, extra) in cls.custom_errors.items():
+        db_engine_custom_errors = cls.get_database_custom_errors(database_name)
+        if not isinstance(db_engine_custom_errors, dict):
+            db_engine_custom_errors = {}
+
+        for regex, (message, error_type, extra) in [
+            *db_engine_custom_errors.items(),
+            *cls.custom_errors.items(),
+        ]:
             match = regex.search(raw_message)
             if match:
                 params = {**context, **match.groupdict()}
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 2bb0c85c53..5e7b5d9861 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -1087,7 +1087,10 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
             )
             db_engine_spec = self.db_engine_spec
             errors = [
-                dataclasses.asdict(error) for error in 
db_engine_spec.extract_errors(ex)
+                dataclasses.asdict(error)
+                for error in db_engine_spec.extract_errors(
+                    ex, database_name=self.database.unique_name
+                )
             ]
             error_message = utils.error_msg_from_exception(ex)
 
diff --git a/superset/sql_lab.py b/superset/sql_lab.py
index c15e924246..2779f96a15 100644
--- a/superset/sql_lab.py
+++ b/superset/sql_lab.py
@@ -111,7 +111,9 @@ def handle_query_error(
     elif isinstance(ex, SupersetErrorsException):
         errors = ex.errors
     else:
-        errors = query.database.db_engine_spec.extract_errors(str(ex))
+        errors = query.database.db_engine_spec.extract_errors(
+            str(ex), database_name=query.database.unique_name
+        )
 
     errors_payload = [dataclasses.asdict(error) for error in errors]
     if errors:
diff --git a/tests/unit_tests/db_engine_specs/test_base.py 
b/tests/unit_tests/db_engine_specs/test_base.py
index ec9689dd17..6db0edb813 100644
--- a/tests/unit_tests/db_engine_specs/test_base.py
+++ b/tests/unit_tests/db_engine_specs/test_base.py
@@ -20,6 +20,7 @@
 from __future__ import annotations
 
 import json  # noqa: TID251
+import re
 from textwrap import dedent
 from typing import Any
 
@@ -30,12 +31,39 @@ from sqlalchemy.dialects import sqlite
 from sqlalchemy.engine.url import make_url, URL
 from sqlalchemy.sql import sqltypes
 
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.sql.parse import Table
 from superset.superset_typing import ResultSetColumnType, SQLAColumnType
 from superset.utils.core import GenericDataType
 from tests.unit_tests.db_engine_specs.utils import assert_column_spec
 
 
+def create_expected_superset_error(
+    message: str,
+    error_type: SupersetErrorType = SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+    engine_name: str | None = None,
+) -> SupersetError:
+    """
+    Helper function to create expected SupersetError objects for testing.
+    """
+    extra = {
+        "engine_name": engine_name,
+        "issue_codes": [
+            {
+                "code": 1002,
+                "message": "Issue 1002 - The database returned an unexpected 
error.",
+            }
+        ],
+    }
+
+    return SupersetError(
+        message=message,
+        error_type=error_type,
+        level=ErrorLevel.ERROR,
+        extra=extra,
+    )
+
+
 def test_get_text_clause_with_colon() -> None:
     """
     Make sure text clauses are correctly escaped
@@ -539,3 +567,330 @@ def test_use_equality_for_boolean_filters_property() -> 
None:
 
     # Default should be False (use IS operators)
     assert BaseEngineSpec.use_equality_for_boolean_filters is False
+
+
+def test_extract_errors(mocker: MockerFixture) -> None:
+    """
+    Test that error is extracted correctly when no custom error message is 
provided.
+    """
+
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    mocker.patch(
+        "flask.current_app.config",
+        {},
+    )
+
+    msg = "This connector does not support roles"
+    result = BaseEngineSpec.extract_errors(Exception(msg))
+
+    expected = create_expected_superset_error(
+        message="This connector does not support roles",
+        engine_name=None,
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_from_config(mocker: MockerFixture) -> None:
+    """
+    Test that custom error messages are extracted correctly from app config
+    using database_name.
+    """
+
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile("This connector does not support roles"): (
+                        "Custom error message",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                }
+            }
+        },
+    )
+
+    msg = "This connector does not support roles"
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples")
+
+    expected = create_expected_superset_error(
+        message="Custom error message",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_only_to_specified_database(mocker: MockerFixture) -> 
None:
+    """
+    Test that custom error messages are only applied to the specified 
database_name.
+    """
+
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile("This connector does not support roles"): (
+                        "Custom error message",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                }
+            }
+        },
+    )
+
+    msg = "This connector does not support roles"
+    # database_name doesn't match configured one, so default message is used
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples_2")
+
+    expected = create_expected_superset_error(
+        message="This connector does not support roles",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_from_config_with_regex(mocker: MockerFixture) -> None:
+    """
+    Test that custom error messages with regex, custom_doc_links,
+    and show_issue_info are extracted correctly from config.
+    """
+
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile(r'message="(?P<message>[^"]*)"'): (
+                        'Unexpected error: "%(message)s"',
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {
+                            "custom_doc_links": [
+                                {
+                                    "url": "https://example.com/docs";,
+                                    "label": "Check documentation",
+                                },
+                            ],
+                            "show_issue_info": False,
+                        },
+                    )
+                }
+            }
+        },
+    )
+
+    msg = (
+        "db error: SomeUserError(type=USER_ERROR, name=TABLE_NOT_FOUND, "
+        'message="line 3:6: Table '
+        "'example_catalog.example_schema.example_table' does not exist"
+        '", '
+        "query_id=20250812_074513_00084_kju62)"
+    )
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples")
+
+    assert result == [
+        SupersetError(
+            message=(
+                'Unexpected error: "line 3:6: Table '
+                "'example_catalog.example_schema.example_table' does not exist"
+                '"'
+            ),
+            error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={
+                "engine_name": "ExampleEngine",
+                "issue_codes": [
+                    {
+                        "code": 1002,
+                        "message": "Issue 1002 - The database returned an 
unexpected error.",  # noqa: E501
+                    }
+                ],
+                "custom_doc_links": [
+                    {
+                        "url": "https://example.com/docs";,
+                        "label": "Check documentation",
+                    },
+                ],
+                "show_issue_info": False,
+            },
+        )
+    ]
+
+
+def test_extract_errors_with_non_dict_custom_errors(mocker: MockerFixture):
+    """
+    Test that extract_errors doesn't fail when custom database errors
+    are in wrong format.
+    """
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {"CUSTOM_DATABASE_ERRORS": "not a dict"},
+    )
+
+    msg = "This connector does not support roles"
+    result = TestEngineSpec.extract_errors(Exception(msg))
+
+    expected = create_expected_superset_error(
+        message="This connector does not support roles",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_with_non_dict_engine_custom_errors(mocker: 
MockerFixture):
+    """
+    Test that extract_errors doesn't fail when database-specific custom errors
+    are in wrong format.
+    """
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {"CUSTOM_DATABASE_ERRORS": {"examples": "not a dict"}},
+    )
+
+    msg = "This connector does not support roles"
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples")
+
+    expected = create_expected_superset_error(
+        message="This connector does not support roles",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_with_empty_custom_error_message(mocker: MockerFixture):
+    """
+    Test that when the custom error message is empty,
+    the original error message is preserved.
+    """
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile("This connector does not support roles"): (
+                        "",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                }
+            }
+        },
+    )
+
+    msg = "This connector does not support roles"
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples")
+
+    expected = create_expected_superset_error(
+        message="This connector does not support roles",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_matches_database_name_selection(mocker: MockerFixture) 
-> None:
+    """
+    Test that custom error messages are matched by database_name.
+    """
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile("connection error"): (
+                        "Examples DB error message",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                },
+                "examples_2": {
+                    re.compile("connection error"): (
+                        "Examples_2 DB error message",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                },
+            }
+        },
+    )
+
+    msg = "connection error occurred"
+    # When database_name is examples_2 we should get that specific message
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples_2")
+
+    expected = create_expected_superset_error(
+        message="Examples_2 DB error message",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]
+
+
+def test_extract_errors_no_match_falls_back(mocker: MockerFixture) -> None:
+    """
+    Test that when database_name has no match, the original error message is 
preserved.
+    """
+    from superset.db_engine_specs.base import BaseEngineSpec
+
+    class TestEngineSpec(BaseEngineSpec):
+        engine_name = "ExampleEngine"
+
+    mocker.patch(
+        "flask.current_app.config",
+        {
+            "CUSTOM_DATABASE_ERRORS": {
+                "examples": {
+                    re.compile("connection error"): (
+                        "Examples DB error message",
+                        SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                        {},
+                    )
+                },
+            }
+        },
+    )
+
+    msg = "some other error"
+    result = TestEngineSpec.extract_errors(Exception(msg), 
database_name="examples_2")
+
+    expected = create_expected_superset_error(
+        message="some other error",
+        engine_name="ExampleEngine",
+    )
+    assert result == [expected]


Reply via email to