feat: unify exception taxonomy under UiPathError#99
Conversation
…rrors Introduce `UiPathError` as a common root for everything the client can raise and make `UiPathAPIError` (and all status subclasses) inherit from it, so `except UiPathError` is a single catch-all across all backends and providers. For the LangChain passthrough chat models, `UiPathBaseChatModel` now re-tags provider SDK exceptions in place (via `wrap_provider_errors` / `as_uipath_error`) around `_generate`/`_agenerate`/`_stream`/`_astream`. An HTTP-shaped vendor error (openai/anthropic/...) is tagged with the matching `UiPathAPIError` subclass by status code (e.g. a 429 -> `UiPathRateLimitError`), so semantic handling works identically across every provider; non-HTTP errors get the `UiPathError` root marker. The re-tagged exception presents as the UiPath type (`type(exc).__name__` reads e.g. `UiPathBadRequestError`) while remaining catchable as the original vendor type via the MRO. - core + langchain bumped to 1.15.0; changelogs and dependency pin updated - `UiPathRateLimitError.retry_after` is now lazy (survives `__class__` re-tag) - `UiPathAPIError.__str__`/`__repr__` read attrs defensively - tests cover the core helpers and end-to-end wiring (real openai SDK, sync/async/stream) across every catchable type Affects: core (uipath-llm-client) and langchain (uipath-langchain-client). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finding: the documented error-handling contract isn't met for SDK-based providers (openai / anthropic / google)While reviewing this PR I checked it against the README's Error Handling contract — "All exceptions extend Repro (a gateway 403, injected via
|
| Path | Raised on 403 | isinstance UiPathAPIError |
|---|---|---|
core UiPathOpenAI |
openai.PermissionDeniedError |
❌ |
core UiPathAnthropic |
anthropic.PermissionDeniedError |
❌ |
langchain UiPathChatOpenAI.invoke() |
openai.PermissionDeniedError |
❌ |
clients/normalized (httpx) |
UiPathPermissionDeniedError |
✅ |
Bedrock via WrappedBotoClient (after the raise_for_status fix) |
UiPathPermissionDeniedError |
✅ |
So the README example would catch none of openai's RateLimitError / AuthenticationError / PermissionDeniedError — they aren't UiPathAPIError subclasses, so all three except clauses miss and the raw vendor exception propagates (and e.retry_after is never reached).
Why
The openai / anthropic / google SDKs inspect the response status themselves and raise their own typed exceptions — they never call the patched raise_for_status on the injected UiPathHttpxClient, so the patch is bypassed. Only paths that call raise_for_status() directly (the normalized httpx client, and Bedrock's WrappedBotoClient after the recent fix) yield UiPathAPIError.
Concerns with the current approach (for discussion)
- The
__class__re-tagging makes errors catchable as both the vendor type andUiPathError— a transitional/leaky contract vs. the README's "alwaysUiPathAPIError." It also carries real edge cases (__init__is bypassed on re-tag → the getattr/_message()guards; MRO override of__str__/__repr__; pickling of dynamically synthesized classes). - It's wired only into the langchain
base_client; core's ownUiPathOpenAI/UiPathAnthropic/UiPathGoogleclients still leak raw vendor exceptions for non-langchain consumers (table above). - We couldn't find a consumer that catches a raw provider type off the new path (uipath-agents / uipath-langchain-python catch only generic
Exception; the only provider-type catches are the self-contained legacy path) — so the backward-compat motivation for "catchable as both" appears unnecessary.
Proposed direction (separate follow-up PR)
- Keep the
UiPathError/UiPathAPIErrortaxonomy in core — it's the shared vocabulary, and core already raises it on the httpx path. - Add a translate-and-raise primitive in core (e.g.
UiPathAPIError.from_provider(exc)+ awrap_provider_errors()context manager): build aUiPathAPIErrorwith normalized.status_code/.body/.detail, and chain the original on__cause__— no__class__mutation. - Apply it at both call-site layers: core's own vendor-SDK clients and the langchain passthrough. Net contract: consumers always catch
UiPathAPIError, read.detail/.status_code, and find the provider error on.__cause__— which makes the README true.
We'll open a follow-up PR implementing this; flagging here so the finding + repro live alongside #99.
…stead of merging Replace the __class__-merge approach (a vendor error catchable as BOTH its native type and a UiPath type) with a single conversion. wrap_provider_errors now returns a pure UiPath exception: it walks the cause chain (__cause__/ __context__) for an httpx.Response and maps its status onto the matching UiPathAPIError subclass — so providers that wrap the response one level down (Google's ChatGoogleGenerativeAIError) map correctly too — and falls back to the UiPathError root when no response is present. The original provider error is preserved as __cause__. Raising early (at httpx send) was rejected: the openai/anthropic SDKs catch any send() exception and downgrade it to a context-less APIConnectionError, losing the status. Converting the SDK-native exception (which carries .response) keeps the status. Drops the synthesized-class machinery (_uipath_tagged_class / _uipath_base_for / as_uipath_error in-place re-tag) and the __init__-bypass defensive code in UiPathAPIError. patch_raise_for_status stays: it still serves the core normalized client, raw uipath_request/stream, and the Bedrock shim, none of which pass through wrap_provider_errors. Behavioral change: provider errors are no longer catchable as their vendor type (e.g. openai.RateLimitError); standardise handlers on UiPathError/subclasses. Tests: per-provider coverage (openai, anthropic, google, fireworks, bedrock, client-side validation) across generate/agenerate/stream/astream, plus the real-openai-SDK end-to-end. Full gate green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…der_errors Direct raise_for_status() callers (core normalized client, raw uipath_request/ stream, the Bedrock shim) now share the single conversion path with provider SDK exceptions instead of calling from_response inline. The raised UiPathAPIError now carries the original httpx HTTPStatusError as __cause__. Status mapping is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What & why
Today the client has two disjoint error taxonomies: the core HTTP path raises
UiPathAPIError+ status subclasses, while the LangChain passthrough clients surface raw vendor exceptions (openai.BadRequestError, etc.). A caller can't write one handler that covers both.This PR unifies them. It adds a common root,
UiPathError, and makes the passthrough chat models convert every provider error into the matching UiPath type, so callers handle one taxonomy regardless of which provider produced the error.How it works
UiPathErroris a new common root.UiPathAPIError(and every status subclass) now inherits from it. Backward compatible —UiPathAPIErrorstill inheritshttpx.HTTPStatusError, so existing core handlers keep working.wrap_provider_errors()(wrapping the single choke points_generate/_agenerate/_stream/_astream) converts any provider exception viaas_uipath_error():__cause__/__context__) for anhttpx.Response. When found, the status maps onto the matchingUiPathAPIErrorsubclass (429 →UiPathRateLimitError, 400 →UiPathBadRequestError, …). This handles both shapes: openai/anthropic carry the response directly; Google wraps a response-bearinggoogle.genaierror one level down (ChatGoogleGenerativeAIErrorraisedfromit) — the chain walk recovers its status too.UiPathErrorroot.__cause__.UiPathAPIErrorvia theWrappedBotoClient's patchedraise_for_status, so it passes throughwrap_provider_errorsuntouched — a single conversion point, no double-wrapping.Design notes
send()in a context-lessAPIConnectionError(their_requesthas a broadexcept Exception → raise APIConnectionError), which would lose the status. Converting the SDK-native exception — which carries.response— preserves it.__class__-merge? The previous iteration re-tagged vendor errors in place so they stayed catchable as both the vendor type and a UiPath type. We chose a single, pure taxonomy instead: simpler (no synthesized classes /__class__reassignment / doubled MRO), and lossless client-side errors no longer degrade through a rebuild path.patch_raise_for_statusis kept (it serves the core normalized client, the rawuipath_request/uipath_streamAPI, and the Bedrock shim) and now delegates towrap_provider_errorstoo, so every path — provider SDK exceptions and directraise_for_status()— shares one conversion entry point.UiPathRateLimitError.retry_afteris a lazy property parsed fromself.response.Provider errors are surfaced as pure UiPath types and are no longer catchable as their vendor type (e.g.
openai.RateLimitError). The vendor exception is preserved as__cause__. Standardise handlers onUiPathErrorand its subclasses.Packages affected
Both core (
uipath-llm-client) and langchain (uipath-langchain-client) — versioned together to 1.15.0, both changelogs updated, dependency pin at>=1.15.0.Tests
__cause__and__context__) recovery, non-httpx.responseignored, root fallback + chaining, already-UiPath pass-through,GeneratorExit/KeyboardInterruptpass-through.generate/agenerate/stream/astream, asserting the pure UiPath type, the dropped vendor lineage, and the preserved__cause__— plus a real-openai-SDK end-to-end raising a genuine 400.ruff check,ruff format,pyrightall clean.🤖 Generated with Claude Code