diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml index e6fb850247d91..52a09dcbb7b8c 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml @@ -991,6 +991,12 @@ paths: application/json: schema: $ref: '#/components/schemas/StructureDataResponse' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Bad Request '404': content: application/json: diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py index 87184788413f3..597f44db424bb 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py @@ -42,7 +42,12 @@ @structure_router.get( "/structure_data", - responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]), + responses=create_openapi_http_exception_doc( + [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_404_NOT_FOUND, + ] + ), dependencies=[ Depends(requires_access_dag("GET")), Depends(requires_access_dag("GET", DagAccessEntity.DEPENDENCIES)), @@ -161,9 +166,15 @@ def structure_data( ) if (asset_expression := serialized_dag.dag_model.asset_expression) and entry_node_ref: - upstream_asset_nodes, upstream_asset_edges = get_upstream_assets( - asset_expression, entry_node_ref["id"] - ) + try: + upstream_asset_nodes, upstream_asset_edges = get_upstream_assets( + asset_expression, entry_node_ref["id"] + ) + except TypeError as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Malformed asset_expression in Dag {dag_id!r} version {version_number}: {e}", + ) from e data["nodes"] += upstream_asset_nodes data["edges"] += upstream_asset_edges diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts index e65f313e1d93f..abf15b48d4849 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts @@ -4746,6 +4746,7 @@ export class StructureService { version_number: data.versionNumber }, errors: { + 400: 'Bad Request', 404: 'Not Found', 422: 'Validation Error' } diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 85a37588df6fd..543b03fbe6c15 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -7949,6 +7949,10 @@ export type $OpenApiTs = { * Successful Response */ 200: StructureDataResponse; + /** + * Bad Request + */ + 400: HTTPExceptionResponse; /** * Not Found */ diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py index db436ca3cf93a..8e504c132c4eb 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py @@ -709,6 +709,31 @@ def test_should_return_404(self, test_client): assert response.status_code == 404 assert response.json()["detail"] == "Dag with id not_existing was not found" + @pytest.mark.usefixtures("make_dags") + def test_should_return_400_on_malformed_asset_expression(self, test_client): + """A TypeError from get_upstream_assets surfaces as a 400 with a clear message. + + The asset_expression ultimately comes from user-authored Dag code (via the Task SDK), + so a malformed expression is bad input that ended up persisted -- not a server fault. + Without the try/except wrap, the TypeError propagates uncaught and FastAPI returns a + generic ``{"detail": "Internal Server Error"}`` 500 body with no context about which + Dag triggered it. With the wrap, the response identifies the Dag and version, which + is what a caller needs to fix the upstream Dag definition. + """ + with mock.patch( + "airflow.api_fastapi.core_api.routes.ui.structure.get_upstream_assets", + side_effect=TypeError("Unsupported type: dict_keys(['weird-op'])"), + ): + response = test_client.get( + "/structure/structure_data", + params={"dag_id": DAG_ID, "external_dependencies": True}, + ) + assert response.status_code == 400 + detail = response.json()["detail"] + assert "Malformed asset_expression" in detail + assert DAG_ID in detail + assert "Unsupported type" in detail + def test_should_return_404_when_dag_version_not_found(self, test_client): response = test_client.get( "/structure/structure_data", params={"dag_id": DAG_ID, "version_number": 999}