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

bugraoz 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 9f8678a3efc Fix CLI error handling and exit codes for failed commands 
(#65052)
9f8678a3efc is described below

commit 9f8678a3efc197a9a4794e9c2a7252676b262618
Author: rjgoyln <[email protected]>
AuthorDate: Sun Apr 12 19:29:52 2026 +0800

    Fix CLI error handling and exit codes for failed commands (#65052)
---
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  6 +-
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 66 ++++++++++++++++++++++
 2 files changed, 70 insertions(+), 2 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 5f17c603357..6b045e5c562 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -25,6 +25,7 @@ import ast
 import datetime
 import inspect
 import os
+import sys
 from argparse import Namespace
 from collections.abc import Callable, Iterable
 from enum import Enum
@@ -64,8 +65,6 @@ def lazy_load_command(import_path: str) -> Callable:
 
 
 def safe_call_command(function: Callable, args: Iterable[Arg]) -> None:
-    import sys
-
     if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
         rich.print(
             "[yellow]Debug mode is enabled. Please be aware that your 
credentials are not secure.\n"
@@ -90,10 +89,12 @@ def safe_call_command(function: Callable, args: 
Iterable[Arg]) -> None:
                 f"[red]Server response error: {e}. "
                 "Please check if the server is running and the API URL is 
correct.[/red]"
             )
+        sys.exit(1)
     except httpx.ReadTimeout as e:
         rich.print(f"[red]Read timeout error: {e}[/red]")
         if "timed out" in str(e):
             rich.print("[red]Please check if the server is running and the API 
ready to accept calls.[/red]")
+        sys.exit(1)
     except ServerResponseError as e:
         rich.print(f"Server response error: {e}")
         if "Client error message:" in str(e):
@@ -102,6 +103,7 @@ def safe_call_command(function: Callable, args: 
Iterable[Arg]) -> None:
                 "Please check the command and its parameters. "
                 "If you need help, run the command with --help."
             )
+        sys.exit(1)
 
 
 class DefaultHelpParser(argparse.ArgumentParser):
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 117f874c346..410a455d417 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -21,8 +21,10 @@ import argparse
 from argparse import BooleanOptionalAction
 from textwrap import dedent
 
+import httpx
 import pytest
 
+from airflowctl.api.operations import ServerResponseError
 from airflowctl.ctl.cli_config import (
     ARG_AUTH_TOKEN,
     ActionCommand,
@@ -31,6 +33,13 @@ from airflowctl.ctl.cli_config import (
     GroupCommand,
     add_auth_token_to_all_commands,
     merge_commands,
+    safe_call_command,
+)
+from airflowctl.exceptions import (
+    AirflowCtlConnectionException,
+    AirflowCtlCredentialNotFoundException,
+    AirflowCtlKeyringException,
+    AirflowCtlNotFoundException,
 )
 
 
@@ -289,6 +298,63 @@ class TestCommandFactory:
 
 
 class TestCliConfigMethods:
+    @pytest.mark.parametrize(
+        "raised_exception",
+        [
+            AirflowCtlCredentialNotFoundException("missing credentials"),
+            AirflowCtlConnectionException("connection failed"),
+            AirflowCtlKeyringException("keyring failure"),
+            AirflowCtlNotFoundException("resource not found"),
+        ],
+        ids=["credential-not-found", "connection-error", "keyring-error", 
"not-found"],
+    )
+    def test_safe_call_command_exits_non_zero_for_airflowctl_exceptions(self, 
raised_exception):
+        def raise_error(_args):
+            raise raised_exception
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_error, args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+
+    @pytest.mark.parametrize(
+        "raised_exception",
+        [
+            httpx.RemoteProtocolError("remote protocol error"),
+            httpx.ReadError("read error"),
+        ],
+        ids=["remote-protocol-error", "read-error"],
+    )
+    def test_safe_call_command_exits_non_zero_for_httpx_protocol_errors(self, 
raised_exception):
+        def raise_error(_args):
+            raise raised_exception
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_error, args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+
+    def test_safe_call_command_exits_non_zero_for_httpx_read_timeout(self):
+        def raise_error(_args):
+            raise httpx.ReadTimeout("timed out")
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_error, args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+
+    def test_safe_call_command_exits_non_zero_for_server_response_error(self):
+        request = httpx.Request("GET", "http://localhost:8080/api/v2/dags";)
+        response = httpx.Response(500, request=request, json={"detail": 
"boom"})
+
+        def raise_error(_args):
+            raise ServerResponseError("server error", request=request, 
response=response)
+
+        with pytest.raises(SystemExit) as ctx:
+            safe_call_command(raise_error, args=argparse.Namespace())
+
+        assert ctx.value.code == 1
+
     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