From 4e2b3c85b1e21dbf38d4718a65a11b1b78ef526f Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Wed, 24 Jun 2026 19:52:11 +0300 Subject: [PATCH 1/3] feat: unify exception taxonomy with UiPathError and re-tag provider errors Introduce `UiPathError` as a common root for everything the client can raise and make `UiPathAPIError` (and all status subclasses) inherit from it, so `except UiPathError` is a single catch-all across all backends and providers. For the LangChain passthrough chat models, `UiPathBaseChatModel` now re-tags provider SDK exceptions in place (via `wrap_provider_errors` / `as_uipath_error`) around `_generate`/`_agenerate`/`_stream`/`_astream`. An HTTP-shaped vendor error (openai/anthropic/...) is tagged with the matching `UiPathAPIError` subclass by status code (e.g. a 429 -> `UiPathRateLimitError`), so semantic handling works identically across every provider; non-HTTP errors get the `UiPathError` root marker. The re-tagged exception presents as the UiPath type (`type(exc).__name__` reads e.g. `UiPathBadRequestError`) while remaining catchable as the original vendor type via the MRO. - core + langchain bumped to 1.15.0; changelogs and dependency pin updated - `UiPathRateLimitError.retry_after` is now lazy (survives `__class__` re-tag) - `UiPathAPIError.__str__`/`__repr__` read attrs defensively - tests cover the core helpers and end-to-end wiring (real openai SDK, sync/async/stream) across every catchable type Affects: core (uipath-llm-client) and langchain (uipath-langchain-client). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 + packages/uipath_langchain_client/CHANGELOG.md | 8 + .../uipath_langchain_client/pyproject.toml | 2 +- .../src/uipath_langchain_client/__init__.py | 2 + .../uipath_langchain_client/__version__.py | 2 +- .../uipath_langchain_client/base_client.py | 43 ++-- src/uipath/llm_client/__init__.py | 2 + src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/utils/exceptions.py | 195 ++++++++++++++++-- .../core/features/test_exception_wrapping.py | 174 ++++++++++++++++ .../features/test_exception_wrapping.py | 188 +++++++++++++++++ 11 files changed, 587 insertions(+), 41 deletions(-) create mode 100644 tests/core/features/test_exception_wrapping.py create mode 100644 tests/langchain/features/test_exception_wrapping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9894dd..bb2d18d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.15.0] - 2026-06-24 + +### Added +- `UiPathError` — a common root exception for everything the client can raise, re-exported from `uipath.llm_client`. `UiPathAPIError` (and therefore every status-specific subclass) now inherits from it, so `except UiPathError` is a single catch-all across all backends and providers. Fully backward compatible: `UiPathAPIError` still inherits `httpx.HTTPStatusError` and all existing handlers keep working. +- `as_uipath_error(exc)` and the `wrap_provider_errors()` context manager (in `uipath.llm_client.utils.exceptions`). They re-tag a provider/SDK exception in place so it is catchable as **both** its original type (e.g. `openai.BadRequestError`) and the matching UiPath type. When the error carries an `httpx.Response`, its status code is mapped onto the corresponding `UiPathAPIError` subclass (e.g. a 429 becomes a `UiPathRateLimitError`) so semantic handling works identically across providers; otherwise the error is tagged with the `UiPathError` root only. The re-tagged exception **presents as the UiPath type** — `type(exc).__name__` reads e.g. `UiPathBadRequestError` and reprs/tracebacks show it — while the original vendor type stays visible via the MRO (`isinstance`/`except` against it still succeed). Built-in exceptions whose layout forbids `__class__` reassignment are rebuilt and chained via `raise ... from`. + +### Changed +- `UiPathRateLimitError.retry_after` is now parsed lazily from `self.response` (via a property) instead of being cached in `__init__`, so the value is correct for both constructed and re-tagged instances. `_parse_retry_after` and the parsing behaviour are unchanged. +- `UiPathAPIError.__str__` / `__repr__` read `message`/`status_code`/`body` defensively so a re-tagged foreign exception without those attributes cannot raise `AttributeError`. Output is unchanged for normally-constructed instances. + ## [1.14.0] - 2026-06-15 ### Added diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index c96658d1..af64352c 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.15.0] - 2026-06-24 + +### Added +- `UiPathBaseChatModel` now re-tags provider SDK exceptions as `UiPathError`. When any chat client (`UiPathChatOpenAI`, `UiPathChatAnthropic`, `UiPathChat`, Bedrock/Vertex/Google/LiteLLM/Fireworks, …) raises during `_generate`/`_agenerate`/`_stream`/`_astream`, the error is re-tagged in place so callers can catch it as **both** its original vendor type (e.g. `openai.BadRequestError`) and as a UiPath type. HTTP-shaped vendor errors map onto the matching semantic subclass (a 429 → `UiPathRateLimitError`, a 400 → `UiPathBadRequestError`, …), giving provider-agnostic error handling across every client; non-HTTP errors are tagged with the `UiPathError` root. `UiPathError` is re-exported from `uipath_langchain_client`. + +### Changed +- Bumped `uipath-llm-client` floor to `>=1.15.0` to pick up `UiPathError` and the `wrap_provider_errors` / `as_uipath_error` helpers. + ## [1.14.1] - 2026-06-23 ### Fixed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 32612485..6670850c 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15,<2.0.0", - "uipath-llm-client>=1.14.0,<2.0.0", + "uipath-llm-client>=1.15.0,<2.0.0", ] [project.optional-dependencies] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__init__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__init__.py index 14fe5edb..f0113cd5 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__init__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__init__.py @@ -32,6 +32,7 @@ - get_embedding_model(): Create an embeddings model with auto-detected vendor """ +from uipath.llm_client.utils.exceptions import UiPathError from uipath_langchain_client.__version__ import __version__ from uipath_langchain_client.callbacks import UiPathDynamicHeadersCallback from uipath_langchain_client.clients import UiPathChat, UiPathEmbeddings @@ -52,4 +53,5 @@ "get_default_client_settings", "LLMGatewaySettings", "PlatformSettings", + "UiPathError", ] 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 fd16af42..b33880d1 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.1" +__version__ = "1.15.0" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index fccd696c..9189ce2a 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -44,6 +44,7 @@ UiPathHttpxAsyncClient, UiPathHttpxClient, ) +from uipath.llm_client.utils.exceptions import wrap_provider_errors from uipath.llm_client.utils.headers import ( UIPATH_DEFAULT_REQUEST_HEADERS, get_captured_response_headers, @@ -437,7 +438,10 @@ def _generate( ) set_captured_response_headers({}) try: - result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) + with wrap_provider_errors(): + result = self._uipath_generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) self._inject_gateway_headers(result.generations) return result finally: @@ -468,9 +472,10 @@ async def _agenerate( ) set_captured_response_headers({}) try: - result = await self._uipath_agenerate( - messages, stop=stop, run_manager=run_manager, **kwargs - ) + with wrap_provider_errors(): + result = await self._uipath_agenerate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) self._inject_gateway_headers(result.generations) return result finally: @@ -502,13 +507,14 @@ def _stream( set_captured_response_headers({}) try: first = True - for chunk in self._uipath_stream( - messages, stop=stop, run_manager=run_manager, **kwargs - ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + with wrap_provider_errors(): + for chunk in self._uipath_stream( + messages, stop=stop, run_manager=run_manager, **kwargs + ): + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) @@ -538,13 +544,14 @@ async def _astream( set_captured_response_headers({}) try: first = True - async for chunk in self._uipath_astream( - messages, stop=stop, run_manager=run_manager, **kwargs - ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + with wrap_provider_errors(): + async for chunk in self._uipath_astream( + messages, stop=stop, run_manager=run_manager, **kwargs + ): + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index da9f300a..eca87f88 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -42,6 +42,7 @@ UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, + UiPathError, UiPathGatewayTimeoutError, UiPathInternalServerError, UiPathNotFoundError, @@ -69,6 +70,7 @@ # Retry "RetryConfig", # Exceptions + "UiPathError", "UiPathAPIError", "UiPathAuthenticationError", "UiPathBadGatewayError", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index fdf225c1..520e6288 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.14.0" +__version__ = "1.15.0" diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 0ad93e3e..8e32c7d7 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -5,8 +5,13 @@ Each exception class corresponds to a specific HTTP status code, allowing for precise error handling in application code. -These exceptions inherit from httpx.HTTPStatusError, so they can be caught -by both UiPath-specific handlers and generic httpx error handlers. +These exceptions inherit from both UiPathError and httpx.HTTPStatusError, so +they can be caught by a UiPath-wide ``except UiPathError`` handler, by +status-specific UiPath handlers, or by generic httpx error handlers. + +For the LangChain passthrough chat models, vendor SDK exceptions (e.g. +``openai.BadRequestError``) are additionally re-tagged via as_uipath_error so +they too are catchable as UiPathError without losing their original type. The UiPathAPIError.from_response() factory method automatically creates the appropriate exception type based on the HTTP response status code. @@ -25,16 +30,44 @@ ... print(f"API Error: {e.status_code} - {e.message}") """ +from collections.abc import Iterator +from contextlib import contextmanager from json import JSONDecodeError -from typing import Literal +from typing import Literal, cast from httpx import HTTPStatusError, Request, Response -class UiPathAPIError(HTTPStatusError): +class UiPathError(Exception): + """Common base class for every error surfaced by the UiPath LLM client. + + Two distinct kinds of error are catchable as ``UiPathError``: + + * :class:`UiPathAPIError` and its status-specific subclasses, raised by the + core HTTP client for non-2xx responses. + * Vendor SDK exceptions (e.g. ``openai.BadRequestError``) raised by the + LangChain passthrough chat models. These are re-tagged in place by + :func:`as_uipath_error` so they remain catchable as *both* their original + type and ``UiPathError``. + + Catch ``UiPathError`` to handle any UiPath LLM failure regardless of which + backend or provider produced it:: + + try: + chat.invoke(...) + except openai.BadRequestError: # provider-specific handling still works + ... + except UiPathError: # catch-all across every provider + ... + """ + + +class UiPathAPIError(UiPathError, HTTPStatusError): """Base exception for all UiPath API errors. - Inherits from httpx.HTTPStatusError for compatibility with httpx error handling. + Inherits from :class:`UiPathError` (so it can be caught alongside wrapped + provider errors) and ``httpx.HTTPStatusError`` (for compatibility with httpx + error handling). Attributes: message: Human-readable error message (usually the HTTP reason phrase). @@ -59,11 +92,25 @@ def __init__( self.message = message self.body = body + def _message(self) -> object: + # ``message`` is set by __init__, but a vendor error re-tagged into this + # class via as_uipath_error() bypasses __init__ — fall back to args. + message = getattr(self, "message", None) + if message is None: + message = self.args[0] if self.args else "" + return message + def __str__(self) -> str: - return f"{self.__class__.__name__}: {self.message} (Status Code: {self.status_code}) {self.body}" + return ( + f"{self.__class__.__name__}: {self._message()} " + f"(Status Code: {getattr(self, 'status_code', None)}) {getattr(self, 'body', None)}" + ) def __repr__(self) -> str: - return f"{self.__class__.__name__}(message={self.message!r}, status_code={self.status_code}, body={self.body!r})" + return ( + f"{self.__class__.__name__}(message={self._message()!r}, " + f"status_code={getattr(self, 'status_code', None)}, body={getattr(self, 'body', None)!r})" + ) @classmethod def from_response(cls, response: Response, request: Request | None = None) -> "UiPathAPIError": @@ -151,21 +198,18 @@ class UiPathRateLimitError(UiPathAPIError): status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] - def __init__( - self, - message: str, - *, - request: Request, - response: Response, - body: str | dict | None = None, - ): - super().__init__(message, request=request, response=response, body=body) - self._retry_after = self._parse_retry_after(response) - @property def retry_after(self) -> float | None: - """Get the retry-after value in seconds, if available.""" - return self._retry_after + """Get the retry-after value in seconds, if available. + + Parsed lazily from ``self.response`` so the value is correct both for + instances built via ``__init__`` and for vendor errors re-tagged into + this class by :func:`as_uipath_error` (which bypasses ``__init__``). + """ + response = getattr(self, "response", None) + if not isinstance(response, Response): + return None + return self._parse_retry_after(response) @staticmethod def _parse_retry_after(response: Response) -> float | None: @@ -274,7 +318,116 @@ def raise_for_status() -> Response: return response +# Cache of (original exception class, UiPath base) -> synthesized subclass. +# Tagged classes are generated once per (original type, base) pair so +# ``isinstance`` results are stable and we never leak a new class per raised +# exception. +_uipath_tagged_classes: dict[tuple[type[Exception], type[Exception]], type[Exception]] = {} + + +def _uipath_tagged_class(cls: type[Exception], base: type[Exception]) -> type[Exception]: + """Return a cached ``(base, cls)`` subclass for ``cls``. + + The synthesized class presents as the UiPath ``base``: it takes ``base``'s + ``__name__`` and ``__module__`` (so ``type(exc).__name__`` reads e.g. + ``UiPathBadRequestError`` in reprs and tracebacks) while remaining a subclass + of the original vendor exception ``cls`` -- so it stays catchable as both. + The original vendor type remains visible via ``__bases__`` / the MRO. + """ + key = (cls, base) + tagged = _uipath_tagged_classes.get(key) + if tagged is None: + tagged = cast( + "type[Exception]", + type(base.__name__, (base, cls), {"__module__": base.__module__}), + ) + _uipath_tagged_classes[key] = tagged + return tagged + + +def _uipath_base_for(exc: Exception) -> type[Exception]: + """Pick the UiPath base class to tag ``exc`` with. + + When ``exc`` carries a real ``httpx.Response`` (openai, anthropic, httpx, + litellm, fireworks, ...), map its HTTP status code onto the matching + :class:`UiPathAPIError` subclass so the error is catchable by its semantic + UiPath type (e.g. ``UiPathRateLimitError``) across every provider. An + unmapped status still becomes a generic :class:`UiPathAPIError`. + + When no httpx response is available (botocore, google, plain exceptions), we + cannot safely claim HTTP semantics, so we tag with the :class:`UiPathError` + root marker only — still catchable as ``UiPathError`` plus the original type. + """ + response = getattr(exc, "response", None) + if not isinstance(response, Response): + return UiPathError + status_code = getattr(exc, "status_code", None) + if not isinstance(status_code, int): + status_code = response.status_code + return _STATUS_CODE_TO_EXCEPTION.get(status_code, UiPathAPIError) + + +def as_uipath_error(exc: Exception) -> Exception: + """Tag ``exc`` so it is catchable as both its original type and ``UiPathError``. + + The tag is chosen by :func:`_uipath_base_for`: an HTTP-shaped vendor error is + tagged with the matching :class:`UiPathAPIError` subclass (so a provider's + 429 becomes a ``UiPathRateLimitError``, a 400 a ``UiPathBadRequestError``, + etc.), while anything else is tagged with the :class:`UiPathError` root. + + For mutable exception types (all vendor SDK exceptions: openai, anthropic, + botocore, google, httpx) the instance is re-tagged in place via ``__class__`` + assignment, preserving its message, attributes and traceback. The returned + object *is* ``exc``. + + For immutable built-in exception types (e.g. ``ValueError``) whose memory + layout forbids ``__class__`` reassignment, a fresh instance of the tagged + subclass is rebuilt from ``exc.args`` and returned instead; callers should + chain it with ``raise ... from exc``. If the rebuild fails, a plain + :class:`UiPathError` carrying the original message is returned as a last + resort. + + ``UiPathError`` instances (including :class:`UiPathAPIError`) are returned + unchanged. + """ + if isinstance(exc, UiPathError): + return exc + base = _uipath_base_for(exc) + tagged_cls = _uipath_tagged_class(type(exc), base) + try: + exc.__class__ = tagged_cls + return exc + except TypeError: + try: + return tagged_cls(*exc.args) + except Exception: + return UiPathError(str(exc)) + + +@contextmanager +def wrap_provider_errors() -> Iterator[None]: + """Re-raise provider/SDK exceptions tagged as :class:`UiPathError`. + + Any ``Exception`` raised inside the ``with`` block is re-tagged via + :func:`as_uipath_error` so callers can catch it as either its original type + (e.g. ``openai.BadRequestError``) or ``UiPathError``. ``UiPathError`` + instances pass through untouched, and non-``Exception`` ``BaseException`` + subclasses (``GeneratorExit``, ``KeyboardInterrupt``, ``SystemExit``) are + never wrapped. + """ + try: + yield + except UiPathError: + raise + except Exception as exc: + tagged = as_uipath_error(exc) + if tagged is exc: + raise + raise tagged from exc + + __all__ = [ + "UiPathError", "UiPathAPIError", "UiPathBadRequestError", "UiPathAuthenticationError", @@ -290,4 +443,6 @@ def raise_for_status() -> Response: "UiPathServiceUnavailableError", "UiPathGatewayTimeoutError", "UiPathTooManyRequestsError", + "as_uipath_error", + "wrap_provider_errors", ] diff --git a/tests/core/features/test_exception_wrapping.py b/tests/core/features/test_exception_wrapping.py new file mode 100644 index 00000000..faa5dd93 --- /dev/null +++ b/tests/core/features/test_exception_wrapping.py @@ -0,0 +1,174 @@ +"""Tests for the UiPathError root and as_uipath_error / wrap_provider_errors. + +These cover the unified-taxonomy behaviour: a provider error is re-tagged so it +is catchable as its original type, as the matching UiPath semantic subclass, and +as the UiPathError root. +""" + +import pytest +from httpx import HTTPStatusError, Request, Response + +from uipath.llm_client.utils.exceptions import ( + UiPathAPIError, + UiPathAuthenticationError, + UiPathBadRequestError, + UiPathError, + UiPathRateLimitError, + as_uipath_error, + wrap_provider_errors, +) + + +class _FakeVendorError(Exception): + """Mimics the shape of openai/anthropic errors (mutable, httpx response).""" + + def __init__( + self, + message: str, + status_code: int, + response: Response, + body: object = None, + ): + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + self.body = body + + +def _response(status: int, headers: dict[str, str] | None = None) -> Response: + return Response(status, request=Request("POST", "https://example.com"), headers=headers or {}) + + +class TestUiPathErrorHierarchy: + def test_uipath_error_is_root_of_api_errors(self): + assert issubclass(UiPathAPIError, UiPathError) + assert issubclass(UiPathBadRequestError, UiPathError) + assert issubclass(UiPathRateLimitError, UiPathError) + + def test_api_error_still_httpx(self): + assert issubclass(UiPathAPIError, HTTPStatusError) + + def test_rate_limit_retry_after_lazy_from_response(self): + # No custom __init__ anymore: retry_after is parsed from self.response. + exc = UiPathRateLimitError( + "slow down", + request=Request("POST", "https://example.com"), + response=_response(429, {"retry-after": "30"}), + ) + assert exc.retry_after == 30.0 + + +class TestAsUiPathError: + def test_maps_429_to_rate_limit_in_place(self): + err = _FakeVendorError("slow", 429, _response(429, {"retry-after": "12"})) + tagged = as_uipath_error(err) + assert tagged is err # re-tagged in place, same object + assert isinstance(tagged, UiPathRateLimitError) + assert isinstance(tagged, _FakeVendorError) + assert isinstance(tagged, UiPathAPIError) + assert isinstance(tagged, UiPathError) + assert tagged.status_code == 429 + assert tagged.retry_after == 12.0 + + def test_maps_400_to_bad_request(self): + err = _FakeVendorError("bad", 400, _response(400)) + tagged = as_uipath_error(err) + assert isinstance(tagged, UiPathBadRequestError) + assert isinstance(tagged, _FakeVendorError) + + def test_maps_401_to_authentication(self): + err = _FakeVendorError("no", 401, _response(401)) + tagged = as_uipath_error(err) + assert isinstance(tagged, UiPathAuthenticationError) + + def test_unmapped_status_becomes_generic_api_error(self): + err = _FakeVendorError("teapot", 418, _response(418)) + tagged = as_uipath_error(err) + assert type(tagged) is not UiPathAPIError # it's the tagged subclass + assert isinstance(tagged, UiPathAPIError) + assert not isinstance(tagged, UiPathBadRequestError) + assert tagged.status_code == 418 + + def test_no_http_response_gets_root_marker_only(self): + class _NoResponseError(Exception): + pass + + err = _NoResponseError("boom") + tagged = as_uipath_error(err) + assert isinstance(tagged, UiPathError) + assert isinstance(tagged, _NoResponseError) + assert not isinstance(tagged, UiPathAPIError) + + def test_existing_uipath_error_passthrough(self): + existing = UiPathAPIError.from_response(_response(500)) + assert as_uipath_error(existing) is existing + + def test_presents_as_uipath_type_but_keeps_vendor_type(self): + err = _FakeVendorError("bad", 400, _response(400)) + tagged = as_uipath_error(err) + # Presents as the UiPath semantic type... + assert type(tagged).__name__ == "UiPathBadRequestError" + assert type(tagged).__module__ == UiPathBadRequestError.__module__ + # ...while still being the original vendor type. + assert isinstance(tagged, _FakeVendorError) + assert _FakeVendorError in type(tagged).__mro__ + + def test_root_marker_presents_as_uipath_error(self): + class _NoResponseError(Exception): + pass + + tagged = as_uipath_error(_NoResponseError("boom")) + assert type(tagged).__name__ == "UiPathError" + assert isinstance(tagged, _NoResponseError) + + def test_str_uses_uipath_format_after_tag(self): + err = _FakeVendorError("bad", 400, _response(400)) + tagged = as_uipath_error(err) + assert str(tagged).startswith("UiPathBadRequestError") + assert "Status Code: 400" in str(tagged) + + def test_tagged_class_is_cached(self): + a = as_uipath_error(_FakeVendorError("x", 400, _response(400))) + b = as_uipath_error(_FakeVendorError("y", 400, _response(400))) + assert type(a) is type(b) + + +class TestWrapProviderErrors: + def _raise(self, exc: Exception): + with wrap_provider_errors(): + raise exc + + def test_vendor_error_catchable_every_way(self): + for exc_type in ( + _FakeVendorError, + UiPathBadRequestError, + UiPathAPIError, + UiPathError, + ): + with pytest.raises(exc_type): + self._raise(_FakeVendorError("bad", 400, _response(400))) + + def test_uipath_error_passes_through_unchanged(self): + original = UiPathAPIError.from_response(_response(503)) + with pytest.raises(UiPathAPIError) as info: + self._raise(original) + assert info.value is original + + def test_builtin_exception_rebuilt_and_chained(self): + with pytest.raises(UiPathError) as info: + with wrap_provider_errors(): + raise ValueError("boom") + assert isinstance(info.value, ValueError) + assert isinstance(info.value.__cause__, ValueError) + assert info.value.__cause__ is not info.value + + def test_generator_exit_not_wrapped(self): + with pytest.raises(GeneratorExit): + with wrap_provider_errors(): + raise GeneratorExit() + + def test_keyboard_interrupt_not_wrapped(self): + with pytest.raises(KeyboardInterrupt): + with wrap_provider_errors(): + raise KeyboardInterrupt() diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py new file mode 100644 index 00000000..1100e526 --- /dev/null +++ b/tests/langchain/features/test_exception_wrapping.py @@ -0,0 +1,188 @@ +"""Tests that UiPathBaseChatModel re-tags provider SDK exceptions as UiPathError. + +A vendor error (e.g. ``openai.BadRequestError``) raised by a passthrough client +is re-tagged in place so callers can catch it as the original vendor type, as +the matching UiPath semantic subclass (``UiPathBadRequestError``), or as the +``UiPathError`` root -- across sync/async generate and streaming. +""" + +import json +import os +from typing import Any +from unittest.mock import patch + +import httpx +import openai +import pytest +from uipath_langchain_client.clients.normalized.chat_models import UiPathChat +from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + +from uipath.llm_client.settings import LLMGatewaySettings +from uipath.llm_client.settings.utils import SingletonMeta +from uipath.llm_client.utils.exceptions import ( + UiPathAPIError, + UiPathBadRequestError, + UiPathError, + UiPathRateLimitError, +) + +LLMGW_ENV = { + "LLMGW_URL": "https://cloud.uipath.com", + "LLMGW_SEMANTIC_ORG_ID": "test-org-id", + "LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id", + "LLMGW_REQUESTING_PRODUCT": "test-product", + "LLMGW_REQUESTING_FEATURE": "test-feature", + "LLMGW_ACCESS_TOKEN": "test-access-token", +} + + +@pytest.fixture(autouse=True) +def clear_singletons(): + SingletonMeta._instances.clear() + yield + SingletonMeta._instances.clear() + + +@pytest.fixture +def llmgw_settings(): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + return LLMGatewaySettings() + + +def _bad_request() -> openai.BadRequestError: + req = httpx.Request("POST", "https://example.com") + resp = httpx.Response(400, request=req, json={"error": {"message": "bad"}}) + return openai.BadRequestError("bad", response=resp, body={"error": {"message": "bad"}}) + + +class _RaisingChat(UiPathChat): + """Passthrough-style chat whose core methods raise a configured vendor error.""" + + boom: Any = None + + def _uipath_generate(self, messages, stop=None, run_manager=None, **kwargs): + raise self.boom + + async def _uipath_agenerate(self, messages, stop=None, run_manager=None, **kwargs): + raise self.boom + + def _uipath_stream(self, messages, stop=None, run_manager=None, **kwargs): + raise self.boom + yield # pragma: no cover - marks this a generator + + async def _uipath_astream(self, messages, stop=None, run_manager=None, **kwargs): + raise self.boom + yield # pragma: no cover - marks this an async generator + + +def _make_chat(settings: LLMGatewaySettings) -> _RaisingChat: + return _RaisingChat(model="gpt-4o", settings=settings, model_details={}, boom=_bad_request()) + + +CATCH_TYPES = [openai.BadRequestError, UiPathBadRequestError, UiPathAPIError, UiPathError] + + +class TestProviderErrorWrapping: + @pytest.mark.parametrize("exc_type", CATCH_TYPES) + def test_generate(self, llmgw_settings, exc_type): + chat = _make_chat(llmgw_settings) + with pytest.raises(exc_type): + chat.invoke("hi") + + @pytest.mark.parametrize("exc_type", CATCH_TYPES) + @pytest.mark.asyncio + async def test_agenerate(self, llmgw_settings, exc_type): + chat = _make_chat(llmgw_settings) + with pytest.raises(exc_type): + await chat.ainvoke("hi") + + @pytest.mark.parametrize("exc_type", CATCH_TYPES) + def test_stream(self, llmgw_settings, exc_type): + chat = _make_chat(llmgw_settings) + with pytest.raises(exc_type): + list(chat.stream("hi")) + + @pytest.mark.parametrize("exc_type", CATCH_TYPES) + @pytest.mark.asyncio + async def test_astream(self, llmgw_settings, exc_type): + chat = _make_chat(llmgw_settings) + with pytest.raises(exc_type): + async for _ in chat.astream("hi"): + pass + + def test_presents_as_uipath_type_but_catchable_as_vendor(self, llmgw_settings): + chat = _make_chat(llmgw_settings) + with pytest.raises(UiPathError) as info: + chat.invoke("hi") + exc = info.value + # The exception presents as the UiPath type... + assert type(exc).__name__ == "UiPathBadRequestError" + # ...while remaining catchable as the original vendor type. + assert isinstance(exc, openai.BadRequestError) + assert exc.status_code == 400 + + +# ============================================================================ +# End-to-end: the genuine openai SDK raises BadRequestError on a real 400, and +# UiPathBaseChatModel re-tags it so it is catchable as both types. +# ============================================================================ + +_OPENAI_400_BODY = { + "error": {"message": "Invalid 'messages'", "type": "invalid_request_error", "code": None} +} + + +def _openai_400_response(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + request=request, + content=json.dumps(_OPENAI_400_BODY).encode(), + headers={"content-type": "application/json"}, + ) + + +class _SyncMock400(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + return _openai_400_response(request) + + +class _AsyncMock400(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return _openai_400_response(request) + + +def _make_openai_chat_returning_400(settings: LLMGatewaySettings) -> UiPathChatOpenAI: + chat = UiPathChatOpenAI(model="gpt-4o-2024-11-20", settings=settings, model_details={}) + # The openai SDK uses chat.uipath_sync_client / uipath_async_client as its + # http_client (same object), so rerouting their transport makes the real SDK + # receive a 400 and raise a genuine openai.BadRequestError. + chat.uipath_sync_client._transport = _SyncMock400() # type: ignore[attr-defined] + chat.uipath_sync_client._mounts = {} # type: ignore[attr-defined] + chat.uipath_async_client._transport = _AsyncMock400() # type: ignore[attr-defined] + chat.uipath_async_client._mounts = {} # type: ignore[attr-defined] + return chat + + +class TestRealOpenAISDKWrapping: + def test_sync_invoke_catchable_both_ways(self, llmgw_settings): + chat = _make_openai_chat_returning_400(llmgw_settings) + with pytest.raises(openai.BadRequestError) as info: + chat.invoke("hi") + exc = info.value + assert isinstance(exc, UiPathBadRequestError) + assert isinstance(exc, UiPathAPIError) + assert isinstance(exc, UiPathError) + assert not isinstance(exc, UiPathRateLimitError) + assert exc.status_code == 400 + + def test_sync_invoke_catchable_as_uipath_semantic(self, llmgw_settings): + chat = _make_openai_chat_returning_400(llmgw_settings) + with pytest.raises(UiPathBadRequestError): + chat.invoke("hi") + + @pytest.mark.asyncio + async def test_async_invoke_catchable_both_ways(self, llmgw_settings): + chat = _make_openai_chat_returning_400(llmgw_settings) + with pytest.raises(UiPathBadRequestError) as info: + await chat.ainvoke("hi") + assert isinstance(info.value, openai.BadRequestError) From f73ee821a1dc81319bbb45a1df02fd7b051535e2 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 25 Jun 2026 18:20:51 +0300 Subject: [PATCH 2/3] refactor(exceptions): convert provider errors to pure UiPath types instead of merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the __class__-merge approach (a vendor error catchable as BOTH its native type and a UiPath type) with a single conversion. wrap_provider_errors now returns a pure UiPath exception: it walks the cause chain (__cause__/ __context__) for an httpx.Response and maps its status onto the matching UiPathAPIError subclass — so providers that wrap the response one level down (Google's ChatGoogleGenerativeAIError) map correctly too — and falls back to the UiPathError root when no response is present. The original provider error is preserved as __cause__. Raising early (at httpx send) was rejected: the openai/anthropic SDKs catch any send() exception and downgrade it to a context-less APIConnectionError, losing the status. Converting the SDK-native exception (which carries .response) keeps the status. Drops the synthesized-class machinery (_uipath_tagged_class / _uipath_base_for / as_uipath_error in-place re-tag) and the __init__-bypass defensive code in UiPathAPIError. patch_raise_for_status stays: it still serves the core normalized client, raw uipath_request/stream, and the Bedrock shim, none of which pass through wrap_provider_errors. Behavioral change: provider errors are no longer catchable as their vendor type (e.g. openai.RateLimitError); standardise handlers on UiPathError/subclasses. Tests: per-provider coverage (openai, anthropic, google, fireworks, bedrock, client-side validation) across generate/agenerate/stream/astream, plus the real-openai-SDK end-to-end. Full gate green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +- packages/uipath_langchain_client/CHANGELOG.md | 7 +- src/uipath/llm_client/utils/exceptions.py | 185 ++++++---------- .../core/features/test_exception_wrapping.py | 144 ++++++------- .../features/test_exception_wrapping.py | 199 +++++++++++++----- 5 files changed, 296 insertions(+), 249 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2d18d2..306e8a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,17 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. -## [1.15.0] - 2026-06-24 +## [1.15.0] - 2026-06-25 ### Added - `UiPathError` — a common root exception for everything the client can raise, re-exported from `uipath.llm_client`. `UiPathAPIError` (and therefore every status-specific subclass) now inherits from it, so `except UiPathError` is a single catch-all across all backends and providers. Fully backward compatible: `UiPathAPIError` still inherits `httpx.HTTPStatusError` and all existing handlers keep working. -- `as_uipath_error(exc)` and the `wrap_provider_errors()` context manager (in `uipath.llm_client.utils.exceptions`). They re-tag a provider/SDK exception in place so it is catchable as **both** its original type (e.g. `openai.BadRequestError`) and the matching UiPath type. When the error carries an `httpx.Response`, its status code is mapped onto the corresponding `UiPathAPIError` subclass (e.g. a 429 becomes a `UiPathRateLimitError`) so semantic handling works identically across providers; otherwise the error is tagged with the `UiPathError` root only. The re-tagged exception **presents as the UiPath type** — `type(exc).__name__` reads e.g. `UiPathBadRequestError` and reprs/tracebacks show it — while the original vendor type stays visible via the MRO (`isinstance`/`except` against it still succeed). Built-in exceptions whose layout forbids `__class__` reassignment are rebuilt and chained via `raise ... from`. +- `as_uipath_error(exc)` and the `wrap_provider_errors()` context manager (in `uipath.llm_client.utils.exceptions`). They convert a provider/SDK exception into the matching UiPath exception so callers handle one taxonomy regardless of which provider produced the error. The cause chain (`__cause__`/`__context__`) is walked for an `httpx.Response`; when found, its status code is mapped onto the corresponding `UiPathAPIError` subclass (a 429 → `UiPathRateLimitError`, a 400 → `UiPathBadRequestError`, …) so semantic handling is identical across providers — including providers (e.g. Google) that wrap the response-bearing error one level down. When no response is available anywhere in the chain (client-side validation errors, connection failures) the `UiPathError` root is returned. The original provider exception is preserved as `__cause__`. ### Changed -- `UiPathRateLimitError.retry_after` is now parsed lazily from `self.response` (via a property) instead of being cached in `__init__`, so the value is correct for both constructed and re-tagged instances. `_parse_retry_after` and the parsing behaviour are unchanged. -- `UiPathAPIError.__str__` / `__repr__` read `message`/`status_code`/`body` defensively so a re-tagged foreign exception without those attributes cannot raise `AttributeError`. Output is unchanged for normally-constructed instances. +- `UiPathRateLimitError.retry_after` is now parsed lazily from `self.response` (via a property) instead of being cached in `__init__`. `_parse_retry_after` and the parsing behaviour are unchanged. + +### Note +- Provider errors are surfaced as **pure** UiPath types: an `openai.RateLimitError` raised through a passthrough chat model becomes a `UiPathRateLimitError` and is **not** catchable as `openai.RateLimitError` (the vendor exception is kept as `__cause__`). Standardise handlers on `UiPathError` and its subclasses. ## [1.14.0] - 2026-06-15 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index af64352c..991543b6 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,14 +2,17 @@ All notable changes to `uipath_langchain_client` will be documented in this file. -## [1.15.0] - 2026-06-24 +## [1.15.0] - 2026-06-25 ### Added -- `UiPathBaseChatModel` now re-tags provider SDK exceptions as `UiPathError`. When any chat client (`UiPathChatOpenAI`, `UiPathChatAnthropic`, `UiPathChat`, Bedrock/Vertex/Google/LiteLLM/Fireworks, …) raises during `_generate`/`_agenerate`/`_stream`/`_astream`, the error is re-tagged in place so callers can catch it as **both** its original vendor type (e.g. `openai.BadRequestError`) and as a UiPath type. HTTP-shaped vendor errors map onto the matching semantic subclass (a 429 → `UiPathRateLimitError`, a 400 → `UiPathBadRequestError`, …), giving provider-agnostic error handling across every client; non-HTTP errors are tagged with the `UiPathError` root. `UiPathError` is re-exported from `uipath_langchain_client`. +- `UiPathBaseChatModel` now converts provider SDK exceptions into `UiPathError`. When any chat client (`UiPathChatOpenAI`, `UiPathChatAnthropic`, `UiPathChat`, Bedrock/Vertex/Google/LiteLLM/Fireworks, …) raises during `_generate`/`_agenerate`/`_stream`/`_astream`, the error is converted into the matching UiPath semantic subclass (a 429 → `UiPathRateLimitError`, a 400 → `UiPathBadRequestError`, …) — including providers like Google that wrap the response-bearing error one level down — giving provider-agnostic error handling across every client. Errors without an HTTP response (e.g. client-side validation) become the `UiPathError` root. `UiPathError` is re-exported from `uipath_langchain_client`. ### Changed - Bumped `uipath-llm-client` floor to `>=1.15.0` to pick up `UiPathError` and the `wrap_provider_errors` / `as_uipath_error` helpers. +### Note +- Provider errors are now surfaced as **pure** UiPath types and are **not** catchable as their original vendor type (e.g. `openai.BadRequestError`); the vendor exception is preserved as `__cause__`. Standardise handlers on `UiPathError` and its subclasses. + ## [1.14.1] - 2026-06-23 ### Fixed diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 8e32c7d7..bd7664e9 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -10,8 +10,9 @@ status-specific UiPath handlers, or by generic httpx error handlers. For the LangChain passthrough chat models, vendor SDK exceptions (e.g. -``openai.BadRequestError``) are additionally re-tagged via as_uipath_error so -they too are catchable as UiPathError without losing their original type. +``openai.BadRequestError``) are converted into the matching UiPath exception by +:func:`wrap_provider_errors`, so callers handle one taxonomy regardless of which +provider produced the error. The UiPathAPIError.from_response() factory method automatically creates the appropriate exception type based on the HTTP response status code. @@ -33,7 +34,7 @@ from collections.abc import Iterator from contextlib import contextmanager from json import JSONDecodeError -from typing import Literal, cast +from typing import Literal from httpx import HTTPStatusError, Request, Response @@ -41,23 +42,24 @@ class UiPathError(Exception): """Common base class for every error surfaced by the UiPath LLM client. - Two distinct kinds of error are catchable as ``UiPathError``: + Everything the client can raise is catchable as ``UiPathError``: * :class:`UiPathAPIError` and its status-specific subclasses, raised by the core HTTP client for non-2xx responses. - * Vendor SDK exceptions (e.g. ``openai.BadRequestError``) raised by the - LangChain passthrough chat models. These are re-tagged in place by - :func:`as_uipath_error` so they remain catchable as *both* their original - type and ``UiPathError``. + * Provider SDK exceptions (e.g. ``openai.BadRequestError``) raised by the + LangChain passthrough chat models, which :func:`wrap_provider_errors` + converts into the matching UiPath exception (mapping the HTTP status when + the error carries an ``httpx.Response``, else the ``UiPathError`` root). + The original provider exception is preserved as ``__cause__``. Catch ``UiPathError`` to handle any UiPath LLM failure regardless of which backend or provider produced it:: try: chat.invoke(...) - except openai.BadRequestError: # provider-specific handling still works - ... - except UiPathError: # catch-all across every provider + except UiPathRateLimitError as e: # same semantic class for every provider + backoff(e.retry_after) + except UiPathError: # catch-all across every provider ... """ @@ -92,24 +94,16 @@ def __init__( self.message = message self.body = body - def _message(self) -> object: - # ``message`` is set by __init__, but a vendor error re-tagged into this - # class via as_uipath_error() bypasses __init__ — fall back to args. - message = getattr(self, "message", None) - if message is None: - message = self.args[0] if self.args else "" - return message - def __str__(self) -> str: return ( - f"{self.__class__.__name__}: {self._message()} " - f"(Status Code: {getattr(self, 'status_code', None)}) {getattr(self, 'body', None)}" + f"{self.__class__.__name__}: {self.message} " + f"(Status Code: {self.status_code}) {self.body}" ) def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(message={self._message()!r}, " - f"status_code={getattr(self, 'status_code', None)}, body={getattr(self, 'body', None)!r})" + f"{self.__class__.__name__}(message={self.message!r}, " + f"status_code={self.status_code}, body={self.body!r})" ) @classmethod @@ -202,9 +196,8 @@ class UiPathRateLimitError(UiPathAPIError): def retry_after(self) -> float | None: """Get the retry-after value in seconds, if available. - Parsed lazily from ``self.response`` so the value is correct both for - instances built via ``__init__`` and for vendor errors re-tagged into - this class by :func:`as_uipath_error` (which bypasses ``__init__``). + Parsed lazily from ``self.response`` (the Retry-After / x-retry-after + header). """ response = getattr(self, "response", None) if not isinstance(response, Response): @@ -318,112 +311,70 @@ def raise_for_status() -> Response: return response -# Cache of (original exception class, UiPath base) -> synthesized subclass. -# Tagged classes are generated once per (original type, base) pair so -# ``isinstance`` results are stable and we never leak a new class per raised -# exception. -_uipath_tagged_classes: dict[tuple[type[Exception], type[Exception]], type[Exception]] = {} - +def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]: + """Yield ``exc`` then its ``__cause__``/``__context__`` ancestors, once each. -def _uipath_tagged_class(cls: type[Exception], base: type[Exception]) -> type[Exception]: - """Return a cached ``(base, cls)`` subclass for ``cls``. - - The synthesized class presents as the UiPath ``base``: it takes ``base``'s - ``__name__`` and ``__module__`` (so ``type(exc).__name__`` reads e.g. - ``UiPathBadRequestError`` in reprs and tracebacks) while remaining a subclass - of the original vendor exception ``cls`` -- so it stays catchable as both. - The original vendor type remains visible via ``__bases__`` / the MRO. + Providers wrap the underlying error at different depths: openai/anthropic + raise an error that carries the ``httpx.Response`` directly, while + langchain-google re-raises ``ChatGoogleGenerativeAIError`` ``from`` the + underlying ``google.genai`` error (which holds the response). Walking the + chain lets a single rule recover the HTTP status from either shape. """ - key = (cls, base) - tagged = _uipath_tagged_classes.get(key) - if tagged is None: - tagged = cast( - "type[Exception]", - type(base.__name__, (base, cls), {"__module__": base.__module__}), - ) - _uipath_tagged_classes[key] = tagged - return tagged - - -def _uipath_base_for(exc: Exception) -> type[Exception]: - """Pick the UiPath base class to tag ``exc`` with. - - When ``exc`` carries a real ``httpx.Response`` (openai, anthropic, httpx, - litellm, fireworks, ...), map its HTTP status code onto the matching - :class:`UiPathAPIError` subclass so the error is catchable by its semantic - UiPath type (e.g. ``UiPathRateLimitError``) across every provider. An - unmapped status still becomes a generic :class:`UiPathAPIError`. - - When no httpx response is available (botocore, google, plain exceptions), we - cannot safely claim HTTP semantics, so we tag with the :class:`UiPathError` - root marker only — still catchable as ``UiPathError`` plus the original type. - """ - response = getattr(exc, "response", None) - if not isinstance(response, Response): - return UiPathError - status_code = getattr(exc, "status_code", None) - if not isinstance(status_code, int): - status_code = response.status_code - return _STATUS_CODE_TO_EXCEPTION.get(status_code, UiPathAPIError) - - -def as_uipath_error(exc: Exception) -> Exception: - """Tag ``exc`` so it is catchable as both its original type and ``UiPathError``. - - The tag is chosen by :func:`_uipath_base_for`: an HTTP-shaped vendor error is - tagged with the matching :class:`UiPathAPIError` subclass (so a provider's - 429 becomes a ``UiPathRateLimitError``, a 400 a ``UiPathBadRequestError``, - etc.), while anything else is tagged with the :class:`UiPathError` root. - - For mutable exception types (all vendor SDK exceptions: openai, anthropic, - botocore, google, httpx) the instance is re-tagged in place via ``__class__`` - assignment, preserving its message, attributes and traceback. The returned - object *is* ``exc``. - - For immutable built-in exception types (e.g. ``ValueError``) whose memory - layout forbids ``__class__`` reassignment, a fresh instance of the tagged - subclass is rebuilt from ``exc.args`` and returned instead; callers should - chain it with ``raise ... from exc``. If the rebuild fails, a plain - :class:`UiPathError` carrying the original message is returned as a last - resort. - - ``UiPathError`` instances (including :class:`UiPathAPIError`) are returned - unchanged. + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ or current.__context__ + + +def as_uipath_error(exc: Exception) -> UiPathError: + """Convert a provider/SDK exception into the matching UiPath exception. + + Walks ``exc`` and its cause chain for an ``httpx.Response``. When one is + found, its status code is mapped onto the matching :class:`UiPathAPIError` + subclass (a provider's 429 becomes a :class:`UiPathRateLimitError`, a 400 a + :class:`UiPathBadRequestError`, …) so semantic handling is identical across + providers; an unmapped status becomes a generic :class:`UiPathAPIError`. + + When no response is available anywhere in the chain (client-side validation + errors, connection failures, plain exceptions) we cannot claim HTTP + semantics, so the :class:`UiPathError` root is returned — still catchable as + ``UiPathError``. ``UiPathError`` instances are returned unchanged. + + The returned exception is a *new* object; callers should chain it to the + original via ``raise ... from exc`` to preserve the provider error as + ``__cause__``. """ if isinstance(exc, UiPathError): return exc - base = _uipath_base_for(exc) - tagged_cls = _uipath_tagged_class(type(exc), base) - try: - exc.__class__ = tagged_cls - return exc - except TypeError: - try: - return tagged_cls(*exc.args) - except Exception: - return UiPathError(str(exc)) + for err in _iter_error_chain(exc): + response = getattr(err, "response", None) + if isinstance(response, Response): + return UiPathAPIError.from_response(response) + return UiPathError(str(exc)) @contextmanager def wrap_provider_errors() -> Iterator[None]: - """Re-raise provider/SDK exceptions tagged as :class:`UiPathError`. - - Any ``Exception`` raised inside the ``with`` block is re-tagged via - :func:`as_uipath_error` so callers can catch it as either its original type - (e.g. ``openai.BadRequestError``) or ``UiPathError``. ``UiPathError`` - instances pass through untouched, and non-``Exception`` ``BaseException`` - subclasses (``GeneratorExit``, ``KeyboardInterrupt``, ``SystemExit``) are - never wrapped. + """Convert provider/SDK exceptions into UiPath exceptions. + + Any ``Exception`` raised inside the ``with`` block is converted via + :func:`as_uipath_error` into the matching :class:`UiPathAPIError` subclass + (or the :class:`UiPathError` root when no HTTP response is available) and + re-raised, chained to the original via ``raise ... from``. + + ``UiPathError`` instances pass through untouched — the core HTTP client and + the Bedrock shim already raise them, so there is a single conversion point. + Non-``Exception`` ``BaseException`` subclasses (``GeneratorExit``, + ``KeyboardInterrupt``, ``SystemExit``) are never wrapped. """ try: yield except UiPathError: raise except Exception as exc: - tagged = as_uipath_error(exc) - if tagged is exc: - raise - raise tagged from exc + raise as_uipath_error(exc) from exc __all__ = [ diff --git a/tests/core/features/test_exception_wrapping.py b/tests/core/features/test_exception_wrapping.py index faa5dd93..e08a2b5d 100644 --- a/tests/core/features/test_exception_wrapping.py +++ b/tests/core/features/test_exception_wrapping.py @@ -1,8 +1,8 @@ """Tests for the UiPathError root and as_uipath_error / wrap_provider_errors. -These cover the unified-taxonomy behaviour: a provider error is re-tagged so it -is catchable as its original type, as the matching UiPath semantic subclass, and -as the UiPathError root. +These cover the unified-taxonomy behaviour: a provider error is converted into +the matching UiPath exception (status mapped from any ``httpx.Response`` in the +cause chain) and the original provider error is preserved as ``__cause__``. """ import pytest @@ -50,7 +50,6 @@ def test_api_error_still_httpx(self): assert issubclass(UiPathAPIError, HTTPStatusError) def test_rate_limit_retry_after_lazy_from_response(self): - # No custom __init__ anymore: retry_after is parsed from self.response. exc = UiPathRateLimitError( "slow down", request=Request("POST", "https://example.com"), @@ -60,78 +59,78 @@ def test_rate_limit_retry_after_lazy_from_response(self): class TestAsUiPathError: - def test_maps_429_to_rate_limit_in_place(self): + def test_maps_429_to_rate_limit(self): err = _FakeVendorError("slow", 429, _response(429, {"retry-after": "12"})) - tagged = as_uipath_error(err) - assert tagged is err # re-tagged in place, same object - assert isinstance(tagged, UiPathRateLimitError) - assert isinstance(tagged, _FakeVendorError) - assert isinstance(tagged, UiPathAPIError) - assert isinstance(tagged, UiPathError) - assert tagged.status_code == 429 - assert tagged.retry_after == 12.0 + converted = as_uipath_error(err) + assert type(converted) is UiPathRateLimitError + assert isinstance(converted, UiPathAPIError) + assert isinstance(converted, UiPathError) + assert converted.status_code == 429 + assert converted.retry_after == 12.0 def test_maps_400_to_bad_request(self): - err = _FakeVendorError("bad", 400, _response(400)) - tagged = as_uipath_error(err) - assert isinstance(tagged, UiPathBadRequestError) - assert isinstance(tagged, _FakeVendorError) + converted = as_uipath_error(_FakeVendorError("bad", 400, _response(400))) + assert type(converted) is UiPathBadRequestError def test_maps_401_to_authentication(self): - err = _FakeVendorError("no", 401, _response(401)) - tagged = as_uipath_error(err) - assert isinstance(tagged, UiPathAuthenticationError) + converted = as_uipath_error(_FakeVendorError("no", 401, _response(401))) + assert type(converted) is UiPathAuthenticationError + + def test_result_is_pure_uipath_not_vendor_type(self): + err = _FakeVendorError("bad", 400, _response(400)) + converted = as_uipath_error(err) + # The conversion drops the vendor lineage entirely. + assert not isinstance(converted, _FakeVendorError) + assert _FakeVendorError not in type(converted).__mro__ + assert converted is not err def test_unmapped_status_becomes_generic_api_error(self): - err = _FakeVendorError("teapot", 418, _response(418)) - tagged = as_uipath_error(err) - assert type(tagged) is not UiPathAPIError # it's the tagged subclass - assert isinstance(tagged, UiPathAPIError) - assert not isinstance(tagged, UiPathBadRequestError) - assert tagged.status_code == 418 - - def test_no_http_response_gets_root_marker_only(self): + converted = as_uipath_error(_FakeVendorError("teapot", 418, _response(418))) + assert type(converted) is UiPathAPIError + assert not isinstance(converted, UiPathBadRequestError) + assert converted.status_code == 418 + + def test_status_recovered_from_cause_chain(self): + # google shape: a wrapper raised ``from`` the response-bearing error. + inner = _FakeVendorError("bad", 400, _response(400)) + outer = RuntimeError("Error calling model") + outer.__cause__ = inner + converted = as_uipath_error(outer) + assert type(converted) is UiPathBadRequestError + assert converted.status_code == 400 + + def test_status_recovered_from_context_chain(self): + inner = _FakeVendorError("bad", 401, _response(401)) + outer = RuntimeError("wrapped") + outer.__context__ = inner + assert type(as_uipath_error(outer)) is UiPathAuthenticationError + + def test_no_http_response_gets_root_only(self): class _NoResponseError(Exception): pass - err = _NoResponseError("boom") - tagged = as_uipath_error(err) - assert isinstance(tagged, UiPathError) - assert isinstance(tagged, _NoResponseError) - assert not isinstance(tagged, UiPathAPIError) + converted = as_uipath_error(_NoResponseError("boom")) + assert type(converted) is UiPathError + assert not isinstance(converted, UiPathAPIError) + + def test_non_httpx_response_attribute_ignored(self): + # A ``.response`` that is not an httpx.Response (e.g. botocore's dict) + # must not be mistaken for one. + class _BotoLike(Exception): + response = {"ResponseMetadata": {"HTTPStatusCode": 400}} + + converted = as_uipath_error(_BotoLike("boom")) + assert type(converted) is UiPathError def test_existing_uipath_error_passthrough(self): existing = UiPathAPIError.from_response(_response(500)) assert as_uipath_error(existing) is existing - def test_presents_as_uipath_type_but_keeps_vendor_type(self): - err = _FakeVendorError("bad", 400, _response(400)) - tagged = as_uipath_error(err) - # Presents as the UiPath semantic type... - assert type(tagged).__name__ == "UiPathBadRequestError" - assert type(tagged).__module__ == UiPathBadRequestError.__module__ - # ...while still being the original vendor type. - assert isinstance(tagged, _FakeVendorError) - assert _FakeVendorError in type(tagged).__mro__ - - def test_root_marker_presents_as_uipath_error(self): - class _NoResponseError(Exception): - pass - - tagged = as_uipath_error(_NoResponseError("boom")) - assert type(tagged).__name__ == "UiPathError" - assert isinstance(tagged, _NoResponseError) - - def test_str_uses_uipath_format_after_tag(self): - err = _FakeVendorError("bad", 400, _response(400)) - tagged = as_uipath_error(err) - assert str(tagged).startswith("UiPathBadRequestError") - assert "Status Code: 400" in str(tagged) - - def test_tagged_class_is_cached(self): - a = as_uipath_error(_FakeVendorError("x", 400, _response(400))) - b = as_uipath_error(_FakeVendorError("y", 400, _response(400))) - assert type(a) is type(b) + def test_presents_as_uipath_type(self): + converted = as_uipath_error(_FakeVendorError("bad", 400, _response(400))) + assert type(converted).__name__ == "UiPathBadRequestError" + assert str(converted).startswith("UiPathBadRequestError") + assert "Status Code: 400" in str(converted) class TestWrapProviderErrors: @@ -139,29 +138,32 @@ def _raise(self, exc: Exception): with wrap_provider_errors(): raise exc - def test_vendor_error_catchable_every_way(self): - for exc_type in ( - _FakeVendorError, - UiPathBadRequestError, - UiPathAPIError, - UiPathError, - ): + def test_vendor_error_converted_and_catchable_as_uipath(self): + for exc_type in (UiPathBadRequestError, UiPathAPIError, UiPathError): with pytest.raises(exc_type): self._raise(_FakeVendorError("bad", 400, _response(400))) + def test_vendor_error_not_catchable_as_vendor_type(self): + with pytest.raises(UiPathBadRequestError) as info: + self._raise(_FakeVendorError("bad", 400, _response(400))) + assert not isinstance(info.value, _FakeVendorError) + # Original provider error is preserved as the cause. + assert isinstance(info.value.__cause__, _FakeVendorError) + def test_uipath_error_passes_through_unchanged(self): original = UiPathAPIError.from_response(_response(503)) with pytest.raises(UiPathAPIError) as info: self._raise(original) assert info.value is original + assert info.value.__cause__ is None - def test_builtin_exception_rebuilt_and_chained(self): + def test_builtin_exception_becomes_root_and_chained(self): with pytest.raises(UiPathError) as info: with wrap_provider_errors(): raise ValueError("boom") - assert isinstance(info.value, ValueError) + assert type(info.value) is UiPathError + assert not isinstance(info.value, ValueError) assert isinstance(info.value.__cause__, ValueError) - assert info.value.__cause__ is not info.value def test_generator_exit_not_wrapped(self): with pytest.raises(GeneratorExit): diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py index 1100e526..b3f8032a 100644 --- a/tests/langchain/features/test_exception_wrapping.py +++ b/tests/langchain/features/test_exception_wrapping.py @@ -1,19 +1,24 @@ -"""Tests that UiPathBaseChatModel re-tags provider SDK exceptions as UiPathError. - -A vendor error (e.g. ``openai.BadRequestError``) raised by a passthrough client -is re-tagged in place so callers can catch it as the original vendor type, as -the matching UiPath semantic subclass (``UiPathBadRequestError``), or as the -``UiPathError`` root -- across sync/async generate and streaming. +"""Tests that UiPathBaseChatModel converts provider SDK exceptions to UiPath errors. + +A provider error (e.g. ``openai.BadRequestError``) raised by a passthrough client +is converted into the matching UiPath semantic subclass +(``UiPathBadRequestError``, ``UiPathRateLimitError``, …) — across sync/async +generate and streaming, for every provider. The result is a *pure* UiPath +exception (no vendor lineage); the original provider error is preserved as +``__cause__``. """ import json import os -from typing import Any +from typing import Any, Callable from unittest.mock import patch +import anthropic import httpx import openai import pytest +from google.genai.errors import ClientError as GenAIClientError +from langchain_google_genai.chat_models import ChatGoogleGenerativeAIError from uipath_langchain_client.clients.normalized.chat_models import UiPathChat from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI @@ -21,8 +26,11 @@ from uipath.llm_client.settings.utils import SingletonMeta from uipath.llm_client.utils.exceptions import ( UiPathAPIError, + UiPathAuthenticationError, UiPathBadRequestError, UiPathError, + UiPathInternalServerError, + UiPathPermissionDeniedError, UiPathRateLimitError, ) @@ -49,14 +57,74 @@ def llmgw_settings(): return LLMGatewaySettings() -def _bad_request() -> openai.BadRequestError: - req = httpx.Request("POST", "https://example.com") - resp = httpx.Response(400, request=req, json={"error": {"message": "bad"}}) - return openai.BadRequestError("bad", response=resp, body={"error": {"message": "bad"}}) +def _resp(status: int, headers: dict[str, str] | None = None) -> httpx.Response: + return httpx.Response( + status, + request=httpx.Request("POST", "https://example.com"), + headers=headers or {}, + json={"error": {"message": "boom"}}, + ) + + +# --- builders for each provider's *native* exception shape ------------------ + + +def _openai_exc(exc_cls: type[openai.APIStatusError], status: int, headers=None): + return lambda: exc_cls("boom", response=_resp(status, headers), body=None) + + +def _anthropic_exc(exc_cls: type[anthropic.APIStatusError], status: int): + return lambda: exc_cls("boom", response=_resp(status), body=None) + + +def _google_exc(status: int, headers=None): + """langchain-google raises ChatGoogleGenerativeAIError *from* a genai error + that holds the httpx response.""" + + def build(): + cause = GenAIClientError(status, {"error": {"code": status}}, _resp(status, headers)) + err = ChatGoogleGenerativeAIError(f"Error calling model: {status}") + err.__cause__ = cause + return err + + return build + + +def _bedrock_exc(status: int): + """The Bedrock shim's patched raise_for_status already yields a pure + UiPathAPIError, so the chat model receives a UiPathError directly.""" + return lambda: UiPathAPIError.from_response(_resp(status)) + + +# (provider, builds the native exc, expected pure UiPath type, already_uipath) +PROVIDER_CASES: list[tuple[str, Callable[[], Exception], type[UiPathError], bool]] = [ + ("openai", _openai_exc(openai.BadRequestError, 400), UiPathBadRequestError, False), + ( + "openai", + _openai_exc(openai.RateLimitError, 429, {"retry-after": "5"}), + UiPathRateLimitError, + False, + ), + ("anthropic", _anthropic_exc(anthropic.BadRequestError, 400), UiPathBadRequestError, False), + ( + "anthropic", + _anthropic_exc(anthropic.InternalServerError, 500), + UiPathInternalServerError, + False, + ), + ("google", _google_exc(400), UiPathBadRequestError, False), + ("google", _google_exc(429, {"retry-after": "9"}), UiPathRateLimitError, False), + # litellm / fireworks surface openai-style errors + ("fireworks", _openai_exc(openai.AuthenticationError, 401), UiPathAuthenticationError, False), + # bedrock: the shim already raised a pure UiPath error + ("bedrock", _bedrock_exc(403), UiPathPermissionDeniedError, True), +] + +CASE_IDS = [f"{name}-{exp.__name__}" for name, _, exp, _ in PROVIDER_CASES] class _RaisingChat(UiPathChat): - """Passthrough-style chat whose core methods raise a configured vendor error.""" + """Passthrough-style chat whose core methods raise a configured provider error.""" boom: Any = None @@ -75,56 +143,79 @@ async def _uipath_astream(self, messages, stop=None, run_manager=None, **kwargs) yield # pragma: no cover - marks this an async generator -def _make_chat(settings: LLMGatewaySettings) -> _RaisingChat: - return _RaisingChat(model="gpt-4o", settings=settings, model_details={}, boom=_bad_request()) - +def _make_chat(settings: LLMGatewaySettings, boom: Exception) -> _RaisingChat: + return _RaisingChat(model="gpt-4o", settings=settings, model_details={}, boom=boom) -CATCH_TYPES = [openai.BadRequestError, UiPathBadRequestError, UiPathAPIError, UiPathError] - -class TestProviderErrorWrapping: - @pytest.mark.parametrize("exc_type", CATCH_TYPES) - def test_generate(self, llmgw_settings, exc_type): - chat = _make_chat(llmgw_settings) - with pytest.raises(exc_type): +class TestProviderErrorConversion: + @pytest.mark.parametrize("name,build,expected,already", PROVIDER_CASES, ids=CASE_IDS) + def test_generate(self, llmgw_settings, name, build, expected, already): + native = build() + chat = _make_chat(llmgw_settings, native) + with pytest.raises(expected) as info: chat.invoke("hi") - - @pytest.mark.parametrize("exc_type", CATCH_TYPES) + exc = info.value + assert type(exc) is expected + assert isinstance(exc, UiPathError) + if already: + # bedrock: the shim's UiPathError passes through untouched + assert exc is native + else: + # the result is a *pure* UiPath error, original kept as cause + assert exc.__cause__ is native + + @pytest.mark.parametrize("name,build,expected,already", PROVIDER_CASES, ids=CASE_IDS) @pytest.mark.asyncio - async def test_agenerate(self, llmgw_settings, exc_type): - chat = _make_chat(llmgw_settings) - with pytest.raises(exc_type): + async def test_agenerate(self, llmgw_settings, name, build, expected, already): + chat = _make_chat(llmgw_settings, build()) + with pytest.raises(expected): await chat.ainvoke("hi") - @pytest.mark.parametrize("exc_type", CATCH_TYPES) - def test_stream(self, llmgw_settings, exc_type): - chat = _make_chat(llmgw_settings) - with pytest.raises(exc_type): + @pytest.mark.parametrize("name,build,expected,already", PROVIDER_CASES, ids=CASE_IDS) + def test_stream(self, llmgw_settings, name, build, expected, already): + chat = _make_chat(llmgw_settings, build()) + with pytest.raises(expected): list(chat.stream("hi")) - @pytest.mark.parametrize("exc_type", CATCH_TYPES) + @pytest.mark.parametrize("name,build,expected,already", PROVIDER_CASES, ids=CASE_IDS) @pytest.mark.asyncio - async def test_astream(self, llmgw_settings, exc_type): - chat = _make_chat(llmgw_settings) - with pytest.raises(exc_type): + async def test_astream(self, llmgw_settings, name, build, expected, already): + chat = _make_chat(llmgw_settings, build()) + with pytest.raises(expected): async for _ in chat.astream("hi"): pass - def test_presents_as_uipath_type_but_catchable_as_vendor(self, llmgw_settings): - chat = _make_chat(llmgw_settings) + def test_no_vendor_lineage(self, llmgw_settings): + """Converted errors are not catchable as their original vendor type.""" + chat = _make_chat( + llmgw_settings, openai.BadRequestError("bad", response=_resp(400), body=None) + ) + with pytest.raises(UiPathBadRequestError) as info: + chat.invoke("hi") + assert not isinstance(info.value, openai.APIError) + + def test_rate_limit_retry_after_preserved(self, llmgw_settings): + chat = _make_chat( + llmgw_settings, + openai.RateLimitError("slow", response=_resp(429, {"retry-after": "5"}), body=None), + ) + with pytest.raises(UiPathRateLimitError) as info: + chat.invoke("hi") + assert info.value.retry_after == 5.0 + + def test_client_side_validation_error_becomes_root(self, llmgw_settings): + """A non-HTTP error (e.g. a pydantic/validation error) maps to the root.""" + chat = _make_chat(llmgw_settings, ValueError("max_tokens not permitted")) with pytest.raises(UiPathError) as info: chat.invoke("hi") - exc = info.value - # The exception presents as the UiPath type... - assert type(exc).__name__ == "UiPathBadRequestError" - # ...while remaining catchable as the original vendor type. - assert isinstance(exc, openai.BadRequestError) - assert exc.status_code == 400 + assert type(info.value) is UiPathError + assert not isinstance(info.value, UiPathAPIError) + assert isinstance(info.value.__cause__, ValueError) # ============================================================================ # End-to-end: the genuine openai SDK raises BadRequestError on a real 400, and -# UiPathBaseChatModel re-tags it so it is catchable as both types. +# UiPathBaseChatModel converts it into a pure UiPathBadRequestError. # ============================================================================ _OPENAI_400_BODY = { @@ -163,26 +254,24 @@ def _make_openai_chat_returning_400(settings: LLMGatewaySettings) -> UiPathChatO return chat -class TestRealOpenAISDKWrapping: - def test_sync_invoke_catchable_both_ways(self, llmgw_settings): +class TestRealOpenAISDKConversion: + def test_sync_invoke_is_pure_uipath(self, llmgw_settings): chat = _make_openai_chat_returning_400(llmgw_settings) - with pytest.raises(openai.BadRequestError) as info: + with pytest.raises(UiPathBadRequestError) as info: chat.invoke("hi") exc = info.value - assert isinstance(exc, UiPathBadRequestError) assert isinstance(exc, UiPathAPIError) assert isinstance(exc, UiPathError) assert not isinstance(exc, UiPathRateLimitError) + assert not isinstance(exc, openai.APIError) # vendor lineage dropped assert exc.status_code == 400 - - def test_sync_invoke_catchable_as_uipath_semantic(self, llmgw_settings): - chat = _make_openai_chat_returning_400(llmgw_settings) - with pytest.raises(UiPathBadRequestError): - chat.invoke("hi") + # the genuine openai error is preserved as the cause + assert isinstance(exc.__cause__, openai.BadRequestError) @pytest.mark.asyncio - async def test_async_invoke_catchable_both_ways(self, llmgw_settings): + async def test_async_invoke_is_pure_uipath(self, llmgw_settings): chat = _make_openai_chat_returning_400(llmgw_settings) with pytest.raises(UiPathBadRequestError) as info: await chat.ainvoke("hi") - assert isinstance(info.value, openai.BadRequestError) + assert not isinstance(info.value, openai.APIError) + assert info.value.status_code == 400 From 01eb4ae61caa4e682afc93d3b3970023e8ffaa88 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Fri, 26 Jun 2026 17:13:38 +0300 Subject: [PATCH 3/3] refactor(exceptions): route patch_raise_for_status through wrap_provider_errors Direct raise_for_status() callers (core normalized client, raw uipath_request/ stream, the Bedrock shim) now share the single conversion path with provider SDK exceptions instead of calling from_response inline. The raised UiPathAPIError now carries the original httpx HTTPStatusError as __cause__. Status mapping is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + src/uipath/llm_client/utils/exceptions.py | 14 ++++++++++---- tests/core/features/test_exceptions.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306e8a68..7e99dad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `uipath_llm_client` (core package) will be documented in ### Changed - `UiPathRateLimitError.retry_after` is now parsed lazily from `self.response` (via a property) instead of being cached in `__init__`. `_parse_retry_after` and the parsing behaviour are unchanged. +- `patch_raise_for_status` now routes the httpx `HTTPStatusError` through `wrap_provider_errors`, so direct `raise_for_status()` callers and provider SDK exceptions share a single conversion path. The raised `UiPathAPIError` now carries the original `HTTPStatusError` as `__cause__` (previously `None`); status mapping is unchanged. ### Note - Provider errors are surfaced as **pure** UiPath types: an `openai.RateLimitError` raised through a passthrough chat model becomes a `UiPathRateLimitError` and is **not** catchable as `openai.RateLimitError` (the vendor exception is kept as `__cause__`). Standardise handlers on `UiPathError` and its subclasses. diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index bd7664e9..63ee37a4 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -297,14 +297,20 @@ class UiPathTooManyRequestsError(UiPathAPIError): def patch_raise_for_status(response: Response) -> Response: - """Patch response.raise_for_status() to raise UiPath-specific exceptions.""" + """Patch response.raise_for_status() to raise UiPath-specific exceptions. + + The httpx ``HTTPStatusError`` is routed through :func:`wrap_provider_errors` + so direct ``raise_for_status()`` callers (the core normalized client, the + raw ``uipath_request``/``uipath_stream`` API, the Bedrock shim) go through + the *same* conversion and status mapping as provider SDK exceptions — a + single entry point. The original ``HTTPStatusError`` is preserved as + ``__cause__``. + """ original_raise_for_status = response.raise_for_status def raise_for_status() -> Response: - try: + with wrap_provider_errors(): original_raise_for_status() - except HTTPStatusError: - raise UiPathAPIError.from_response(response) return response response.raise_for_status = raise_for_status diff --git a/tests/core/features/test_exceptions.py b/tests/core/features/test_exceptions.py index 3f7b71a0..823f5048 100644 --- a/tests/core/features/test_exceptions.py +++ b/tests/core/features/test_exceptions.py @@ -347,3 +347,17 @@ def test_patched_replaces_original_method(self): patched = patch_raise_for_status(mock_resp) assert patched.raise_for_status is not original + + def test_patched_preserves_original_httpstatuserror_as_cause(self): + """Routing through wrap_provider_errors keeps the httpx error as __cause__.""" + from httpx import HTTPStatusError, Request, Response + + req = Request("GET", "https://example.com") + resp = Response(404, request=req, json={"error": "nope"}) + original = MagicMock(side_effect=HTTPStatusError("err", request=req, response=resp)) + resp.raise_for_status = original # type: ignore[method-assign] + + patch_raise_for_status(resp) + with pytest.raises(UiPathNotFoundError) as exc_info: + resp.raise_for_status() + assert isinstance(exc_info.value.__cause__, HTTPStatusError)