diff --git a/task-sdk/src/airflow/sdk/api/client.py b/task-sdk/src/airflow/sdk/api/client.py index d0362ce12aa61..afaece92fab76 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 detail := getattr(self, "detail", None): + return f"{base}\nDetail: {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..4d6a27baa1b65 100644 --- a/task-sdk/tests/task_sdk/api/test_client.py +++ b/task-sdk/tests/task_sdk/api/test_client.py @@ -175,6 +175,45 @@ 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_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] = [