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]