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

henry3260 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 61d3710166f [airflow-ctl/v0-1-test] Fix airflowctl showing a traceback 
instead of the server error on 5xx (#70841) (#70910)
61d3710166f is described below

commit 61d3710166f102c5f17962e78855505fcee6b00c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Aug 2 09:06:16 2026 +0800

    [airflow-ctl/v0-1-test] Fix airflowctl showing a traceback instead of the 
server error on 5xx (#70841) (#70910)
    
    (cherry picked from commit eef1b970acd833e2841a87cbea61bab555f8fbf7)
    
    Co-authored-by: rjgoyln <[email protected]>
---
 airflow-ctl/src/airflowctl/api/operations.py     | 22 ++++++------
 airflow-ctl/tests/airflow_ctl/api/test_client.py | 46 ++++++++++++++++++++++++
 2 files changed, 56 insertions(+), 12 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/api/operations.py 
b/airflow-ctl/src/airflowctl/api/operations.py
index 0a08a8fa2c3..bbfc18fce2c 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -110,18 +110,16 @@ class ServerResponseError(httpx.HTTPStatusError):
         if response.headers.get("content-type") != "application/json":
             return None
 
-        if 400 <= response.status_code < 500:
-            response.read()
-            return cls(
-                message=f"Client error message: {response.json()}",
-                request=response.request,
-                response=response,
-            )
-
-        msg = response.json()
-
-        self = cls(message=msg, request=response.request, response=response)
-        return self
+        # httpx runs response event hooks before it reads the body, so the 
body has to be
+        # pulled in explicitly here or ``.json()`` raises 
``httpx.ResponseNotRead``.
+        response.read()
+
+        error_kind = "Client" if response.status_code < 500 else "Server"
+        return cls(
+            message=f"{error_kind} error message: {response.json()}",
+            request=response.request,
+            response=response,
+        )
 
 
 def _check_flag_and_exit_if_server_response_error(func):
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py 
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 3ce4466efef..dc33092e780 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -53,6 +53,22 @@ def make_client_w_responses(responses: list[httpx.Response]) 
-> Client:
     return Client(base_url="", token="", mounts={"'http://": 
httpx.MockTransport(handle_request)})
 
 
+def make_unread_json_response(status_code: int, payload: dict, **kwargs) -> 
httpx.Response:
+    """
+    Build a JSON response whose body has not been read yet.
+
+    ``httpx.Response(json=...)`` eagerly loads the body, which hides the fact 
that response
+    event hooks run before httpx reads a real server's body. Passing an 
iterator keeps the
+    body streaming, matching what the hooks see against a live API server.
+    """
+    return httpx.Response(
+        status_code,
+        headers={"content-type": "application/json"},
+        content=iter([json.dumps(payload).encode()]),
+        **kwargs,
+    )
+
+
 @pytest.fixture(autouse=True)
 def unique_config_dir():
     temp_dir = tempfile.mkdtemp()
@@ -107,6 +123,23 @@ class TestClient:
             client.get("http://error";)
         assert err.value.args == ("Client error message: {'detail': 'Not 
found'}",)
 
+    @pytest.mark.parametrize(
+        ("status_code", "expected_message"),
+        [
+            pytest.param(404, "Client error message: {'detail': 'boom'}", 
id="client-error"),
+            pytest.param(500, "Server error message: {'detail': 'boom'}", 
id="server-error"),
+        ],
+    )
+    def test_error_parsing_with_unread_body(self, status_code, 
expected_message):
+        response = make_unread_json_response(
+            status_code, {"detail": "boom"}, request=httpx.Request("GET", 
"http://error";)
+        )
+
+        with pytest.raises(ServerResponseError) as err:
+            get_json_error(response)
+
+        assert err.value.args == (expected_message,)
+
     @pytest.mark.parametrize(
         ("suppress_error_log", "expected_warning_count"),
         [
@@ -407,6 +440,19 @@ class TestSaveKeyringPatching:
             assert response.status_code == 200
             assert len(responses) == 1
 
+    def test_retry_handling_server_error_with_unread_body(self):
+        with time_machine.travel("2023-01-01T00:00:00Z", tick=False):
+            responses: list[httpx.Response] = [
+                make_unread_json_response(500, {"detail": "boom"}),
+                httpx.Response(200, json={"detail": "Recovered from error"}),
+                httpx.Response(400, json={"detail": "Should not get here"}),
+            ]
+            client = make_client_w_responses(responses)
+
+            response = client.get("http://error";)
+            assert response.status_code == 200
+            assert len(responses) == 1
+
     def test_retry_handling_non_retry_error(self):
         with time_machine.travel("2023-01-01T00:00:00Z", tick=False):
             responses: list[httpx.Response] = [

Reply via email to