Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,4 +53,5 @@
"get_default_client_settings",
"LLMGatewaySettings",
"PlatformSettings",
"UiPathError",
]
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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({})

Expand Down Expand Up @@ -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({})

Expand Down
2 changes: 2 additions & 0 deletions src/uipath/llm_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
UiPathBadGatewayError,
UiPathBadRequestError,
UiPathConflictError,
UiPathError,
UiPathGatewayTimeoutError,
UiPathInternalServerError,
UiPathNotFoundError,
Expand Down Expand Up @@ -69,6 +70,7 @@
# Retry
"RetryConfig",
# Exceptions
"UiPathError",
"UiPathAPIError",
"UiPathAuthenticationError",
"UiPathBadGatewayError",
Expand Down
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
158 changes: 135 additions & 23 deletions src/uipath/llm_client/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand All @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -290,4 +400,6 @@ def raise_for_status() -> Response:
"UiPathServiceUnavailableError",
"UiPathGatewayTimeoutError",
"UiPathTooManyRequestsError",
"as_uipath_error",
"wrap_provider_errors",
]
Loading
Loading