diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9894dd..7e99dad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [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 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__`. `_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. + ## [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..991543b6 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.15.0] - 2026-06-25 + +### Added +- `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/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..63ee37a4 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -5,8 +5,14 @@ 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 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. @@ -25,16 +31,45 @@ ... 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 httpx import HTTPStatusError, Request, Response -class UiPathAPIError(HTTPStatusError): +class UiPathError(Exception): + """Common base class for every error surfaced by the UiPath LLM client. + + 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. + * 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 UiPathRateLimitError as e: # same semantic class for every provider + backoff(e.retry_after) + 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). @@ -60,10 +95,16 @@ def __init__( self.body = body 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: {self.status_code}) {self.body}" + ) 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={self.status_code}, body={self.body!r})" + ) @classmethod def from_response(cls, response: Response, request: Request | None = None) -> "UiPathAPIError": @@ -151,21 +192,17 @@ 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`` (the Retry-After / x-retry-after + header). + """ + 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: @@ -260,21 +297,94 @@ 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 return response +def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]: + """Yield ``exc`` then its ``__cause__``/``__context__`` ancestors, once each. + + 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. + """ + 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 + 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]: + """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: + raise as_uipath_error(exc) from exc + + __all__ = [ + "UiPathError", "UiPathAPIError", "UiPathBadRequestError", "UiPathAuthenticationError", @@ -290,4 +400,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..e08a2b5d --- /dev/null +++ b/tests/core/features/test_exception_wrapping.py @@ -0,0 +1,176 @@ +"""Tests for the UiPathError root and as_uipath_error / wrap_provider_errors. + +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 +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): + 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(self): + err = _FakeVendorError("slow", 429, _response(429, {"retry-after": "12"})) + 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): + converted = as_uipath_error(_FakeVendorError("bad", 400, _response(400))) + assert type(converted) is UiPathBadRequestError + + def test_maps_401_to_authentication(self): + 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): + 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 + + 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(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: + def _raise(self, exc: Exception): + with wrap_provider_errors(): + raise exc + + 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_becomes_root_and_chained(self): + with pytest.raises(UiPathError) as info: + with wrap_provider_errors(): + raise ValueError("boom") + assert type(info.value) is UiPathError + assert not isinstance(info.value, ValueError) + assert isinstance(info.value.__cause__, ValueError) + + 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/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) diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py new file mode 100644 index 00000000..b3f8032a --- /dev/null +++ b/tests/langchain/features/test_exception_wrapping.py @@ -0,0 +1,277 @@ +"""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, 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 + +from uipath.llm_client.settings import LLMGatewaySettings +from uipath.llm_client.settings.utils import SingletonMeta +from uipath.llm_client.utils.exceptions import ( + UiPathAPIError, + UiPathAuthenticationError, + UiPathBadRequestError, + UiPathError, + UiPathInternalServerError, + UiPathPermissionDeniedError, + 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 _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 provider 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, boom: Exception) -> _RaisingChat: + return _RaisingChat(model="gpt-4o", settings=settings, model_details={}, boom=boom) + + +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") + 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, name, build, expected, already): + chat = _make_chat(llmgw_settings, build()) + with pytest.raises(expected): + await chat.ainvoke("hi") + + @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("name,build,expected,already", PROVIDER_CASES, ids=CASE_IDS) + @pytest.mark.asyncio + 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_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") + 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 converts it into a pure UiPathBadRequestError. +# ============================================================================ + +_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 TestRealOpenAISDKConversion: + def test_sync_invoke_is_pure_uipath(self, llmgw_settings): + chat = _make_openai_chat_returning_400(llmgw_settings) + with pytest.raises(UiPathBadRequestError) as info: + chat.invoke("hi") + exc = info.value + 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 + # the genuine openai error is preserved as the cause + assert isinstance(exc.__cause__, openai.BadRequestError) + + @pytest.mark.asyncio + 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 not isinstance(info.value, openai.APIError) + assert info.value.status_code == 400