From 4939adf8336f0b7ccd429be0bb5448cc0687aec8 Mon Sep 17 00:00:00 2001 From: Yoann Abriel Date: Wed, 11 Mar 2026 19:12:08 +0100 Subject: [PATCH 1/3] fix(task-sdk): include error details in ServerResponseError string representation When ServerResponseError propagated up the stack without being explicitly caught and handled, the error details were lost because __str__() only returned the base message without the detail attribute. This made debugging difficult as logs would show 'Server returned error' without any context about what actually went wrong. Added __str__() override to include the detail when present, so error details are always visible in logs and tracebacks regardless of how the exception is caught and re-raised. Closes: #57961 --- task-sdk/src/airflow/sdk/api/client.py | 6 +++++ task-sdk/tests/task_sdk/api/test_client.py | 28 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/task-sdk/src/airflow/sdk/api/client.py b/task-sdk/src/airflow/sdk/api/client.py index d0362ce12aa61..66db616cad168 100644 --- a/task-sdk/src/airflow/sdk/api/client.py +++ b/task-sdk/src/airflow/sdk/api/client.py @@ -1062,6 +1062,12 @@ def __init__(self, message: str, *, request: httpx.Request, response: httpx.Resp detail: list[RemoteValidationError] | str | dict[str, Any] | None + def __str__(self) -> str: + base = super().__str__() + if self.detail: + return f"{base}\nDetail: {self.detail}" + return base + def __reduce__(self) -> tuple[Any, ...]: # Needed because https://github.com/encode/httpx/pull/3108 isn't merged yet. return Exception.__new__, (type(self),) + self.args, self.__dict__ diff --git a/task-sdk/tests/task_sdk/api/test_client.py b/task-sdk/tests/task_sdk/api/test_client.py index 0df8839c55f30..80c5c45dbfd6b 100644 --- a/task-sdk/tests/task_sdk/api/test_client.py +++ b/task-sdk/tests/task_sdk/api/test_client.py @@ -175,6 +175,34 @@ def test_server_response_error_pickling(self): assert unpickled.response.status_code == 404 assert unpickled.request.url == "http://error" + def test_server_response_error_str_includes_detail(self): + """Test that ServerResponseError string representation includes error details.""" + responses = [httpx.Response(409, json={"detail": {"reason": "invalid_state", "message": "TI was not in the running state"}})] + client = make_client_w_responses(responses) + + with pytest.raises(ServerResponseError) as exc_info: + client.get("http://error") + + err = exc_info.value + error_str = str(err) + assert "Detail:" in error_str + assert "invalid_state" in error_str + + def test_server_response_error_str_without_detail(self): + """Test that ServerResponseError string without detail doesn't include Detail section.""" + # 422 with a string detail: the string becomes the message, detail attr is None + responses = [httpx.Response(422, json={"detail": "Simple error"})] + client = make_client_w_responses(responses) + + with pytest.raises(ServerResponseError) as exc_info: + client.get("http://error") + + err = exc_info.value + error_str = str(err) + assert "Simple error" in error_str + # detail is None when the detail string is used as the message itself + assert "Detail:" not in error_str + def test_retry_handling_unrecoverable_error(self): with time_machine.travel("2023-01-01T00:00:00Z", tick=False): responses: list[httpx.Response] = [ From e56568379151077ca8ea85df86f68d18d89c32ab Mon Sep 17 00:00:00 2001 From: Yoann Abriel Date: Wed, 8 Apr 2026 11:06:55 +0200 Subject: [PATCH 2/3] fix(task-sdk): handle missing detail attr in ServerResponseError --- task-sdk/src/airflow/sdk/api/client.py | 4 ++-- task-sdk/tests/task_sdk/api/test_client.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/task-sdk/src/airflow/sdk/api/client.py b/task-sdk/src/airflow/sdk/api/client.py index 66db616cad168..afaece92fab76 100644 --- a/task-sdk/src/airflow/sdk/api/client.py +++ b/task-sdk/src/airflow/sdk/api/client.py @@ -1064,8 +1064,8 @@ def __init__(self, message: str, *, request: httpx.Request, response: httpx.Resp def __str__(self) -> str: base = super().__str__() - if self.detail: - return f"{base}\nDetail: {self.detail}" + if detail := getattr(self, "detail", None): + return f"{base}\nDetail: {detail}" return base def __reduce__(self) -> tuple[Any, ...]: diff --git a/task-sdk/tests/task_sdk/api/test_client.py b/task-sdk/tests/task_sdk/api/test_client.py index 80c5c45dbfd6b..f99b8dc09d963 100644 --- a/task-sdk/tests/task_sdk/api/test_client.py +++ b/task-sdk/tests/task_sdk/api/test_client.py @@ -203,6 +203,12 @@ def test_server_response_error_str_without_detail(self): # detail is None when the detail string is used as the message itself assert "Detail:" not in error_str + def test_server_response_error_str_missing_detail_attr(self): + err = ServerResponseError.__new__(ServerResponseError) + err.args = ("Server returned error",) + + assert "Detail:" not in str(err) + def test_retry_handling_unrecoverable_error(self): with time_machine.travel("2023-01-01T00:00:00Z", tick=False): responses: list[httpx.Response] = [ From 92e9202f30250efb2f544fd70873150d5997a1cd Mon Sep 17 00:00:00 2001 From: Yoann Abriel Date: Wed, 8 Apr 2026 14:10:45 +0200 Subject: [PATCH 3/3] style: apply ruff formatting --- task-sdk/tests/task_sdk/api/test_client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/task-sdk/tests/task_sdk/api/test_client.py b/task-sdk/tests/task_sdk/api/test_client.py index f99b8dc09d963..4d6a27baa1b65 100644 --- a/task-sdk/tests/task_sdk/api/test_client.py +++ b/task-sdk/tests/task_sdk/api/test_client.py @@ -177,7 +177,12 @@ def test_server_response_error_pickling(self): def test_server_response_error_str_includes_detail(self): """Test that ServerResponseError string representation includes error details.""" - responses = [httpx.Response(409, json={"detail": {"reason": "invalid_state", "message": "TI was not in the running state"}})] + responses = [ + httpx.Response( + 409, + json={"detail": {"reason": "invalid_state", "message": "TI was not in the running state"}}, + ) + ] client = make_client_w_responses(responses) with pytest.raises(ServerResponseError) as exc_info: