This is an automated email from the ASF dual-hosted git repository.
eschutho 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 bab40cf437c fix(errors): downgrade SSH tunnel connection-failure
logging to WARNING (SC-115347) (#42538)
bab40cf437c is described below
commit bab40cf437c69336cb19b650ced19d44de0967ed
Author: Elizabeth Thompson <[email protected]>
AuthorDate: Wed Jul 29 15:03:17 2026 -0700
fix(errors): downgrade SSH tunnel connection-failure logging to WARNING
(SC-115347) (#42538)
Co-authored-by: Claude <[email protected]>
---
superset/views/error_handling.py | 33 ++++++++
tests/unit_tests/views/test_error_handling.py | 112 ++++++++++++++++++++++++++
2 files changed, 145 insertions(+)
diff --git a/superset/views/error_handling.py b/superset/views/error_handling.py
index 236291d96ec..f7e2ad8bc5f 100644
--- a/superset/views/error_handling.py
+++ b/superset/views/error_handling.py
@@ -23,12 +23,14 @@ import typing
from importlib.resources import files
from typing import Any, Callable, cast
+import sshtunnel
from flask import (
Flask,
request,
Response,
send_file,
)
+from flask_babel import gettext as _
from flask_wtf.csrf import CSRFError
from sqlalchemy import exc
from werkzeug.exceptions import HTTPException
@@ -86,6 +88,29 @@ def json_error_response(
)
+def handle_ssh_tunnel_error(ex: sshtunnel.BaseSSHTunnelForwarderError) ->
FlaskResponse:
+ """
+ Build the structured response for an unreachable/misconfigured SSH tunnel
+ gateway. This is an expected environmental failure (analogous to a
+ database connection failure), so it is logged at WARNING rather than
+ ERROR to avoid alarm fatigue on otherwise-actionable alerting.
+ """
+ logger.warning("BaseSSHTunnelForwarderError", exc_info=True)
+ return json_error_response(
+ [
+ SupersetError(
+ message=_(
+ "Failed to establish an SSH tunnel to the database:
%(reason)s",
+ reason=str(ex),
+ ),
+ error_type=SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+ level=ErrorLevel.WARNING,
+ ),
+ ],
+ status=400,
+ )
+
+
def handle_api_exception(
f: Callable[..., FlaskResponse],
) -> Callable[..., FlaskResponse]:
@@ -121,6 +146,8 @@ def handle_api_exception(
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 sshtunnel.BaseSSHTunnelForwarderError as ex:
+ return handle_ssh_tunnel_error(ex)
except Exception as ex: # pylint: disable=broad-except
logger.exception(ex)
return json_error_response(utils.error_msg_from_exception(ex))
@@ -211,6 +238,12 @@ def set_app_error_handlers(app: Flask) -> None: # noqa:
C901
status=ex.status,
)
+ @app.errorhandler(sshtunnel.BaseSSHTunnelForwarderError)
+ def show_ssh_tunnel_error(
+ ex: sshtunnel.BaseSSHTunnelForwarderError,
+ ) -> FlaskResponse:
+ return handle_ssh_tunnel_error(ex)
+
@app.errorhandler(Exception)
@app.errorhandler(500)
def show_unexpected_exception(ex: Exception) -> FlaskResponse:
diff --git a/tests/unit_tests/views/test_error_handling.py
b/tests/unit_tests/views/test_error_handling.py
new file mode 100644
index 00000000000..a3f46fa3a63
--- /dev/null
+++ b/tests/unit_tests/views/test_error_handling.py
@@ -0,0 +1,112 @@
+# 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 logging
+from typing import cast
+
+import pytest
+import sshtunnel
+from flask import Flask, Response
+from flask_babel import Babel
+
+from superset.errors import SupersetErrorType
+from superset.superset_typing import FlaskResponse
+from superset.utils import json
+from superset.views.error_handling import handle_api_exception,
set_app_error_handlers
+
+
+class TestHandleApiExceptionSSHTunnelError:
+ def test_returns_400_with_connection_host_down_error_and_no_error_log(
+ self, app, caplog: pytest.LogCaptureFixture
+ ):
+ @handle_api_exception
+ def view(self: object) -> FlaskResponse:
+ raise sshtunnel.BaseSSHTunnelForwarderError(
+ "Could not establish session to SSH gateway"
+ )
+
+ with app.test_request_context():
+ with caplog.at_level(logging.WARNING):
+ response = cast(Response, view(self=object()))
+
+ assert response.status_code == 400
+ payload = json.loads(response.data)
+ assert (
+ payload["errors"][0]["error_type"]
+ == SupersetErrorType.CONNECTION_HOST_DOWN_ERROR.value
+ )
+ assert not any(record.levelno >= logging.ERROR for record in
caplog.records)
+ assert any(
+ record.levelno == logging.WARNING
+ and "BaseSSHTunnelForwarderError" in record.message
+ for record in caplog.records
+ )
+
+
+class TestShowUnexpectedException:
+ def _build_app_with_handlers(self) -> Flask:
+ # A fresh, minimal Flask app per test: `set_app_error_handlers` can
+ # only register handlers before the app has served its first
+ # request, so it can't share the module-scoped `app` fixture across
+ # tests in this class.
+ test_app = Flask(__name__)
+ test_app.config["DEBUG"] = False
+ Babel(test_app)
+ set_app_error_handlers(test_app)
+
+ @test_app.route("/ssh-tunnel-error")
+ def ssh_tunnel_error_view() -> FlaskResponse:
+ raise sshtunnel.BaseSSHTunnelForwarderError(
+ "Could not establish session to SSH gateway"
+ )
+
+ @test_app.route("/generic-error")
+ def generic_error_view() -> FlaskResponse:
+ raise ValueError("boom")
+
+ return test_app
+
+ def test_ssh_tunnel_error_returns_structured_400(
+ self, caplog: pytest.LogCaptureFixture
+ ):
+ client = self._build_app_with_handlers().test_client()
+
+ with caplog.at_level(logging.WARNING):
+ response = client.get("/ssh-tunnel-error")
+
+ assert response.status_code == 400
+ payload = json.loads(response.data)
+ assert (
+ payload["errors"][0]["error_type"]
+ == SupersetErrorType.CONNECTION_HOST_DOWN_ERROR.value
+ )
+ assert not any(record.levelno >= logging.ERROR for record in
caplog.records)
+
+ def test_generic_exception_still_returns_original_500_shape(
+ self, caplog: pytest.LogCaptureFixture
+ ):
+ client = self._build_app_with_handlers().test_client()
+
+ with caplog.at_level(logging.WARNING):
+ response = client.get("/generic-error")
+
+ assert response.status_code == 500
+ payload = json.loads(response.data)
+ assert (
+ payload["errors"][0]["error_type"]
+ == SupersetErrorType.GENERIC_BACKEND_ERROR.value
+ )
+ assert any(record.levelno >= logging.ERROR for record in
caplog.records)