This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 89ab81f6f41 Report airflowctl response validation errors instead of a 
traceback (#76120)
89ab81f6f41 is described below

commit 89ab81f6f413f97596bbd944e14af635b54ef1ab
Author: Shahar Epstein <[email protected]>
AuthorDate: Sat Oct 31 10:13:16 2026 +0200

    Report airflowctl response validation errors instead of a traceback (#76120)
    
    A pydantic ValidationError escaping a command now prints a one-line error 
and exits 1:
    - non-JSON body (for example an HTML 200 page): points at the API URL
    - request-body model (names ending in Body): reports invalid input
    - other response models: reports the mismatch and hints at a newer server
    List commands re-raise the original ValidationError when the body is not 
JSON instead of a JSONDecodeError.
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       | 21 ++++++++
 .../tests/airflow_ctl/api/test_operations.py       |  8 ++++
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 56 ++++++++++++++++++++++
 3 files changed, 85 insertions(+)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 2150872ab7a..69fda36c76e 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -38,6 +38,7 @@ from typing import Any, NamedTuple
 import httpx
 import rich
 from pydantic import BaseModel, ValidationError
+from rich.markup import escape
 
 import airflowctl.api.datamodels.generated as generated_datamodels
 from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, 
_request_method, provide_api_client
@@ -139,6 +140,26 @@ def safe_call_command(function: Callable, args: 
Iterable[Arg]) -> None:
             )
         _warn_if_write_may_have_applied(e)
         sys.exit(1)
+    except ValidationError as e:
+        errors = e.errors()
+        first: dict[str, Any] = dict(errors[0]) if errors else {}
+        title = escape(e.title)
+        location = escape(".".join(str(part) for part in first.get("loc", ())) 
or "<root>")
+        detail = f"{title} (invalid '{location}': {escape(first.get('msg', 
''))})"
+        if first.get("type") == "json_invalid":
+            message = (
+                f"Server returned a non-JSON response for {title}; "
+                "check that the API URL points at the Airflow API server."
+            )
+        elif e.title.endswith("Body"):
+            message = f"Input did not match the airflowctl model {detail}."
+        else:
+            message = (
+                f"Response did not match the airflowctl model {detail}. "
+                "If the server is newer than this client, try upgrading 
airflowctl."
+            )
+        rich.print(f"[red]{message}[/red]")
+        sys.exit(1)
 
 
 class DefaultHelpParser(argparse.ArgumentParser):
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 692403dfc8c..91ebe43b17b 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -170,6 +170,14 @@ class TestBaseOperations:
         ):
             client.connections.get("1")
 
+    def test_execute_list_raises_validation_error_for_non_json_response(self):
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, headers={"content-type": "text/html"}, 
content=b"<html>login</html>")
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        with pytest.raises(ValidationError):
+            client.pools.list()
+
     @pytest.mark.parametrize(
         ("total_entries", "limit", "expected_response"),
         [
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py 
b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
index 1078540f3f1..ed1f87484d6 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -35,6 +35,9 @@ from airflowctl.api.datamodels.generated import (
     ClearTaskInstancesBody,
     ConnectionBody,
     ConnectionTestResponse,
+    PoolBody,
+    PoolCollectionResponse,
+    PoolResponse,
 )
 from airflowctl.api.operations import ConnectionsOperations, DagRunOperations, 
ServerResponseError
 from airflowctl.ctl import cli_parser
@@ -794,6 +797,59 @@ class TestCliConfigMethods:
         assert "Server response error:" in out
         assert ("does not follow" in out) is hint_expected
 
+    @pytest.mark.parametrize(
+        ("model", "payload", "expected", "unexpected"),
+        [
+            pytest.param(
+                PoolResponse,
+                {},
+                ["Response did not match the airflowctl model PoolResponse", 
"upgrading airflowctl"],
+                [],
+                id="response",
+            ),
+            pytest.param(
+                PoolCollectionResponse,
+                {"pools": [{}], "total_entries": 1},
+                ["PoolCollectionResponse", "pools.0."],
+                [],
+                id="nested-location",
+            ),
+            pytest.param(
+                PoolBody,
+                {"slots": "abc", "name": "p"},
+                ["Input did not match the airflowctl model PoolBody"],
+                ["upgrading airflowctl"],
+                id="request-body",
+            ),
+        ],
+    )
+    def test_safe_call_command_exits_non_zero_for_validation_error(
+        self, model, payload, expected, unexpected, capsys
+    ):
+        def raise_validation_error(_args):
+            model.model_validate(payload)
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_validation_error, 
args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+        out = " ".join(capsys.readouterr().out.split())
+        for text in expected:
+            assert text in out
+        for text in unexpected:
+            assert text not in out
+
+    def test_safe_call_command_reports_non_json_response(self, capsys):
+        def raise_validation_error(_args):
+            PoolResponse.model_validate_json(b"<html>login</html>")
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_validation_error, 
args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+        out = " ".join(capsys.readouterr().out.split())
+        assert "Server returned a non-JSON response for PoolResponse" in out
+
     def test_add_to_parser_drops_type_for_boolean_optional_action(self):
         """Test add_to_parser removes type for BooleanOptionalAction."""
         parser = argparse.ArgumentParser()

Reply via email to