From 76e252a743edb13e3035e16114acaefa1c3971da Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 23 Jun 2026 10:03:40 +0300 Subject: [PATCH] fix(bedrock): raise_for_status in WrappedBotoClient so gateway errors surface WrappedBotoClient read responses with .json()/iter_bytes without checking the HTTP status, so a non-2xx gateway response (e.g. 403 License-not-available) was parsed as a normal result and handed to langchain_aws, which then raised a misleading "No 'output' key ... misconfiguration of endpoint or region" ValueError - losing the real status code and detail. Call raise_for_status() in converse, invoke_model, and the streaming generator so gateway HTTP errors surface as the patched UiPathAPIError subclass (e.g. UiPathPermissionDeniedError), matching the OpenAI and Vertex paths. For streaming, read the error body first so the typed exception keeps its detail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 5 ++ .../uipath_langchain_client/__version__.py | 2 +- .../clients/bedrock/utils.py | 24 +++++--- .../bedrock/test_wrapped_boto_client.py | 60 +++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 tests/langchain/clients/bedrock/test_wrapped_boto_client.py diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index e6f40ae0..c96658d1 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.14.1] - 2026-06-23 + +### Fixed +- `WrappedBotoClient` (the httpx-backed Bedrock shim) now calls `raise_for_status()` in `converse`, `invoke_model`, and the streaming generator before reading the response. Previously a non-2xx gateway response (e.g. 403 License-not-available) was parsed as a normal result and handed to `langchain_aws`, which raised a misleading `ValueError("No 'output' key found in the response from the Bedrock Converse API ... misconfiguration of endpoint or region")` — the real status code and `detail` were lost. Gateway HTTP errors now surface as the patched `UiPathAPIError` subclass (e.g. `UiPathPermissionDeniedError`), matching the OpenAI and Vertex paths. For streaming responses the error body is read first so the typed exception retains its `detail`. + ## [1.14.0] - 2026-06-15 ### Added diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 6e980c9f..fd16af42 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LangChain Client" __description__ = "A Python client for interacting with UiPath's LLM services via LangChain." -__version__ = "1.14.0" +__version__ = "1.14.1" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py index 72e78e51..67a93a45 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/utils.py @@ -57,6 +57,12 @@ def _stream_generator( if self.httpx_client is None: raise ValueError("httpx_client is not set") with self.httpx_client.stream("POST", "/", json=_serialize_bytes(request_body)) as response: + if response.is_error: + # The gateway returns a non-streamed JSON error body; read it so + # the patched raise_for_status surfaces it (with detail) instead + # of the EventStreamBuffer choking on a non-event payload. + response.read() + response.raise_for_status() buffer = EventStreamBuffer() for chunk in response.iter_bytes(): buffer.add_data(chunk) @@ -71,12 +77,12 @@ def _stream_generator( def invoke_model(self, **kwargs: Any) -> Any: if self.httpx_client is None: raise ValueError("httpx_client is not set") - return { - "body": self.httpx_client.post( - "/", - json=json.loads(kwargs.get("body", "{}")), - ) - } + response = self.httpx_client.post( + "/", + json=json.loads(kwargs.get("body", "{}")), + ) + response.raise_for_status() + return {"body": response} def invoke_model_with_response_stream(self, **kwargs: Any) -> Any: return {"body": self._stream_generator(json.loads(kwargs.get("body", "{}")))} @@ -90,7 +96,7 @@ def converse( ) -> Any: if self.httpx_client is None: raise ValueError("httpx_client is not set") - return self.httpx_client.post( + response = self.httpx_client.post( "/", json=_serialize_bytes( { @@ -99,7 +105,9 @@ def converse( **params, } ), - ).json() + ) + response.raise_for_status() + return response.json() def converse_stream( self, diff --git a/tests/langchain/clients/bedrock/test_wrapped_boto_client.py b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py new file mode 100644 index 00000000..47189587 --- /dev/null +++ b/tests/langchain/clients/bedrock/test_wrapped_boto_client.py @@ -0,0 +1,60 @@ +"""Unit tests for ``WrappedBotoClient`` HTTP error surfacing. + +The shim talks to the LLM Gateway over httpx instead of AWS. It must call +``raise_for_status()`` so gateway HTTP errors (e.g. 403 License-not-available) +propagate as exceptions, rather than being parsed as a normal result and then +mis-reported downstream (langchain_aws raises a misleading "No 'output' key" +``ValueError`` when the response lacks the expected fields). +""" + +import json + +import httpx +import pytest +from uipath_langchain_client.clients.bedrock.utils import WrappedBotoClient + +_ERROR_BODY = { + "title": "License not available", + "status": 403, + "detail": "License not available for LLM usage.", +} + + +def _wrapped(handler: object) -> WrappedBotoClient: + transport = httpx.MockTransport(handler) # type: ignore[arg-type] + return WrappedBotoClient( + httpx_client=httpx.Client(transport=transport, base_url="http://gateway") + ) + + +def test_converse_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + with pytest.raises(httpx.HTTPStatusError): + client.converse(messages=[{"role": "user", "content": [{"text": "hi"}]}]) + + +def test_converse_returns_body_on_success() -> None: + payload = {"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}} + client = _wrapped(lambda request: httpx.Response(200, json=payload)) + assert client.converse(messages=[]) == payload + + +def test_invoke_model_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + with pytest.raises(httpx.HTTPStatusError): + client.invoke_model(body=json.dumps({"prompt": "hi"})) + + +def test_converse_stream_raises_on_http_error() -> None: + # The generator defers work until iterated, so the error surfaces on consume. + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + stream = client.converse_stream(messages=[])["stream"] + with pytest.raises(httpx.HTTPStatusError): + list(stream) + + +def test_invoke_model_with_response_stream_raises_on_http_error() -> None: + client = _wrapped(lambda request: httpx.Response(403, json=_ERROR_BODY)) + stream = client.invoke_model_with_response_stream(body=json.dumps({"prompt": "hi"}))["body"] + with pytest.raises(httpx.HTTPStatusError): + list(stream)