This is an automated email from the ASF dual-hosted git repository. eschutho pushed a commit to branch fix-ssh-tunnel-error-noise in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1a4bdecdef2df87a72f251cef7cb1dfe0387843a Author: Elizabeth Thompson <[email protected]> AuthorDate: Tue Jul 28 15:20:13 2026 +0000 fix(errors): downgrade SSH tunnel connection-failure logging to WARNING (SC-115347) Query-time SSH tunnel gateway failures (BaseSSHTunnelForwarderError, raised from Database.get_sqla_engine()) were falling through to the generic ERROR-level catch-alls in handle_api_exception.wraps and show_unexpected_exception, producing an opaque 500 and double-logging at ERROR for what is an expected, environment-specific failure (analogous to a database being unreachable), not a Superset bug. Both catch sites now recognize sshtunnel.BaseSSHTunnelForwarderError and return a structured 400 response with SupersetErrorType.CONNECTION_HOST_DOWN_ERROR, logging at WARNING (with exc_info preserved) instead of ERROR. Fixes SUPERSET-PYTHON-YY5 Fixes SUPERSET-PYTHON-T7B Co-Authored-By: Claude <[email protected]> --- superset/views/error_handling.py | 30 +++++++ tests/unit_tests/views/test_error_handling.py | 112 ++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/superset/views/error_handling.py b/superset/views/error_handling.py index 236291d96ec..c8c46e64acc 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)) @@ -215,6 +242,9 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901 @app.errorhandler(500) def show_unexpected_exception(ex: Exception) -> FlaskResponse: """Catch-all, to ensure all errors from the backend conform to SIP-40""" + if isinstance(ex, sshtunnel.BaseSSHTunnelForwarderError): + return handle_ssh_tunnel_error(ex) + logger.warning("Exception", exc_info=True) logger.exception(ex) 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)
