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