Skip to content
Open
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.15.1] - 2026-07-01

### Added
- Every `UiPathError` now carries two orthogonal, consumer-facing dimensions: `error_code` (a stable machine-readable semantic identifier — `"UIPATH_ERROR"` on the root, `"API_ERROR"` on `UiPathAPIError`, specific codes on typed subclasses) and `status_code` (`int | None` — the originating HTTP status, or `None` for purely client-side failures). Consumers can dispatch on `error_code` for semantic handling or `status_code` for HTTP-shaped handling from a single `except UiPathError` block.
- `UiPathUnsupportedAttachmentError` — a typed `UiPathError` subclass with a stable `UNSUPPORTED_ATTACHMENT_FORMAT` `error_code`, re-exported from `uipath.llm_client`. Raised when a provider rejects a file attachment before any HTTP response exists (e.g. Bedrock Converse request conversion raising a bare `ValueError("Unsupported MIME type: …")`). Preserves the offending `mime_type` and the raw `provider_detail` for downstream, agent-specific mapping; `status_code` is `None`.

### 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 typed errors, e.g. the unsupported-attachment case). Previously client-side classifiers ran first and could mask an authenticated/rate-limited/server error carrying an unrelated marker.

## [1.15.0] - 2026-06-25

### 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 @@ -53,6 +53,7 @@
UiPathServiceUnavailableError,
UiPathTooManyRequestsError,
UiPathUnprocessableEntityError,
UiPathUnsupportedAttachmentError,
)
from uipath.llm_client.utils.retry import RetryConfig

Expand Down Expand Up @@ -86,4 +87,5 @@
"UiPathServiceUnavailableError",
"UiPathTooManyRequestsError",
"UiPathUnprocessableEntityError",
"UiPathUnsupportedAttachmentError",
]
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.15.0"
__version__ = "1.15.1"
107 changes: 96 additions & 11 deletions src/uipath/llm_client/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@
... print(f"API Error: {e.status_code} - {e.message}")
"""

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

from httpx import HTTPStatusError, Request, Response

_UNSUPPORTED_MIME_MARKER = "Unsupported MIME type"
_UNSUPPORTED_MIME_RE = re.compile(r"Unsupported MIME type:\s*(?P<mime_type>\S+)")


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

Every error carries two orthogonal, consumer-facing dimensions:

* ``error_code`` — a stable, machine-readable *semantic* identifier
(e.g. ``"UNSUPPORTED_ATTACHMENT_FORMAT"``). Switch on it to handle the
error's meaning; it does not change when the underlying HTTP status does.
* ``status_code`` — the originating HTTP status (``int``) when the error
carries an HTTP response, else ``None`` for purely client-side failures.

Handle whichever axis fits::

except UiPathError as e:
if e.error_code == "UNSUPPORTED_ATTACHMENT_FORMAT":
... # semantic handling
elif e.status_code == 429:
backoff() # HTTP-shaped handling
"""

error_code: str = "UIPATH_ERROR"
status_code: int | None = None


class UiPathUnsupportedAttachmentError(UiPathError):
"""A file attachment has a format unsupported by the selected model/provider.

A client-side rejection (no HTTP response), so ``status_code`` is ``None``.
"""

error_code = "UNSUPPORTED_ATTACHMENT_FORMAT"

def __init__(
self,
message: str = "Unsupported file attachment format.",
*,
mime_type: str | None = None,
provider_detail: str | None = None,
):
super().__init__(message)
self.message = message
self.mime_type = mime_type
self.provider_detail = provider_detail


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

status_code: int
error_code: str = "API_ERROR"

def __init__(
self,
Expand Down Expand Up @@ -334,18 +378,55 @@ def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]:
current = current.__cause__ or current.__context__


def _extract_unsupported_mime_type(message: str) -> str | None:
match = _UNSUPPORTED_MIME_RE.search(message)
if not match:
return None
return match.group("mime_type").rstrip(".,;")


def _as_unsupported_attachment_error(
exc: BaseException,
) -> UiPathUnsupportedAttachmentError | None:
for err in _iter_error_chain(exc):
message = str(err)
if isinstance(err, ValueError) and _UNSUPPORTED_MIME_MARKER in message:
return UiPathUnsupportedAttachmentError(
mime_type=_extract_unsupported_mime_type(message),
provider_detail=message,
)
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.
# Adding a new non-HTTP error propagation = append its classifier here.
_CLIENT_SIDE_CLASSIFIERS: list[_ClientSideClassifier] = [
_as_unsupported_attachment_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 its typed :class:`UiPathError`
subclass (``status_code`` ``None``, semantic ``error_code`` set).

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 +439,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 +469,7 @@ def wrap_provider_errors() -> Iterator[None]:

__all__ = [
"UiPathError",
"UiPathUnsupportedAttachmentError",
"UiPathAPIError",
"UiPathBadRequestError",
"UiPathAuthenticationError",
Expand Down
86 changes: 86 additions & 0 deletions tests/langchain/features/test_exception_wrapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
UiPathInternalServerError,
UiPathPermissionDeniedError,
UiPathRateLimitError,
UiPathUnsupportedAttachmentError,
)

LLMGW_ENV = {
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(),
UiPathUnsupportedAttachmentError,
False,
),
]

CASE_IDS = [f"{name}-{exp.__name__}" for name, _, exp, _ in PROVIDER_CASES]
Expand Down Expand Up @@ -212,6 +232,72 @@ 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_preserves_attachment_context(self, llmgw_settings):
native = _unsupported_mime_exc()()
chat = _make_chat(llmgw_settings, native)

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

assert info.value.mime_type == "application/octet-stream"
assert info.value.provider_detail is not None
assert "Bedrock Converse API" in info.value.provider_detail
# both dimensions are carried: semantic code set, no HTTP status
assert info.value.error_code == "UNSUPPORTED_ATTACHMENT_FORMAT"
assert info.value.status_code is None

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_error_dimensions_are_both_present():
"""Every UiPath error exposes error_code and status_code for the consumer."""
from uipath.llm_client.utils.exceptions import as_uipath_error

http = as_uipath_error(openai.BadRequestError("bad", response=_resp(400), body=None))
assert http.status_code == 400
assert http.error_code == "API_ERROR"

client_side = as_uipath_error(_unsupported_mime_exc()())
assert client_side.status_code is None
assert client_side.error_code == "UNSUPPORTED_ATTACHMENT_FORMAT"

root = as_uipath_error(ValueError("nope"))
assert root.status_code is None
assert root.error_code == "UIPATH_ERROR"


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

class _CustomError(UiPathError):
error_code = "CUSTOM"

def _classify(exc):
return _CustomError("custom") if "trip me" in str(exc) else None

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

assert isinstance(exc_mod.as_uipath_error(ValueError("trip me")), _CustomError)

# 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