This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 9322e902156 [v3-3-test] Route SQLAlchemyError through global exception
handler (#69267) (#70236)
9322e902156 is described below
commit 9322e9021568210d1550691d4677016a15aa5fdc
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jul 22 16:33:26 2026 +0200
[v3-3-test] Route SQLAlchemyError through global exception handler (#69267)
(#70236)
* Route SQLAlchemyError through global exception handler
* Replace manual traceback with logging
* Update airflow-core/src/airflow/api_fastapi/common/exceptions.py
* Add test for ti_run and include traceback when expose_stacktrace=True
* Add test for ti_run and include traceback when expose_stacktrace=True
* fix referenced before assignment problems
* Handle execution API database errors consistently
* Handle execution API database errors consistently
* Refactor duplicated database error handling into helper
* fix code format
* Trigger CI rerun
* Move database error response handling into handler class
---------
(cherry picked from commit 7c03dabdcddfda99e8b663bd254ce18637278407)
Co-authored-by: fat-catTW <[email protected]>
Co-authored-by: Jason(Zhe-You) Liu
<[email protected]>
---
airflow-core/src/airflow/api_fastapi/app.py | 3 +-
.../src/airflow/api_fastapi/common/exceptions.py | 48 ++++++++++++-----
.../src/airflow/api_fastapi/core_api/app.py | 7 ---
.../src/airflow/api_fastapi/execution_api/app.py | 2 +
.../execution_api/routes/task_instances.py | 14 ++---
.../unit/api_fastapi/common/test_exceptions.py | 40 +++++++++++++++
.../unit/api_fastapi/execution_api/test_app.py | 18 ++++++-
.../versions/head/test_task_instances.py | 60 +++++++++++++++++++++-
8 files changed, 159 insertions(+), 33 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/app.py
b/airflow-core/src/airflow/api_fastapi/app.py
index a4dbc3c9b72..e4d1e40efbb 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -27,9 +27,9 @@ from fastapi import FastAPI
from fastapi.routing import Mount
from airflow.api_fastapi.common.dagbag import create_dag_bag
+from airflow.api_fastapi.common.exceptions import init_error_handlers
from airflow.api_fastapi.core_api.app import (
init_config,
- init_error_handlers,
init_flask_plugins,
init_middlewares,
init_views,
@@ -128,7 +128,6 @@ def create_app(apps: str = "all") -> FastAPI:
if "all" in apps_list or "execution" in apps_list:
task_exec_api_app = create_task_execution_api_app()
task_exec_api_app.state.dag_bag = dag_bag
- init_error_handlers(task_exec_api_app)
app.mount("/execution", task_exec_api_app)
if "all" in apps_list or "core" in apps_list:
diff --git a/airflow-core/src/airflow/api_fastapi/common/exceptions.py
b/airflow-core/src/airflow/api_fastapi/common/exceptions.py
index 86af9e062cd..00cae2bbdd1 100644
--- a/airflow-core/src/airflow/api_fastapi/common/exceptions.py
+++ b/airflow-core/src/airflow/api_fastapi/common/exceptions.py
@@ -23,15 +23,15 @@ from abc import ABC, abstractmethod
from enum import Enum
from typing import Generic, TypeVar
-from fastapi import HTTPException, Request, status
-from sqlalchemy.exc import DatabaseError, DataError, IntegrityError
+from fastapi import FastAPI, HTTPException, Request, status
+from sqlalchemy.exc import DataError, IntegrityError, SQLAlchemyError
from airflow.configuration import conf
from airflow.exceptions import DeserializationError
from airflow.utils.strings import get_random_string
T = TypeVar("T", bound=Exception)
-DBError = TypeVar("DBError", bound=DatabaseError)
+DBError = TypeVar("DBError", bound=SQLAlchemyError)
log = logging.getLogger(__name__)
@@ -70,35 +70,41 @@ class _DatabaseErrorHandler(BaseErrorHandler[DBError]):
def _should_handle(self, exc: DBError) -> bool:
return True
- def exception_handler(self, request: Request, exc: DBError):
- if not self._should_handle(exc):
- return
+ def _raise_database_error_response(self, exc: DBError) -> None:
+ statement = getattr(exc, "statement", "hidden")
+ orig_error = getattr(exc, "orig", "hidden")
exception_id = get_random_string()
stacktrace = "".join(traceback.format_tb(exc.__traceback__))
- log_message = f"Error with id {exception_id}, statement:
{exc.statement}\n{stacktrace}"
+ log_message = f"Error with id {exception_id}, statement:
{statement}\n{stacktrace}"
log.error(log_message)
+
if conf.get("api", "expose_stacktrace") == "True":
message = log_message
- statement = str(exc.statement)
- orig_error = str(exc.orig)
+ statement_out = str(statement)
+ orig_error_out = str(orig_error)
else:
message = (
"Serious error when handling your request. Check logs for more
details - "
f"you will find it in api server when you look for ID
{exception_id}"
)
- statement = "hidden"
- orig_error = "hidden"
+ statement_out = "hidden"
+ orig_error_out = "hidden"
raise HTTPException(
status_code=self.status_code,
detail={
"reason": self.reason,
- "statement": statement,
- "orig_error": orig_error,
+ "statement": statement_out,
+ "orig_error": orig_error_out,
"message": message,
},
)
+ def exception_handler(self, request: Request, exc: DBError):
+ if not self._should_handle(exc):
+ return
+ self._raise_database_error_response(exc)
+
class _UniqueConstraintErrorHandler(_DatabaseErrorHandler[IntegrityError]):
"""Translate a unique-constraint ``IntegrityError`` into a 409, matched
per database dialect."""
@@ -158,8 +164,24 @@ class
DagErrorHandler(BaseErrorHandler[DeserializationError]):
)
+class SQLAlchemyErrorHandler(_DatabaseErrorHandler[SQLAlchemyError]):
+ """Generic handler for SQLAlchemyError -> 500 responses."""
+
+ status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
+ reason = "Database error"
+
+ def __init__(self):
+ super().__init__(SQLAlchemyError)
+
+
ERROR_HANDLERS: list[BaseErrorHandler] = [
_UniqueConstraintErrorHandler(),
DataErrorHandler(),
+ SQLAlchemyErrorHandler(),
DagErrorHandler(),
]
+
+
+def init_error_handlers(app: FastAPI) -> None:
+ for handler in ERROR_HANDLERS:
+ app.add_exception_handler(handler.exception_cls,
handler.exception_handler)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index 27213a4c0a2..961f13fbc71 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -170,13 +170,6 @@ def init_config(app: FastAPI) -> None:
app.state.secret_key = get_signing_key("api", "secret_key")
-def init_error_handlers(app: FastAPI) -> None:
- from airflow.api_fastapi.common.exceptions import ERROR_HANDLERS
-
- for handler in ERROR_HANDLERS:
- app.add_exception_handler(handler.exception_cls,
handler.exception_handler)
-
-
def init_middlewares(app: FastAPI) -> None:
from airflow.api_fastapi.app import get_auth_manager
from airflow.api_fastapi.auth.middlewares.refresh_token import
JWTRefreshMiddleware
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/app.py
b/airflow-core/src/airflow/api_fastapi/execution_api/app.py
index 5a87cdb81e0..f9e7cb17250 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/app.py
@@ -287,6 +287,7 @@ def _inject_trace_context_dep(routes, mode: str) -> None:
def create_task_execution_api_app(lifespan: svcs.fastapi.lifespan = lifespan)
-> FastAPI:
"""Create FastAPI app for task execution API."""
+ from airflow.api_fastapi.common.exceptions import init_error_handlers
from airflow.api_fastapi.execution_api.routes import execution_api_router
from airflow.api_fastapi.execution_api.versions import bundle
from airflow.configuration import conf
@@ -314,6 +315,7 @@ def create_task_execution_api_app(lifespan:
svcs.fastapi.lifespan = lifespan) ->
_inject_trace_context_dep(execution_api_router.routes, mode)
app.generate_and_include_versioned_routers(execution_api_router)
+ init_error_handlers(app)
# As we are mounted as a sub app, we don't get any logs for unhandled
exceptions without this!
@app.exception_handler(Exception)
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index c1bac796023..34c3dc35406 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -319,10 +319,8 @@ def ti_run(
# Let the app-level DataErrorHandler return a 422 (not the opaque 500
below).
raise
except SQLAlchemyError:
- log.exception("Error marking Task Instance state as running")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred"
- )
+ # Defer to app-level SQLAlchemyError handler (returns HTTP 500).
+ raise
# JWTReissueMiddleware also writes Refreshed-API-Token but skips workload
tokens, so we set it here for the workload→execution swap.
if token.claims.scope == "workload":
@@ -502,11 +500,9 @@ def ti_update_state(
except DataError:
# Let DataErrorHandler return a 422 (not the opaque 500 below).
raise
- except SQLAlchemyError as e:
- log.error("Error updating Task Instance state", error=str(e))
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred"
- )
+ except SQLAlchemyError:
+ # Defer to app-level SQLAlchemyError handler (returns HTTP 500).
+ raise
if updated_state == TaskInstanceState.SUCCESS:
if conf.getboolean("state_store", "clear_on_success"):
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_exceptions.py
b/airflow-core/tests/unit/api_fastapi/common/test_exceptions.py
index 7f470e2f019..b9b18c893d0 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_exceptions.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_exceptions.py
@@ -497,6 +497,46 @@ class TestDataErrorHandler:
assert detail["statement"] == "hidden"
assert detail["orig_error"] == "hidden"
+ @conf_vars({("api", "expose_stacktrace"): "False"})
+ @patch("airflow.api_fastapi.common.exceptions.get_random_string",
return_value=MOCKED_ID)
+ def test_sqlalchemy_error_dispatched_through_fastapi_app(self,
mock_get_random_string) -> None:
+ """End-to-end: a route raising SQLAlchemyError returns 500 via the
registered handler."""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ app = FastAPI()
+ for h in ERROR_HANDLERS:
+ app.add_exception_handler(h.exception_cls, h.exception_handler)
+
+ @app.post("/test")
+ def trigger_error():
+ raise SQLAlchemyError("boom")
+
+ response = TestClient(app, raise_server_exceptions=False).post("/test")
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ detail = response.json()["detail"]
+ assert detail["reason"] == "Database error"
+ assert detail["message"] == MESSAGE
+
+ @conf_vars({("api", "expose_stacktrace"): "True"})
+ @patch("airflow.api_fastapi.common.exceptions.get_random_string",
return_value=MOCKED_ID)
+ def test_sqlalchemy_error_includes_traceback_with_stacktrace(self,
mock_get_random_string) -> None:
+ """End-to-end: SQLAlchemyError exposes traceback details when
stacktrace logging is enabled."""
+ from sqlalchemy.exc import SQLAlchemyError
+
+ app = FastAPI()
+ for h in ERROR_HANDLERS:
+ app.add_exception_handler(h.exception_cls, h.exception_handler)
+
+ @app.post("/test")
+ def trigger_error():
+ raise SQLAlchemyError("boom")
+
+ response = TestClient(app, raise_server_exceptions=False).post("/test")
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ detail = response.json()["detail"]
+ assert detail["reason"] == "Database error"
+ assert "trigger_error" in detail["message"]
+
class TestDagErrorHandler:
@pytest.mark.parametrize(
diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
index d4b9ce5ac88..bb2d2d557dc 100644
--- a/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_app.py
@@ -24,11 +24,12 @@ from uuid import UUID
import httpx
import pytest
-from fastapi import Request
+from fastapi import Request, status
from fastapi.params import Security as SecurityParam
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from opentelemetry import context as otel_context, propagate as otel_propagate
+from sqlalchemy.exc import SQLAlchemyError
from airflow.api_fastapi.execution_api.app import (
InProcessExecutionAPI,
@@ -64,6 +65,21 @@ def test_access_api_contract(client):
assert response.headers["airflow-api-version"] == bundle.versions[0].value
+@conf_vars({("api", "expose_stacktrace"): "False"})
[email protected]("airflow.api_fastapi.common.exceptions.get_random_string",
return_value="test-error-id")
+def
test_direct_execution_api_app_handles_sqlalchemy_errors(mock_get_random_string):
+ app = create_task_execution_api_app()
+
+ @app.get("/test-sqlalchemy-error")
+ def trigger_error():
+ raise SQLAlchemyError("boom")
+
+ response = TestClient(app,
raise_server_exceptions=False).get("/test-sqlalchemy-error")
+
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ assert response.json()["detail"]["reason"] == "Database error"
+
+
def test_ti_self_routes_have_task_instance_id_param(client):
"""Every route with ti:self scope must have a {task_instance_id} path
parameter."""
app = client.app
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 542ce7eaaf1..8a152bebe0d 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -18,6 +18,7 @@
from __future__ import annotations
from datetime import datetime
+from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest import mock
from uuid import UUID, uuid4
@@ -1497,7 +1498,64 @@ class TestTIUpdateState:
mock_register_asset_changes_in_db.return_value = None
response =
client.patch(f"/execution/task-instances/{ti.id}/state", json=payload)
assert response.status_code == 500
- assert response.json()["detail"] == "Database error occurred"
+ detail = response.json()["detail"]
+ assert isinstance(detail, dict)
+ assert detail.get("reason") == "Database error"
+
+ def test_ti_run_database_error(self, client, session,
create_task_instance):
+ """
+ Test that a database error is handled correctly when starting the Task
Instance.
+ """
+ ti = create_task_instance(
+ task_id="test_ti_run_database_error",
+ state=State.QUEUED,
+ dagrun_state=DagRunState.RUNNING,
+ session=session,
+ dag_id=str(uuid4()),
+ )
+ session.commit()
+
+ payload = {
+ "state": "running",
+ "hostname": "hostname",
+ "unixname": "unixname",
+ "pid": 123,
+ "start_date": "2024-10-31T12:00:00Z",
+ }
+
+ with mock.patch(
+ "airflow.api_fastapi.common.db.common.Session.execute",
+ side_effect=[
+ mock.Mock(
+ one=mock.Mock(
+ return_value=SimpleNamespace(
+ state="queued",
+ dag_id="dag",
+ run_id="run",
+ task_id="task",
+ map_index=-1,
+ try_number=1,
+ max_tries=0,
+ start_date=None,
+ next_method=None,
+ hostname=None,
+ unixname=None,
+ pid=None,
+ next_kwargs=None,
+ logical_date=timezone.utcnow(),
+ owners="test_owner",
+ )
+ )
+ ),
+ SQLAlchemyError("Database error"),
+ ],
+ ):
+ response = client.patch(f"/execution/task-instances/{ti.id}/run",
json=payload)
+
+ assert response.status_code == 500
+ detail = response.json()["detail"]
+ assert isinstance(detail, dict)
+ assert detail.get("reason") == "Database error"
@pytest.mark.parametrize("queues_enabled", [False, True])
def test_ti_update_state_to_deferred(