This is an automated email from the ASF dual-hosted git repository.
henry3260 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 352ca600361 Fix API client errors losing detail behind a reverse proxy
(#75493)
352ca600361 is described below
commit 352ca600361fea10561f225d063b23355421a36b
Author: rjgoyln <[email protected]>
AuthorDate: Mon Oct 26 03:32:45 2026 +0800
Fix API client errors losing detail behind a reverse proxy (#75493)
* Fix airflowctl tracebacks on JSON errors behind a reverse proxy
airflowctl compared the error response content-type against the exact
string "application/json". Any reverse proxy, ingress, or API gateway
that returns "application/json; charset=utf-8" — or emits its own
"application/problem+json" error — fell outside that comparison, so
ServerResponseError was never constructed and the friendly "not found"
messages in the dags and tasks commands were skipped. The bare
httpx.HTTPStatusError raised instead is not handled by
safe_call_command, so users saw a raw traceback. Airflow's own API
server does not send a charset, which is why this only surfaces once
something sits in front of it.
Media types are case-insensitive and may carry parameters (RFC 9110),
so the header has to be parsed rather than compared whole.
Matching more responses also routes bodies into the error path that are
valid JSON but not objects, which dict() rejected with a ValueError from
that same unprotected path, and bodies that do not decode at all.
* Move media type rationale from docstring into an inline comment
* Apply the same JSON media type handling to the Task SDK client
The Task SDK client carries the identical exact-string content-type
comparison, so a task talking to the API server through a proxy that
declares a charset loses the server's error detail and, because the
raised exception is then a plain httpx.HTTPStatusError, no longer hits
the status-specific handling in the supervisor and the task runner.
* Cross-reference the twin JSON media type checks
The airflowctl and Task SDK copies are the same three lines with
different comments, and they already drifted in the commits that
created them. A pointer in each makes the next person notice there
are two.
* Add newsfragment for the API client error handling fix
Requested during review: the Task SDK half of the change ships in
airflow-core, and both effects are visible to users running behind a
proxy.
* Use httpx2 in the Task SDK JSON media type check
The Task SDK moved from httpx to httpx2 (#73116) after this branch was
written, so the rebased helper and its tests referred to a module that
is no longer imported there.
* Describe the airflowctl symptom as it is on main now
Since #73307 airflowctl catches the bare HTTPStatusError and prints one
line instead of a traceback, so the newsfragment overstated it; the
server's own message is still what gets lost.
* Keep only the airflowctl test cases the fix changes
The bare media type and the 404/500 axis repeat what
test_error_parsing_with_unread_body already covers, and dict() of an
object body worked before; none of them fail without the fix.
* Explain the non-object log guard by what it protects
The previous comment described the dict() call the guard replaced,
which is no longer in the code.
* Limit the newsfragment to the Task SDK fix
airflow-core's release notes ship only the Task SDK side; airflowctl's
changelog is built from git log. The task runner never handled
ServerResponseError, so it is no longer named.
* Rename the newsfragment to this PR's number
* Shorten the media type comments
The longer versions restated what the diff and the PR already explain,
and the two _has_json_body copies now carry the same note.
---------
Co-authored-by: Henry Chen <[email protected]>
---
airflow-core/newsfragments/75493.bugfix.rst | 1 +
airflow-ctl/src/airflowctl/api/client.py | 4 +-
airflow-ctl/src/airflowctl/api/operations.py | 18 ++++++-
airflow-ctl/tests/airflow_ctl/api/test_client.py | 63 +++++++++++++++++++++++-
task-sdk/src/airflow/sdk/api/client.py | 10 +++-
task-sdk/tests/task_sdk/api/test_client.py | 29 +++++++++++
6 files changed, 119 insertions(+), 6 deletions(-)
diff --git a/airflow-core/newsfragments/75493.bugfix.rst
b/airflow-core/newsfragments/75493.bugfix.rst
new file mode 100644
index 00000000000..ec1de259838
--- /dev/null
+++ b/airflow-core/newsfragments/75493.bugfix.rst
@@ -0,0 +1 @@
+Fix the Task SDK client raising a bare ``HTTPStatusError`` instead of
``ServerResponseError`` when a reverse proxy rewrites the ``content-type`` of a
JSON error response, for example to ``application/json; charset=utf-8``. The
supervisor's and the client's status-specific handling, such as treating a 404
as a missing connection or variable, now applies to those responses.
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index 1236ccec6b7..da8e364a786 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -113,7 +113,9 @@ def get_json_error(response: httpx.Response):
# This part is used in integration tests to verify the error message
# If you are updating here don't forget to update the airflow-ctl-tests
if not
response.request.extensions.get("airflowctl_suppress_error_log"):
- log.warning("Server error ", extra=dict(err.response.json()))
+ detail = err.response.json()
+ # ``extra`` needs a mapping, but a JSON error body can be any JSON
value.
+ log.warning("Server error ", extra=detail if isinstance(detail,
dict) else {"detail": detail})
raise err
diff --git a/airflow-ctl/src/airflowctl/api/operations.py
b/airflow-ctl/src/airflowctl/api/operations.py
index 5cc6e1f4eae..a28048d5a05 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -106,6 +106,14 @@ def _build_query_params(**values: Any) -> dict[str, Any]:
return {name: _serialize_query_param(value) for name, value in
values.items() if value is not None}
+def _has_json_body(response: httpx.Response) -> bool:
+ """Check whether a response declares a JSON body."""
+ # Kept in sync with airflow.sdk.api.client._has_json_body.
+ # Media types are case-insensitive and may carry parameters (RFC 9110).
+ media_type = response.headers.get("content-type",
"").partition(";")[0].strip().lower()
+ return media_type == "application/json" or media_type.endswith("+json")
+
+
# Generic Server Response Error
class ServerResponseError(httpx.HTTPStatusError):
"""Server response error (Generic)."""
@@ -115,16 +123,22 @@ class ServerResponseError(httpx.HTTPStatusError):
if response.status_code < 400:
return None
- if response.headers.get("content-type") != "application/json":
+ if not _has_json_body(response):
return None
# 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()
+ try:
+ detail = response.json()
+ except ValueError:
+ # A JSON content-type does not guarantee a JSON body; leave it to
raise_for_status().
+ return None
+
error_kind = "Client" if response.status_code < 500 else "Server"
return cls(
- message=f"{error_kind} error message: {response.json()}",
+ message=f"{error_kind} error message: {detail}",
request=response.request,
response=response,
)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 4edd4958833..dadf5d80367 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -23,6 +23,7 @@ import shutil
import subprocess
import sys
import tempfile
+from typing import Any
from unittest.mock import MagicMock, patch
import httpx
@@ -57,7 +58,9 @@ 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:
+def make_unread_json_response(
+ status_code: int, payload: Any, content_type: str = "application/json",
**kwargs
+) -> httpx.Response:
"""
Build a JSON response whose body has not been read yet.
@@ -67,7 +70,7 @@ def make_unread_json_response(status_code: int, payload:
dict, **kwargs) -> http
"""
return httpx.Response(
status_code,
- headers={"content-type": "application/json"},
+ headers={"content-type": content_type},
content=iter([json.dumps(payload).encode()]),
**kwargs,
)
@@ -144,6 +147,62 @@ class TestClient:
assert err.value.args == (expected_message,)
+ @pytest.mark.parametrize(
+ "content_type",
+ [
+ pytest.param("application/json; charset=utf-8", id="charset"),
+ pytest.param("application/json;charset=UTF-8",
id="charset-no-space"),
+ pytest.param("Application/JSON", id="mixed-case"),
+ pytest.param("application/problem+json", id="problem-json"),
+ ],
+ )
+ def test_error_parsing_json_media_type_variants(self, content_type):
+ response = make_unread_json_response(
+ 404,
+ {"detail": "boom"},
+ content_type=content_type,
+ request=httpx.Request("GET", "http://error"),
+ )
+
+ with pytest.raises(ServerResponseError):
+ get_json_error(response)
+
+ def test_error_parsing_media_type_merely_prefixed_with_json(self):
+ response = make_unread_json_response(
+ 404,
+ {"detail": "boom"},
+ content_type="application/jsonp",
+ request=httpx.Request("GET", "http://error"),
+ )
+
+ assert get_json_error(response) is None
+
+ def test_error_parsing_undecodable_json_body(self):
+ response = httpx.Response(
+ 404,
+ headers={"content-type": "application/json"},
+ content=iter([b"{truncated"]),
+ request=httpx.Request("GET", "http://error"),
+ )
+
+ assert get_json_error(response) is None
+
+ @pytest.mark.parametrize(
+ ("payload", "expected_extra"),
+ [
+ pytest.param(["a", "b"], {"detail": ["a", "b"]}, id="array"),
+ pytest.param("plain message", {"detail": "plain message"},
id="string"),
+ ],
+ )
+ @patch("airflowctl.api.client.log.warning", autospec=True)
+ def test_error_log_accepts_non_object_json_body(self, mock_warning,
payload, expected_extra):
+ response = make_unread_json_response(404, payload,
request=httpx.Request("GET", "http://error"))
+
+ with pytest.raises(ServerResponseError):
+ get_json_error(response)
+
+ assert mock_warning.call_args.kwargs["extra"] == expected_extra
+
@pytest.mark.parametrize(
("suppress_error_log", "expected_warning_count"),
[
diff --git a/task-sdk/src/airflow/sdk/api/client.py
b/task-sdk/src/airflow/sdk/api/client.py
index cc67a2697ab..bdf5ba34bff 100644
--- a/task-sdk/src/airflow/sdk/api/client.py
+++ b/task-sdk/src/airflow/sdk/api/client.py
@@ -1419,6 +1419,14 @@ class Client(httpx2.Client):
return DagsOperations(self)
+def _has_json_body(response: httpx2.Response) -> bool:
+ """Check whether a response declares a JSON body."""
+ # Kept in sync with airflowctl.api.operations._has_json_body.
+ # Media types are case-insensitive and may carry parameters (RFC 9110).
+ media_type = response.headers.get("content-type",
"").partition(";")[0].strip().lower()
+ return media_type == "application/json" or media_type.endswith("+json")
+
+
# This is only used for parsing. ServerResponseError is raised instead
class _ErrorBody(BaseModel):
detail: list[RemoteValidationError] | dict[str, Any] | str
@@ -1446,7 +1454,7 @@ class ServerResponseError(httpx2.HTTPStatusError):
if not (400 <= response.status_code < 600):
return None
- if response.headers.get("content-type") != "application/json":
+ if not _has_json_body(response):
return None
detail: list[RemoteValidationError] | dict[str, Any] | None = None
diff --git a/task-sdk/tests/task_sdk/api/test_client.py
b/task-sdk/tests/task_sdk/api/test_client.py
index c92653386cf..9b23b65c153 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -200,6 +200,35 @@ class TestClient:
assert err.value.args == ("Not found",)
assert err.value.detail is None
+ @pytest.mark.parametrize(
+ "content_type",
+ [
+ pytest.param("application/json; charset=utf-8", id="charset"),
+ pytest.param("application/json;charset=UTF-8",
id="charset-no-space"),
+ pytest.param("Application/JSON", id="mixed-case"),
+ pytest.param("application/problem+json", id="problem-json"),
+ ],
+ )
+ def test_error_parsing_json_media_type_variants(self, content_type):
+ responses = [
+ httpx2.Response(404, json={"detail": "Not found"},
headers={"content-type": content_type})
+ ]
+ client = make_client_w_responses(responses)
+
+ with pytest.raises(ServerResponseError) as err:
+ client.get("http://error")
+ assert err.value.args == ("Not found",)
+
+ def test_error_parsing_media_type_merely_prefixed_with_json(self):
+ responses = [
+ httpx2.Response(404, json={"detail": "Not found"},
headers={"content-type": "application/jsonp"})
+ ]
+ client = make_client_w_responses(responses)
+
+ with pytest.raises(httpx2.HTTPStatusError) as err:
+ client.get("http://error")
+ assert not isinstance(err.value, ServerResponseError)
+
def test_server_response_error_pickling(self):
responses = [httpx2.Response(404, json={"detail": {"message": "Invalid
input"}})]
client = make_client_w_responses(responses)