diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py index 5f17c60335734..6b045e5c5629c 100755 --- a/airflow-ctl/src/airflowctl/ctl/cli_config.py +++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py @@ -25,6 +25,7 @@ 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 command(*args, **kwargs): 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 117f874c34651..410a455d41735 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 @@ 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 @@ GroupCommand, add_auth_token_to_all_commands, merge_commands, + safe_call_command, +) +from airflowctl.exceptions import ( + AirflowCtlConnectionException, + AirflowCtlCredentialNotFoundException, + AirflowCtlKeyringException, + AirflowCtlNotFoundException, ) @@ -289,6 +298,63 @@ def delete(self, backfill_id: str) -> ServerResponseError | None: 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()