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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to `uipath_llm_client` (core package) will be documented in this file.

## [1.16.2] - 2026-07-06

### Added
- `UiPathLLMErrorCode` — a public string enum of stable semantic error codes independent of provider exception types or provider-specific message text. It currently includes `UNSUPPORTED_MIME_TYPE`.
- `UiPathError` now carries optional semantic context: `error_code` (a stable machine-readable identifier, preferably from `UiPathLLMErrorCode`) and `detail` (best-effort diagnostic detail from the mapped lower-level error).

### Changed
- `as_uipath_error` now resolves errors with HTTP status as authoritative: the cause chain is scanned for an `httpx.Response` first, so a real status always outranks a client-side classifier match elsewhere in the chain (which may be incidental `__context__` rather than the failure). Only when no response exists anywhere is the error offered to the `_CLIENT_SIDE_CLASSIFIERS` registry (extension point for future non-HTTP semantic codes, e.g. the unsupported-MIME case). Previously client-side classifiers ran first and could mask an authenticated/rate-limited/server error carrying an unrelated marker.

## [1.16.0] - 2026-07-03

### Added
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 @@ -45,6 +45,7 @@
UiPathError,
UiPathGatewayTimeoutError,
UiPathInternalServerError,
UiPathLLMErrorCode,
UiPathNotFoundError,
UiPathPermissionDeniedError,
UiPathRateLimitError,
Expand All @@ -71,6 +72,7 @@
"RetryConfig",
# Exceptions
"UiPathError",
"UiPathLLMErrorCode",
"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.16.0"
__version__ = "1.16.2"
95 changes: 82 additions & 13 deletions src/uipath/llm_client/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,25 @@
... print(f"API Error: {e.status_code} - {e.message}")
"""

from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from enum import StrEnum
from json import JSONDecodeError
from typing import Literal

from httpx import HTTPStatusError, Request, Response

_UNSUPPORTED_MIME_MARKER = "Unsupported MIME type"


class UiPathLLMErrorCode(StrEnum):
"""Stable semantic error codes surfaced by the UiPath LLM client.

Values are provider-independent and stable across supported LLM providers.
"""

UNSUPPORTED_MIME_TYPE = "UNSUPPORTED_MIME_TYPE"


class UiPathError(Exception):
"""Common base class for every error surfaced by the UiPath LLM client.
Expand All @@ -61,8 +73,27 @@ class UiPathError(Exception):
backoff(e.retry_after)
except UiPathError: # catch-all across every provider
...

``UiPathError`` carries normalized semantic context:

* ``error_code`` — a stable, machine-readable identifier, when known.
* ``detail`` — a best-effort diagnostic detail from the mapped lower-level
error.
"""

error_code: UiPathLLMErrorCode | str | None
detail: str | None

def __init__(
self,
detail: str | None = None,
*,
error_code: UiPathLLMErrorCode | str | None = None,
) -> None:
Exception.__init__(self, detail or "")
self.error_code = error_code
self.detail = detail


class UiPathAPIError(UiPathError, HTTPStatusError):
"""Base exception for all UiPath API errors.
Expand All @@ -79,17 +110,18 @@ class UiPathAPIError(UiPathError, HTTPStatusError):
response: The original httpx.Response object.
"""

status_code: int

def __init__(
self,
message: str,
*,
request: Request,
response: Response,
body: str | dict | None = None,
error_code: UiPathLLMErrorCode | str | None = None,
detail: str | None = None,
):
super().__init__(message, request=request, response=response)
UiPathError.__init__(self, detail, error_code=error_code)
HTTPStatusError.__init__(self, message, request=request, response=response)
self.status_code = response.status_code
self.message = message
self.body = body
Expand Down Expand Up @@ -334,18 +366,51 @@ def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]:
current = current.__cause__ or current.__context__


_UNSUPPORTED_MIME_MATCHERS: tuple[Callable[[BaseException], bool], ...] = (
lambda err: isinstance(err, ValueError) and _UNSUPPORTED_MIME_MARKER in str(err),
)


def _as_unsupported_mime_type_error(
exc: BaseException,
) -> UiPathError | None:
for err in _iter_error_chain(exc):
if any(matches(err) for matches in _UNSUPPORTED_MIME_MATCHERS):
return UiPathError(
error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE,
detail=str(err),
)
return None


_ClientSideClassifier = Callable[[BaseException], UiPathError | None]

# Classifiers for provider errors raised *before* an HTTP response exists
# (client-side request rejection). Consulted in order, but only after the chain
# is confirmed to carry no httpx.Response — HTTP status stays authoritative.
_CLIENT_SIDE_CLASSIFIERS: list[_ClientSideClassifier] = [
_as_unsupported_mime_type_error,
]


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
HTTP status is authoritative: ``exc`` and its cause chain are walked for an
``httpx.Response`` first. 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`. A real response outranks any client-side
classifier match elsewhere in the chain, which may be incidental
``__context__`` rather than the actual failure.

Only when no response is available anywhere in the chain (a genuinely
client-side rejection) is ``exc`` offered to each classifier in
``_CLIENT_SIDE_CLASSIFIERS``; a match yields :class:`UiPathError` with a
stable semantic ``error_code`` and diagnostic ``detail``.

Otherwise 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
Expand All @@ -358,6 +423,9 @@ def as_uipath_error(exc: Exception) -> UiPathError:
response = getattr(err, "response", None)
if isinstance(response, Response):
return UiPathAPIError.from_response(response)
for classify in _CLIENT_SIDE_CLASSIFIERS:
if typed_error := classify(exc):
return typed_error
return UiPathError(str(exc))


Expand Down Expand Up @@ -385,6 +453,7 @@ def wrap_provider_errors() -> Iterator[None]:

__all__ = [
"UiPathError",
"UiPathLLMErrorCode",
"UiPathAPIError",
"UiPathBadRequestError",
"UiPathAuthenticationError",
Expand Down
91 changes: 91 additions & 0 deletions tests/langchain/features/test_exception_wrapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
UiPathBadRequestError,
UiPathError,
UiPathInternalServerError,
UiPathLLMErrorCode,
UiPathPermissionDeniedError,
UiPathRateLimitError,
)
Expand Down Expand Up @@ -96,6 +97,19 @@ def _bedrock_exc(status: int):
return lambda: UiPathAPIError.from_response(_resp(status))


def _unsupported_mime_exc():
def build():
root = ValueError(
"Unsupported MIME type: application/octet-stream. Please refer to the "
"Bedrock Converse API documentation for supported formats."
)
err = RuntimeError("model invocation failed")
err.__cause__ = root
return err

return build


# (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),
Expand All @@ -118,6 +132,12 @@ def _bedrock_exc(status: int):
("fireworks", _openai_exc(openai.AuthenticationError, 401), UiPathAuthenticationError, False),
# bedrock: the shim already raised a pure UiPath error
("bedrock", _bedrock_exc(403), UiPathPermissionDeniedError, True),
(
"bedrock-unsupported-mime",
_unsupported_mime_exc(),
UiPathError,
False,
),
]

CASE_IDS = [f"{name}-{exp.__name__}" for name, _, exp, _ in PROVIDER_CASES]
Expand Down Expand Up @@ -212,6 +232,77 @@ def test_client_side_validation_error_becomes_root(self, llmgw_settings):
assert not isinstance(info.value, UiPathAPIError)
assert isinstance(info.value.__cause__, ValueError)

def test_unsupported_mime_error_uses_public_semantic_code(self, llmgw_settings):
native = _unsupported_mime_exc()()
chat = _make_chat(llmgw_settings, native)

with pytest.raises(UiPathError) as info:
chat.invoke("hi")

assert type(info.value) is UiPathError
assert info.value.error_code == UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE
assert info.value.detail is not None
assert "application/octet-stream" in info.value.detail
assert "Bedrock Converse API" in info.value.detail
assert not hasattr(info.value, "status_code")

def test_http_status_wins_over_incidental_client_side_marker(self, llmgw_settings):
"""A response-bearing error is not masked by an unrelated MIME marker
elsewhere in its cause/context chain."""
native = openai.AuthenticationError("unauthorized", response=_resp(401), body=None)
native.__context__ = ValueError("Unsupported MIME type: application/octet-stream.")
chat = _make_chat(llmgw_settings, native)

with pytest.raises(UiPathAuthenticationError) as info:
chat.invoke("hi")

assert type(info.value) is UiPathAuthenticationError
assert info.value.status_code == 401


def test_semantic_fields_are_optional_on_uipath_error():
"""UiPathError exposes optional semantic fields; API errors carry status."""
from uipath.llm_client.utils.exceptions import as_uipath_error

http = as_uipath_error(openai.BadRequestError("bad", response=_resp(400), body=None))
assert isinstance(http, UiPathAPIError)
assert http.status_code == 400
assert http.error_code is None
assert http.detail is None

client_side = as_uipath_error(_unsupported_mime_exc()())
assert not isinstance(client_side, UiPathAPIError)
assert not hasattr(client_side, "status_code")
assert client_side.error_code == UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE
assert client_side.detail is not None

root = as_uipath_error(ValueError("nope"))
assert not hasattr(root, "status_code")
assert root.error_code is None
assert root.detail == "nope"


def test_client_side_classifier_registry_is_extension_point(monkeypatch):
"""A registered classifier fires only when the chain carries no HTTP response;
a real response stays authoritative."""
from uipath.llm_client.utils import exceptions as exc_mod

def _classify(exc):
return UiPathError(error_code="CUSTOM", detail="custom") if "trip me" in str(exc) else None

monkeypatch.setattr(exc_mod, "_CLIENT_SIDE_CLASSIFIERS", [_classify], raising=True)

converted = exc_mod.as_uipath_error(ValueError("trip me"))
assert type(converted) is UiPathError
assert converted.error_code == "CUSTOM"
assert converted.detail == "custom"

# response-bearing error is mapped by status, not by the matching classifier
http = openai.BadRequestError("trip me", response=_resp(400), body=None)
assert type(exc_mod.as_uipath_error(http)) is UiPathBadRequestError

assert type(exc_mod.as_uipath_error(ValueError("nope"))) is UiPathError


# ============================================================================
# End-to-end: the genuine openai SDK raises BadRequestError on a real 400, and
Expand Down
Loading