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()