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

Reply via email to