From 08a402d671ad266b30e35c5380323cd94ceb7072 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 24 Jun 2026 19:38:43 +0300 Subject: [PATCH] test(bedrock): lock in legible non-JSON gateway error surfacing (PC-4775) The core fix already landed in #95 (raise_for_status before .json() on the converse and streaming paths, 2026-06-23), which post-dates the PC-4775 failing job's runtime 195.1.0. No production code changes here. #95's tests only exercised a plain httpx.Client with a JSON error body and asserted a bare httpx.HTTPStatusError. They did not cover the actual incident: a non-2xx HTML / non-JSON body through the patch_raise_for_status-wrapped client that production uses. Without the guard, that body crashed converse with a JSONDecodeError and the streaming path with a botocore ChecksumMismatch. Add regression tests that drive converse and converse_stream through a patched client returning a 403 HTML body, asserting a typed UiPathPermissionDeniedError carrying status 403 and the body excerpt instead of an opaque error. A mutation check (removing the raise_for_status guards) confirms both tests fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bedrock/test_wrapped_boto_client.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/langchain/clients/bedrock/test_wrapped_boto_client.py b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py index 47189587..bbaf5b3b 100644 --- a/tests/langchain/clients/bedrock/test_wrapped_boto_client.py +++ b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py @@ -13,12 +13,23 @@ import pytest from uipath_langchain_client.clients.bedrock.utils import WrappedBotoClient +from uipath.llm_client.utils.exceptions import ( + UiPathAPIError, + UiPathPermissionDeniedError, + patch_raise_for_status, +) + _ERROR_BODY = { "title": "License not available", "status": 403, "detail": "License not available for LLM usage.", } +_HTML_403_BODY = ( + "403 Forbidden" + "403 Forbidden" +) + def _wrapped(handler: object) -> WrappedBotoClient: transport = httpx.MockTransport(handler) # type: ignore[arg-type] @@ -27,6 +38,23 @@ def _wrapped(handler: object) -> WrappedBotoClient: ) +def _wrapped_patched(handler: object) -> WrappedBotoClient: + """Mirror production: responses carry the patched ``raise_for_status``. + + ``UiPathHttpxClient`` runs ``patch_raise_for_status`` on every response so a + non-2xx body surfaces as a typed ``UiPathAPIError`` (status + body excerpt) + instead of a bare ``httpx.HTTPStatusError`` or an opaque ``JSONDecodeError``. + """ + transport = httpx.MockTransport(handler) # type: ignore[arg-type] + return WrappedBotoClient( + httpx_client=httpx.Client( + transport=transport, + base_url="http://gateway", + event_hooks={"response": [lambda response: patch_raise_for_status(response)]}, + ) + ) + + def test_converse_raises_on_http_error() -> None: client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) with pytest.raises(httpx.HTTPStatusError): @@ -58,3 +86,54 @@ def test_invoke_model_with_response_stream_raises_on_http_error() -> None: stream = client.invoke_model_with_response_stream(body=json.dumps({"prompt": "hi"}))["body"] with pytest.raises(httpx.HTTPStatusError): list(stream) + + +def test_converse_surfaces_legible_error_for_non_json_body() -> None: + """Regression for PC-4775: a 403 HTML body must not become a JSONDecodeError. + + Through the patched client the gateway error surfaces as a typed + ``UiPathPermissionDeniedError`` carrying the status code and the HTML body + excerpt, instead of the opaque ``json.decoder.JSONDecodeError`` that crashed + the job before the ``raise_for_status`` guard. + """ + client = _wrapped_patched( + lambda request: httpx.Response( + 403, text=_HTML_403_BODY, headers={"content-type": "text/html"} + ) + ) + with pytest.raises(UiPathPermissionDeniedError) as exc_info: + client.converse(messages=[{"role": "user", "content": [{"text": "hi"}]}]) + error = exc_info.value + assert error.status_code == 403 + assert error.body == _HTML_403_BODY + assert "403 Forbidden" in str(error) + + +def test_converse_stream_surfaces_legible_error_for_non_json_body() -> None: + """Regression for PC-4775 on the streaming path. + + The non-event 403 HTML body is read before the EventStreamBuffer touches it, + so the patched ``raise_for_status`` surfaces a typed error rather than a + checksum mismatch or an opaque ``JSONDecodeError``. + """ + client = _wrapped_patched( + lambda request: httpx.Response( + 403, text=_HTML_403_BODY, headers={"content-type": "text/html"} + ) + ) + stream = client.converse_stream(messages=[])["stream"] + with pytest.raises(UiPathPermissionDeniedError) as exc_info: + list(stream) + error = exc_info.value + assert error.status_code == 403 + assert error.body == _HTML_403_BODY + assert "403 Forbidden" in str(error) + + +def test_converse_typed_error_includes_json_body_excerpt() -> None: + """A JSON gateway error body is preserved on the typed error for diagnosis.""" + client = _wrapped_patched(lambda request: httpx.Response(403, json=_ERROR_BODY)) + with pytest.raises(UiPathAPIError) as exc_info: + client.converse(messages=[]) + assert exc_info.value.status_code == 403 + assert exc_info.value.body == _ERROR_BODY