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