This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this
push:
new fbe41ea5ec4 Report airflowctl response validation errors instead of a
traceback (#76121)
fbe41ea5ec4 is described below
commit fbe41ea5ec40b9791ba49221d41c78bf2814f53a
Author: Shahar Epstein <[email protected]>
AuthorDate: Sat Oct 31 10:15:20 2026 +0200
Report airflowctl response validation errors instead of a traceback (#76121)
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/api/operations.py | 8 +++-
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 ++++++++++++++++++++++
4 files changed, 91 insertions(+), 2 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/api/operations.py
b/airflow-ctl/src/airflowctl/api/operations.py
index b7149d4b91f..a0b60cce034 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -232,8 +232,12 @@ class BaseOperations:
def safe_validate(content: bytes) -> BaseModel:
try:
return data_model.model_validate_json(content)
- except ValidationError:
- raw = fill_missing_fields(json.loads(content), data_model)
+ except ValidationError as e:
+ try:
+ parsed = json.loads(content)
+ except json.JSONDecodeError:
+ raise e from None
+ raw = fill_missing_fields(parsed, data_model)
return data_model.model_validate(raw)
self.response = self.client.get(path, params={**shared_params,
"offset": offset})
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index c967572002f..e9aecf9ef0e 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 31dc3219ff9..88151ada8ba 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -165,6 +165,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 262cc9c7d32..c46718940c9 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -34,6 +34,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
@@ -741,6 +744,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()