From c692e7233e08d4afc3cdb460edbc849be6349f8c Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Tue, 30 Jun 2026 23:13:29 +0300 Subject: [PATCH 1/2] fix unsupported attachment error mapping Map Bedrock Converse unsupported-MIME ValueError chains into a typed UiPathUnsupportedAttachmentError (stable UNSUPPORTED_ATTACHMENT_FORMAT code, preserved mime_type/provider_detail). Dispatch client-side typed errors through a _CLIENT_SIDE_CLASSIFIERS registry tried ahead of HTTP status mapping, so future non-HTTP error propagations are a one-line addition rather than a dispatcher edit. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 +++ src/uipath/llm_client/__init__.py | 2 + src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/utils/exceptions.py | 67 ++++++++++++++++++- .../features/test_exception_wrapping.py | 52 ++++++++++++++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e99dad0..bff7bfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.15.1] - 2026-07-01 + +### Added +- `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. + +### Changed +- `as_uipath_error` now dispatches client-side (pre-response) provider errors through a `_CLIENT_SIDE_CLASSIFIERS` registry, tried ahead of HTTP status mapping because a typed semantic error is more actionable than the raw status. Previously such errors fell through to the `UiPathError` root. HTTP status mapping and the root fallback are otherwise unchanged. + ## [1.15.0] - 2026-06-25 ### Added diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index eca87f88..8ad63f4a 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -53,6 +53,7 @@ UiPathServiceUnavailableError, UiPathTooManyRequestsError, UiPathUnprocessableEntityError, + UiPathUnsupportedAttachmentError, ) from uipath.llm_client.utils.retry import RetryConfig @@ -86,4 +87,5 @@ "UiPathServiceUnavailableError", "UiPathTooManyRequestsError", "UiPathUnprocessableEntityError", + "UiPathUnsupportedAttachmentError", ] diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 520e6288..25d09d9f 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.15.0" +__version__ = "1.15.1" diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 63ee37a4..95574362 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -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\S+)") + class UiPathError(Exception): """Common base class for every error surfaced by the UiPath LLM client. @@ -64,6 +68,24 @@ class UiPathError(Exception): """ +class UiPathUnsupportedAttachmentError(UiPathError): + """A file attachment has a format unsupported by the selected model/provider.""" + + 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. @@ -334,10 +356,47 @@ 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). Tried in order, ahead of HTTP status mapping, +# because a typed semantic error is more actionable than the generic status. +# 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 + First offers ``exc`` to each client-side classifier in + ``_CLIENT_SIDE_CLASSIFIERS`` (provider errors rejected before any HTTP + response exists, e.g. an unsupported attachment). A match yields its typed + :class:`UiPathError` subclass, which takes precedence over status mapping + because the semantic error is more actionable than the raw HTTP status. + + Otherwise, 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 @@ -354,6 +413,9 @@ def as_uipath_error(exc: Exception) -> UiPathError: """ if isinstance(exc, UiPathError): return exc + for classify in _CLIENT_SIDE_CLASSIFIERS: + if typed_error := classify(exc): + return typed_error for err in _iter_error_chain(exc): response = getattr(err, "response", None) if isinstance(response, Response): @@ -385,6 +447,7 @@ def wrap_provider_errors() -> Iterator[None]: __all__ = [ "UiPathError", + "UiPathUnsupportedAttachmentError", "UiPathAPIError", "UiPathBadRequestError", "UiPathAuthenticationError", diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py index b3f8032a..e1d0053c 100644 --- a/tests/langchain/features/test_exception_wrapping.py +++ b/tests/langchain/features/test_exception_wrapping.py @@ -32,6 +32,7 @@ UiPathInternalServerError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathUnsupportedAttachmentError, ) LLMGW_ENV = { @@ -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(), + UiPathUnsupportedAttachmentError, + False, + ), ] CASE_IDS = [f"{name}-{exp.__name__}" for name, _, exp, _ in PROVIDER_CASES] @@ -212,6 +232,38 @@ 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 + + +def test_client_side_classifier_registry_is_extension_point(monkeypatch): + """A registered client-side classifier is consulted, ahead of HTTP mapping.""" + 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) + + matched = ValueError("trip me") + assert isinstance(exc_mod.as_uipath_error(matched), _CustomError) + + http = openai.BadRequestError("trip me", response=_resp(400), body=None) + assert isinstance(exc_mod.as_uipath_error(http), _CustomError) + + 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 From 52a43c3bc5c7309cca53909fcd9e65405bd0426f Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Wed, 1 Jul 2026 23:16:02 +0300 Subject: [PATCH 2/2] carry both error_code and status_code on every UiPathError Make HTTP status authoritative in as_uipath_error: scan the cause chain for an httpx.Response before consulting _CLIENT_SIDE_CLASSIFIERS, so a real status (auth/rate-limit/server) is never masked by an incidental client-side marker (e.g. an unrelated 'Unsupported MIME type' in __context__). Expose two orthogonal dimensions on the UiPathError root so consumers dispatch on whichever fits from one except block: - error_code: stable semantic id (UIPATH_ERROR / API_ERROR / typed codes) - status_code: originating HTTP status, or None for client-side failures Add regression tests: response-bearing error with an incidental MIME marker maps by status; both dimensions present across root/HTTP/typed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +- src/uipath/llm_client/utils/exceptions.py | 66 ++++++++++++------- .../features/test_exception_wrapping.py | 42 ++++++++++-- 3 files changed, 85 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff7bfd1..ccf794e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ All notable changes to `uipath_llm_client` (core package) will be documented in ## [1.15.1] - 2026-07-01 ### Added -- `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. +- 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 dispatches client-side (pre-response) provider errors through a `_CLIENT_SIDE_CLASSIFIERS` registry, tried ahead of HTTP status mapping because a typed semantic error is more actionable than the raw status. Previously such errors fell through to the `UiPathError` root. HTTP status mapping and the root fallback are otherwise unchanged. +- `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 diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 95574362..b67ecd74 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -65,11 +65,33 @@ 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 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" @@ -101,7 +123,7 @@ class UiPathAPIError(UiPathError, HTTPStatusError): response: The original httpx.Response object. """ - status_code: int + error_code: str = "API_ERROR" def __init__( self, @@ -379,8 +401,8 @@ def _as_unsupported_attachment_error( _ClientSideClassifier = Callable[[BaseException], UiPathError | None] # Classifiers for provider errors raised *before* an HTTP response exists -# (client-side request rejection). Tried in order, ahead of HTTP status mapping, -# because a typed semantic error is more actionable than the generic status. +# (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, @@ -390,21 +412,21 @@ def _as_unsupported_attachment_error( def as_uipath_error(exc: Exception) -> UiPathError: """Convert a provider/SDK exception into the matching UiPath exception. - First offers ``exc`` to each client-side classifier in - ``_CLIENT_SIDE_CLASSIFIERS`` (provider errors rejected before any HTTP - response exists, e.g. an unsupported attachment). A match yields its typed - :class:`UiPathError` subclass, which takes precedence over status mapping - because the semantic error is more actionable than the raw HTTP status. - - Otherwise, 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 @@ -413,13 +435,13 @@ def as_uipath_error(exc: Exception) -> UiPathError: """ if isinstance(exc, UiPathError): return exc - for classify in _CLIENT_SIDE_CLASSIFIERS: - if typed_error := classify(exc): - return typed_error for err in _iter_error_chain(exc): 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)) diff --git a/tests/langchain/features/test_exception_wrapping.py b/tests/langchain/features/test_exception_wrapping.py index e1d0053c..6b91a359 100644 --- a/tests/langchain/features/test_exception_wrapping.py +++ b/tests/langchain/features/test_exception_wrapping.py @@ -242,10 +242,44 @@ def test_unsupported_mime_error_preserves_attachment_context(self, llmgw_setting 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 client-side classifier is consulted, ahead of HTTP mapping.""" + """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): @@ -256,11 +290,11 @@ def _classify(exc): monkeypatch.setattr(exc_mod, "_CLIENT_SIDE_CLASSIFIERS", [_classify], raising=True) - matched = ValueError("trip me") - assert isinstance(exc_mod.as_uipath_error(matched), _CustomError) + 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 isinstance(exc_mod.as_uipath_error(http), _CustomError) + assert type(exc_mod.as_uipath_error(http)) is UiPathBadRequestError assert type(exc_mod.as_uipath_error(ValueError("nope"))) is UiPathError