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 8517ae36b73 [airflow-ctl/v0-1-test] Handle connection and request
timeouts in airflowctl (#76132) (#76193)
8517ae36b73 is described below
commit 8517ae36b73d99e8f8cd2de0634a2aace42225f9
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Nov 1 08:41:47 2026 +0200
[airflow-ctl/v0-1-test] Handle connection and request timeouts in
airflowctl (#76132) (#76193)
When airflowctl attempts to connect to an unreachable or firewalled API
server, httpx raises ConnectTimeout rather than ConnectError. Because
operations only handled ConnectError and safe_call_command only caught
ReadTimeout, connection timeouts, pool timeouts, and write timeouts escaped
with raw Python tracebacks.
Catching ConnectTimeout in operation calls translates it into a standard
AirflowCtlConnectionException, matching the behavior of connection refused
errors. Catching TimeoutException and WriteError in safe_call_command ensures
all network timeout variants exit cleanly with user-friendly error messages
instead of uncaught exception traces. In addition, ambiguous-write warnings are
suppressed when requests fail before a connection is established.
(cherry picked from commit 152e5344ef111d43e825111314295d990dc0a681)
Co-authored-by: Austin Aviv <[email protected]>
---
airflow-ctl/src/airflowctl/api/operations.py | 2 ++
airflow-ctl/src/airflowctl/ctl/cli_config.py | 8 +++++---
.../tests/airflow_ctl/api/test_operations.py | 10 ++++++++++
.../tests/airflow_ctl/ctl/test_cli_config.py | 21 ++++++++++++++++++---
4 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/api/operations.py
b/airflow-ctl/src/airflowctl/api/operations.py
index bbca789c613..ed6b8affd04 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -171,6 +171,8 @@ def _check_flag_and_exit_if_server_response_error(func):
if "Connection refused" in str(e):
raise AirflowCtlConnectionException("Connection refused. Is
the API server running?")
raise AirflowCtlConnectionException(f"Connection error: {e}")
+ except httpx.ConnectTimeout:
+ raise AirflowCtlConnectionException("Connection timed out. Is the
API server running?")
return wrapped
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index ca9dd42d12a..d25ec56a942 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -77,6 +77,8 @@ def _warn_if_write_may_have_applied(error: httpx.HTTPError)
-> None:
"""Warn that a failed write is not retried because the server may already
have applied it."""
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code
< 500:
return
+ if isinstance(error, (httpx.ConnectTimeout, httpx.PoolTimeout)):
+ return
method = _request_method(error)
if method in _WRITE_METHODS:
rich.print(
@@ -104,7 +106,7 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
) as e:
rich.print(f"command failed due to {e}")
sys.exit(1)
- except (httpx.RemoteProtocolError, httpx.ReadError) as e:
+ except (httpx.RemoteProtocolError, httpx.ReadError, httpx.WriteError) as e:
rich.print(f"[red]Remote protocol error: {e}[/red]")
if "Server disconnected without sending a response." in str(e):
rich.print(
@@ -113,8 +115,8 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
)
_warn_if_write_may_have_applied(e)
sys.exit(1)
- except httpx.ReadTimeout as e:
- rich.print(f"[red]Read timeout error: {e}[/red]")
+ except httpx.TimeoutException as e:
+ rich.print(f"[red]Request 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]")
_warn_if_write_may_have_applied(e)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index ff897cad195..027429e531d 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -183,6 +183,16 @@ class TestBaseOperations:
):
client.connections.get("1")
+ def test_server_connection_timed_out(self):
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectTimeout("Connection timed out", request=request)
+
+ client = make_api_client(transport=httpx.MockTransport(handle_request))
+ with pytest.raises(
+ AirflowCtlConnectionException, match="Connection timed out. Is the
API server running?"
+ ):
+ 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>")
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 c46718940c9..10c7d391ccb 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -611,6 +611,10 @@ class TestCliConfigMethods:
(httpx.ReadTimeout, True),
(httpx.ReadError, True),
(httpx.RemoteProtocolError, True),
+ (httpx.ConnectTimeout, False),
+ (httpx.PoolTimeout, False),
+ (httpx.WriteTimeout, True),
+ (httpx.WriteError, True),
("json-500", True),
("plain-503", True),
("json-400", False),
@@ -674,8 +678,9 @@ class TestCliConfigMethods:
[
httpx.RemoteProtocolError("remote protocol error"),
httpx.ReadError("read error"),
+ httpx.WriteError("write error"),
],
- ids=["remote-protocol-error", "read-error"],
+ ids=["remote-protocol-error", "read-error", "write-error"],
)
def test_safe_call_command_exits_non_zero_for_httpx_protocol_errors(self,
raised_exception):
def raise_error(_args):
@@ -686,9 +691,19 @@ class TestCliConfigMethods:
assert ctx.value.code == 1
- def test_safe_call_command_exits_non_zero_for_httpx_read_timeout(self):
+ @pytest.mark.parametrize(
+ "raised_exception",
+ [
+ httpx.ReadTimeout("timed out"),
+ httpx.ConnectTimeout("timed out"),
+ httpx.WriteTimeout("timed out"),
+ httpx.PoolTimeout("timed out"),
+ ],
+ ids=["read-timeout", "connect-timeout", "write-timeout",
"pool-timeout"],
+ )
+ def test_safe_call_command_exits_non_zero_for_httpx_timeouts(self,
raised_exception):
def raise_error(_args):
- raise httpx.ReadTimeout("timed out")
+ raise raised_exception
with pytest.raises(SystemExit) as ctx:
safe_call_command(raise_error, args=argparse.Namespace())