Skip to content

feat: unify exception taxonomy under UiPathError#99

Merged
cosminacho merged 3 commits into
mainfrom
feat/unified-exception-taxonomy
Jun 26, 2026
Merged

feat: unify exception taxonomy under UiPathError#99
cosminacho merged 3 commits into
mainfrom
feat/unified-exception-taxonomy

Conversation

@cosminacho

@cosminacho cosminacho commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

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.

try:
    chat.invoke(...)                 # any UiPathChat* client, any provider
except UiPathRateLimitError as e:    # same semantic class across EVERY provider
    backoff(e.retry_after)
except UiPathError:                  # one catch-all for any UiPath LLM error
    ...

How it works

  • UiPathError is a new common root. UiPathAPIError (and every status subclass) now inherits from it. Backward compatible — UiPathAPIError still inherits httpx.HTTPStatusError, so existing core handlers keep working.
  • wrap_provider_errors() (wrapping the single choke points _generate/_agenerate/_stream/_astream) converts any provider exception via as_uipath_error():
    • It walks the exception's cause chain (__cause__/__context__) for an httpx.Response. When found, the status maps onto the matching UiPathAPIError subclass (429 → UiPathRateLimitError, 400 → UiPathBadRequestError, …). This handles both shapes: openai/anthropic carry the response directly; Google wraps a response-bearing google.genai error one level down (ChatGoogleGenerativeAIError raised from it) — the chain walk recovers its status too.
    • When no response exists anywhere in the chain (client-side validation, connection failures), it returns the UiPathError root.
    • The original provider exception is preserved as __cause__.
  • Bedrock already produces a pure UiPathAPIError via the WrappedBotoClient's patched raise_for_status, so it passes through wrap_provider_errors untouched — a single conversion point, no double-wrapping.

Design notes

  • Why convert instead of raising early at the httpx layer? The openai/anthropic SDKs wrap any exception from send() in a context-less APIConnectionError (their _request has a broad except Exception → raise APIConnectionError), which would lose the status. Converting the SDK-native exception — which carries .response — preserves it.
  • Why not the earlier __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_status is kept (it serves the core normalized client, the raw uipath_request/uipath_stream API, and the Bedrock shim) and now delegates to wrap_provider_errors too, so every path — provider SDK exceptions and direct raise_for_status() — shares one conversion entry point.
  • UiPathRateLimitError.retry_after is a lazy property parsed from self.response.

⚠️ Behavioral change

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 on UiPathError and 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

  • Core unit tests: hierarchy, status mapping, cause-chain (__cause__ and __context__) recovery, non-httpx .response ignored, root fallback + chaining, already-UiPath pass-through, GeneratorExit/KeyboardInterrupt pass-through.
  • LangChain per-provider tests (openai, anthropic, google, fireworks, bedrock, client-side validation) across 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.
  • Full suite: 2019 passed, 0 failed. ruff check, ruff format, pyright all clean.

🤖 Generated with Claude Code

…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>
@vldcmp-uipath

vldcmp-uipath commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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 UiPathAPIError", with the UiPathAzureChatOpenAI(...).invoke()except UiPathRateLimitError / UiPathAuthenticationError / UiPathAPIError example. That contract does not hold today for the SDK-based providers.

Repro (a gateway 403, injected via httpx.MockTransport, run through the real classes)

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 and UiPathError — a transitional/leaky contract vs. the README's "always UiPathAPIError." 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 own UiPathOpenAI/UiPathAnthropic/UiPathGoogle clients 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 / UiPathAPIError taxonomy 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) + a wrap_provider_errors() context manager): build a UiPathAPIError with 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>
@cosminacho cosminacho changed the title feat: unify exception taxonomy with UiPathError and re-tag provider errors feat: unify exception taxonomy under UiPathError Jun 25, 2026
…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>
@cosminacho cosminacho deployed to LLMGW_SETTINGS June 26, 2026 14:14 — with GitHub Actions Active
@cosminacho cosminacho merged commit 58680d2 into main Jun 26, 2026
10 checks passed
@cosminacho cosminacho deleted the feat/unified-exception-taxonomy branch June 26, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants