From 41fdc28b91f9140618128e801e1c1ef84d6505f9 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 4 Sep 2026 09:51:36 +0200 Subject: [PATCH 1/2] fix: raise APIError when an error body cannot be parsed throw_failed_responses let anything raised while deserializing the error body escape, so a text/html gateway page surfaced as a bare Exception that no except APIError in a client could catch. The parse step is now wrapped and re-raised as an APIError carrying the status and headers, chained from the original. Fixes #705. --- .../httpx/kiota_http/httpx_request_adapter.py | 26 +++++++++++++------ .../httpx/tests/test_httpx_request_adapter.py | 23 ++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/http/httpx/kiota_http/httpx_request_adapter.py b/packages/http/httpx/kiota_http/httpx_request_adapter.py index 23f1dfaf..ff89234c 100644 --- a/packages/http/httpx/kiota_http/httpx_request_adapter.py +++ b/packages/http/httpx/kiota_http/httpx_request_adapter.py @@ -549,14 +549,24 @@ async def throw_failed_responses( raise exc _throw_failed_resp_span.set_attribute("status_message", "received_error_response") - error = await self._get_error_from_response( - response, - error_map, - response_status_code_str, - response_status_code, - attribute_span, - _throw_failed_resp_span, - ) + try: + error = await self._get_error_from_response( + response, + error_map, + response_status_code_str, + response_status_code, + attribute_span, + _throw_failed_resp_span, + ) + except Exception as ex: # a body no parse node can read, e.g. an HTML gateway page + exc = APIError( + "The server returned an unexpected status code and the error body could not" + f" be parsed: {ex}", + response_status_code, + response_headers, # type: ignore + ) + attribute_span.record_exception(exc) + raise exc from ex if isinstance(error, APIError): error.response_headers = response_headers # type: ignore error.response_status_code = response_status_code diff --git a/packages/http/httpx/tests/test_httpx_request_adapter.py b/packages/http/httpx/tests/test_httpx_request_adapter.py index ab3e627c..8d2d1c5f 100644 --- a/packages/http/httpx/tests/test_httpx_request_adapter.py +++ b/packages/http/httpx/tests/test_httpx_request_adapter.py @@ -217,6 +217,29 @@ async def test_throw_failed_responses_5XX( assert str(e.value.message) == "Custom Internal Server Error" +@pytest.mark.asyncio +async def test_throw_failed_responses_unparsable_error_body( + request_adapter, mock_apierror_XXX_map, mock_otel_span +): + resp = httpx.Response( + status_code=502, + headers={"Content-Type": "text/html"}, + content=b"502 Bad Gateway", + ) + assert resp.status_code == 502 + content_type = request_adapter.get_response_content_type(resp) + assert content_type == "text/html" + + with pytest.raises(APIError) as e: + span = mock_otel_span + await request_adapter.throw_failed_responses(resp, mock_apierror_XXX_map, span, span) + assert ( + "The server returned an unexpected status code and the error body could not be parsed" + ) in str(e.value.message) + assert e.value.response_status_code == 502 + assert "text/html" in str(e.value.__cause__) + + @pytest.mark.asyncio async def test_throw_failed_responses_XXX( request_adapter, mock_apierror_XXX_map, mock_error_object, mock_otel_span From 56047948f798715fd68da18b1c0ab2841049024b Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 4 Sep 2026 20:18:04 +0200 Subject: [PATCH 2/2] fix: keep the parse-failure APIError message stable and record the cause on the span The original parsing exception now goes on the span and stays reachable through __cause__ instead of being interpolated into the message. A test pins that a cancellation raised while reading the error body propagates unwrapped. --- .../httpx/kiota_http/httpx_request_adapter.py | 4 +-- .../httpx/tests/test_httpx_request_adapter.py | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/http/httpx/kiota_http/httpx_request_adapter.py b/packages/http/httpx/kiota_http/httpx_request_adapter.py index ff89234c..a07c8757 100644 --- a/packages/http/httpx/kiota_http/httpx_request_adapter.py +++ b/packages/http/httpx/kiota_http/httpx_request_adapter.py @@ -559,13 +559,13 @@ async def throw_failed_responses( _throw_failed_resp_span, ) except Exception as ex: # a body no parse node can read, e.g. an HTML gateway page + attribute_span.record_exception(ex) exc = APIError( "The server returned an unexpected status code and the error body could not" - f" be parsed: {ex}", + " be parsed", response_status_code, response_headers, # type: ignore ) - attribute_span.record_exception(exc) raise exc from ex if isinstance(error, APIError): error.response_headers = response_headers # type: ignore diff --git a/packages/http/httpx/tests/test_httpx_request_adapter.py b/packages/http/httpx/tests/test_httpx_request_adapter.py index 8d2d1c5f..a2e50852 100644 --- a/packages/http/httpx/tests/test_httpx_request_adapter.py +++ b/packages/http/httpx/tests/test_httpx_request_adapter.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import AsyncMock, Mock, call, patch from urllib.parse import unquote @@ -230,14 +231,29 @@ async def test_throw_failed_responses_unparsable_error_body( content_type = request_adapter.get_response_content_type(resp) assert content_type == "text/html" + attribute_span = Mock() with pytest.raises(APIError) as e: - span = mock_otel_span - await request_adapter.throw_failed_responses(resp, mock_apierror_XXX_map, span, span) - assert ( - "The server returned an unexpected status code and the error body could not be parsed" - ) in str(e.value.message) + await request_adapter.throw_failed_responses( + resp, mock_apierror_XXX_map, mock_otel_span, attribute_span + ) + assert ("The server returned an unexpected status code and the error body could not be parsed" + ) in str(e.value.message) + assert "text/html" not in str(e.value.message) assert e.value.response_status_code == 502 assert "text/html" in str(e.value.__cause__) + attribute_span.record_exception.assert_called_once_with(e.value.__cause__) + + +@pytest.mark.asyncio +async def test_throw_failed_responses_lets_cancellation_through( + request_adapter, mock_apierror_XXX_map, mock_otel_span +): + resp = httpx.Response(status_code=502, headers={"Content-Type": "text/html"}, content=b"") + request_adapter._get_error_from_response = AsyncMock(side_effect=asyncio.CancelledError()) + + with pytest.raises(asyncio.CancelledError): + span = mock_otel_span + await request_adapter.throw_failed_responses(resp, mock_apierror_XXX_map, span, span) @pytest.mark.asyncio