From bcfc30479ff8024d195cd83bcc6b1b1212f17c73 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Mon, 6 Jul 2026 11:12:58 +0300 Subject: [PATCH] fix: normalize LLM client semantic errors Replace the typed unsupported-attachment exception with a small public error contract on UiPathError: optional error_code plus diagnostic detail. Add and export UiPathLLMErrorCode so downstream packages handle stable semantic conditions without depending on provider exception types, message text, or llm-client internals. as_uipath_error normalizes on two fixed paths: - status-bearing: walk the cause chain for an httpx.Response and map its status onto the matching UiPathAPIError subclass (status authoritative). - client-side: only when no response exists anywhere in the chain, consult the client-side classifier registry, yielding UiPathError with a stable error_code and diagnostic detail. Recognition is organized per semantic code, not per provider: a code that providers surface differently is one classifier over N matcher predicates (_UNSUPPORTED_MIME_MATCHERS), so adding a provider surfacing is one predicate and as_uipath_error never grows new branches. Bedrock Converse unsupported -MIME ValueError chains map to UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE. Bump uipath-llm-client to 1.16.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++ src/uipath/llm_client/__init__.py | 2 + src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/utils/exceptions.py | 95 ++++++++++++++++--- .../features/test_exception_wrapping.py | 91 ++++++++++++++++++ 5 files changed, 185 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c0c651..a91f16f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index eca87f88..47e1f507 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -45,6 +45,7 @@ UiPathError, UiPathGatewayTimeoutError, UiPathInternalServerError, + UiPathLLMErrorCode, UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, @@ -71,6 +72,7 @@ "RetryConfig", # Exceptions "UiPathError", + "UiPathLLMErrorCode", "UiPathAPIError", "UiPathAuthenticationError", "UiPathBadGatewayError", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 8dc06440..8764d704 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.16.0" +__version__ = "1.16.2" diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 63ee37a4..823d9060 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -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. @@ -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. @@ -79,8 +110,6 @@ class UiPathAPIError(UiPathError, HTTPStatusError): response: The original httpx.Response object. """ - status_code: int - def __init__( self, message: str, @@ -88,8 +117,11 @@ def __init__( 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 @@ -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 @@ -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)) @@ -385,6 +453,7 @@ def wrap_provider_errors() -> Iterator[None]: __all__ = [ "UiPathError", + "UiPathLLMErrorCode", "UiPathAPIError", "UiPathBadRequestError", "UiPathAuthenticationError", diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py index b3f8032a..0229f5ea 100644 --- a/tests/langchain/features/test_exception_wrapping.py +++ b/tests/langchain/features/test_exception_wrapping.py @@ -30,6 +30,7 @@ UiPathBadRequestError, UiPathError, UiPathInternalServerError, + UiPathLLMErrorCode, UiPathPermissionDeniedError, UiPathRateLimitError, ) @@ -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), @@ -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] @@ -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