Skip to content

Commit 7f676b4

Browse files
vldcmp-uipathclaude
authored andcommitted
feat(licensing): normalize provider HTTP errors via provider_errors module (#926)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c221567 commit 7f676b4

5 files changed

Lines changed: 255 additions & 71 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ dependencies = [
2323
"langchain-mcp-adapters==0.2.1",
2424
"pillow>=12.1.1",
2525
"a2a-sdk>=0.2.0,<1.0.0",
26-
"uipath-langchain-client[openai]>=1.14.0,<1.15.0",
26+
"uipath-langchain-client[openai]>=1.14.1,<1.15.0",
2727
]
2828

2929
classifiers = [
@@ -40,21 +40,21 @@ maintainers = [
4040

4141
[project.optional-dependencies]
4242
anthropic = [
43-
"uipath-langchain-client[anthropic]>=1.14.0,<1.15.0",
43+
"uipath-langchain-client[anthropic]>=1.14.1,<1.15.0",
4444
]
4545
vertex = [
46-
"uipath-langchain-client[google]>=1.14.0,<1.15.0",
47-
"uipath-langchain-client[vertexai]>=1.14.0,<1.15.0",
46+
"uipath-langchain-client[google]>=1.14.1,<1.15.0",
47+
"uipath-langchain-client[vertexai]>=1.14.1,<1.15.0",
4848
]
4949
bedrock = [
50-
"uipath-langchain-client[bedrock]>=1.14.0,<1.15.0",
50+
"uipath-langchain-client[bedrock]>=1.14.1,<1.15.0",
5151
"boto3-stubs>=1.41.4",
5252
]
5353
fireworks = [
54-
"uipath-langchain-client[fireworks]>=1.14.0,<1.15.0",
54+
"uipath-langchain-client[fireworks]>=1.14.1,<1.15.0",
5555
]
5656
all = [
57-
"uipath-langchain-client[all]>=1.14.0,<1.15.0",
57+
"uipath-langchain-client[all]>=1.14.1,<1.15.0",
5858
]
5959

6060
[project.entry-points."uipath.middlewares"]
Lines changed: 19 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
"""Convert LLM provider HTTP errors into structured AgentRuntimeErrors.
22
3-
Each LLM provider wraps HTTP errors in a different exception type:
4-
- OpenAI: openai.PermissionDeniedError → e.status_code
5-
- Vertex: google.genai.errors.ClientError → e.code
6-
- Bedrock: botocore.exceptions.ClientError → e.response dict
7-
8-
This module extracts the HTTP status code from any of these and re-raises
9-
as an AgentRuntimeError so that upstream error handling (exception mapper,
10-
CAS bridge) can categorise by status code without provider-specific logic.
3+
Provider exceptions are first normalized to a common ``ProviderError`` (status_code + detail).
4+
This module maps that status code to an AgentRuntimeError so
5+
upstream handling (exception mapper, CAS bridge) can categorise by status code
6+
without provider-specific logic.
117
"""
128

139
from uipath.runtime.errors import UiPathErrorCategory
@@ -16,39 +12,7 @@
1612
AgentRuntimeError,
1713
AgentRuntimeErrorCode,
1814
)
19-
20-
21-
def _extract_status_code(e: BaseException) -> int | None:
22-
"""Extract HTTP status code from any provider-specific exception.
23-
24-
Supports OpenAI (status_code), Vertex/google.genai (code), and
25-
Bedrock/botocore (response dict). Walks __cause__ chain to handle
26-
LangChain wrapper exceptions (e.g. ChatGoogleGenerativeAIError).
27-
"""
28-
# OpenAI: e.status_code
29-
sc = getattr(e, "status_code", None)
30-
if isinstance(sc, int):
31-
return sc
32-
33-
# Vertex (google.genai.errors.APIError): e.code
34-
sc = getattr(e, "code", None)
35-
if isinstance(sc, int):
36-
return sc
37-
38-
# Bedrock (botocore.exceptions.ClientError): e.response dict
39-
resp = getattr(e, "response", None)
40-
if isinstance(resp, dict):
41-
sc = resp.get("ResponseMetadata", {}).get("HTTPStatusCode")
42-
if isinstance(sc, int):
43-
return sc
44-
45-
# Walk __cause__ chain
46-
cause = getattr(e, "__cause__", None)
47-
if cause is not None and cause is not e:
48-
return _extract_status_code(cause)
49-
50-
return None
51-
15+
from uipath_langchain.chat.provider_errors import extract_provider_error
5216

5317
# Maps known LLM Gateway status codes to specific error codes.
5418
# Unknown status codes fall back to HTTP_ERROR.
@@ -60,27 +24,25 @@ def _extract_status_code(e: BaseException) -> int | None:
6024
def raise_for_provider_http_error(e: BaseException) -> None:
6125
"""Re-raise provider-specific HTTP errors as a structured AgentRuntimeError.
6226
63-
Extracts the HTTP status code from any LLM provider exception and
64-
converts it to an AgentRuntimeError with the status code preserved.
65-
Known status codes (e.g. 403) get a specific error code so upstream
66-
handlers can match on the suffix. Does nothing if no HTTP status code
67-
can be extracted.
27+
Extracts the HTTP status code and the gateway's ``detail``
28+
from any LLM provider exception and converts it to an
29+
AgentRuntimeError. Does nothing if no HTTP status code can be extracted.
6830
"""
69-
sc = _extract_status_code(e)
70-
if sc is None:
31+
err = extract_provider_error(e)
32+
if err.status_code is None:
7133
return
7234

73-
code = _LLM_STATUS_CODE_MAP.get(sc, AgentRuntimeErrorCode.HTTP_ERROR)
74-
75-
if sc == 403:
76-
category = UiPathErrorCategory.DEPLOYMENT
77-
else:
78-
category = UiPathErrorCategory.UNKNOWN
35+
code = _LLM_STATUS_CODE_MAP.get(err.status_code, AgentRuntimeErrorCode.HTTP_ERROR)
36+
category = (
37+
UiPathErrorCategory.DEPLOYMENT
38+
if err.status_code == 403
39+
else UiPathErrorCategory.UNKNOWN
40+
)
7941

8042
raise AgentRuntimeError(
8143
code=code,
82-
title=f"LLM provider returned HTTP {sc}",
83-
detail=str(e),
44+
title=f"LLM provider returned HTTP {err.status_code}",
45+
detail=err.detail or str(e),
8446
category=category,
85-
status=sc,
47+
status=err.status_code,
8648
) from e
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Normalize LLM provider HTTP errors into a common shape.
2+
3+
Providers behind the LLM Gateway each raise a different exception type, but all
4+
carry the same gateway body (``{status, detail, ...}``); only the attribute that
5+
holds it differs. LangChain may wrap the SDK error, so the useful fields can sit
6+
a few links down the ``__cause__`` chain — always together, on one link.
7+
"""
8+
9+
from dataclasses import dataclass
10+
11+
12+
@dataclass
13+
class ProviderError:
14+
"""Normalized provider HTTP error: status code + user-facing detail."""
15+
16+
status_code: int | None = None
17+
detail: str | None = None
18+
19+
def __bool__(self) -> bool:
20+
"""Truthy once we have a status code — the signal that a provider matched."""
21+
return self.status_code is not None
22+
23+
24+
def _int(value: object) -> int | None:
25+
"""The value if it is an int (a real HTTP status), else None.
26+
27+
Guards against matching unrelated exceptions that happen to carry a
28+
``code``/``status_code`` attribute that isn't an HTTP status.
29+
"""
30+
return value if isinstance(value, int) else None
31+
32+
33+
def _detail(body: object) -> str | None:
34+
"""The gateway ``detail`` message from a parsed body dict, if present."""
35+
if isinstance(body, dict):
36+
return body.get("detail")
37+
38+
return None
39+
40+
41+
# One extractor per provider: read that SDK's status code and detail out of the exception, if present
42+
# Returns an empty (falsy) ProviderError when ``e`` isn't that provider's error type
43+
44+
45+
def _from_openai(e: BaseException) -> ProviderError:
46+
"""OpenAI / Anthropic: ``e.status_code`` + ``e.body``."""
47+
return ProviderError(
48+
_int(getattr(e, "status_code", None)), _detail(getattr(e, "body", None))
49+
)
50+
51+
52+
def _from_vertex(e: BaseException) -> ProviderError:
53+
"""Vertex / google.genai ``APIError``."""
54+
return ProviderError(
55+
_int(getattr(e, "code", None)), _detail(getattr(e, "details", None))
56+
)
57+
58+
59+
def _from_bedrock(e: BaseException) -> ProviderError:
60+
"""Bedrock — same ``e.status_code`` + ``e.body`` shape as OpenAI.
61+
62+
Bedrock requests go through the uipath-client ``WrappedBotoClient`` shim
63+
rather than boto3. On a gateway HTTP error its ``raise_for_status`` raises a
64+
``UiPathPermissionDeniedError`` (a ``UiPathAPIError`` / ``httpx.HTTPStatusError``
65+
subclass) that exposes the OpenAI-style ``.status_code`` and ``.body``.
66+
"""
67+
return ProviderError(
68+
_int(getattr(e, "status_code", None)), _detail(getattr(e, "body", None))
69+
)
70+
71+
72+
def _from_botocore(e: BaseException) -> ProviderError:
73+
"""Bedrock via legacy direct boto3 (``use_new_llm_clients=False``): a
74+
``botocore.exceptions.ClientError`` carrying everything in ``e.response``."""
75+
resp = getattr(e, "response", None)
76+
if not isinstance(resp, dict):
77+
return ProviderError()
78+
return ProviderError(
79+
_int(resp.get("ResponseMetadata", {}).get("HTTPStatusCode")),
80+
_detail(resp.get("Error")),
81+
)
82+
83+
84+
_PROVIDERS = (_from_openai, _from_vertex, _from_bedrock, _from_botocore)
85+
86+
87+
def extract_provider_error(e: BaseException | None) -> ProviderError:
88+
"""Return the first provider that matches ``e`` or any of its ``__cause__`` links."""
89+
if e is None:
90+
return ProviderError()
91+
for extract in _PROVIDERS:
92+
error = extract(e)
93+
if error:
94+
return error
95+
return extract_provider_error(e.__cause__)

tests/chat/test_provider_errors.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Tests for normalizing provider HTTP errors into a common shape.
2+
3+
Each provider behind the LLM Gateway raises a different exception type, but all
4+
carry the same gateway body (``{status, detail, ...}``). ``extract_provider_error``
5+
reads the status code + detail off whichever attribute the SDK exposes, walking
6+
the ``__cause__`` chain when LangChain wraps the SDK error.
7+
"""
8+
9+
import pytest
10+
from uipath.runtime.errors import UiPathErrorCategory
11+
12+
from uipath_langchain.agent.exceptions.exceptions import (
13+
AgentRuntimeError,
14+
AgentRuntimeErrorCode,
15+
)
16+
from uipath_langchain.agent.exceptions.licensing import raise_for_provider_http_error
17+
from uipath_langchain.chat.provider_errors import (
18+
ProviderError,
19+
extract_provider_error,
20+
)
21+
22+
_DETAIL = "License not available for LLM usage. You need additional 'AGU'."
23+
_BODY = {
24+
"title": "License not available",
25+
"status": 403,
26+
"detail": _DETAIL,
27+
}
28+
29+
30+
class TestExtractProviderError:
31+
def test_openai_status_code_and_body(self) -> None:
32+
class OpenAIError(Exception):
33+
status_code = 403
34+
body = _BODY
35+
36+
result = extract_provider_error(OpenAIError("Forbidden"))
37+
assert result == ProviderError(status_code=403, detail=_DETAIL)
38+
39+
def test_bedrock_uipath_api_error_same_shape_as_openai(self) -> None:
40+
# Bedrock via WrappedBotoClient surfaces as a UiPathAPIError (httpx
41+
# subclass) exposing OpenAI-style .status_code / .body.
42+
class UiPathPermissionDeniedError(Exception):
43+
status_code = 403
44+
body = _BODY
45+
46+
result = extract_provider_error(UiPathPermissionDeniedError("Forbidden"))
47+
assert result == ProviderError(status_code=403, detail=_DETAIL)
48+
49+
def test_vertex_wrapped_in_langchain_error(self) -> None:
50+
# google.genai exposes .code + .details; LangChain wraps it in a class
51+
# that itself exposes nothing, so the fields live on the __cause__.
52+
class GenAIError(Exception):
53+
code = 403
54+
details = _BODY
55+
56+
class ChatGoogleGenerativeAIError(Exception):
57+
pass
58+
59+
try:
60+
try:
61+
raise GenAIError("403")
62+
except GenAIError as cause:
63+
raise ChatGoogleGenerativeAIError("wrapped") from cause
64+
except ChatGoogleGenerativeAIError as wrapper:
65+
result = extract_provider_error(wrapper)
66+
67+
assert result == ProviderError(status_code=403, detail=_DETAIL)
68+
69+
def test_botocore_response_dict(self) -> None:
70+
# Legacy direct boto3 path: botocore.ClientError carries a response dict.
71+
class ClientError(Exception):
72+
response = {
73+
"ResponseMetadata": {"HTTPStatusCode": 403},
74+
"Error": {"Code": "AccessDenied", "detail": _BODY["detail"]},
75+
}
76+
77+
result = extract_provider_error(ClientError("denied"))
78+
assert result.status_code == 403
79+
80+
def test_none_returns_empty(self) -> None:
81+
result = extract_provider_error(None)
82+
assert result == ProviderError()
83+
assert not result
84+
85+
def test_non_int_status_attribute_is_ignored(self) -> None:
86+
# An unrelated exception that happens to carry a string `code` must not
87+
# be mistaken for a provider HTTP error.
88+
class OSLike(Exception):
89+
code = "ENOENT"
90+
91+
assert extract_provider_error(OSLike("nope")) == ProviderError()
92+
93+
94+
class TestRaiseForProviderHttpError:
95+
def test_403_maps_to_license_not_available(self) -> None:
96+
class OpenAIError(Exception):
97+
status_code = 403
98+
body = _BODY
99+
100+
with pytest.raises(AgentRuntimeError) as exc_info:
101+
raise_for_provider_http_error(OpenAIError("Forbidden"))
102+
103+
info = exc_info.value.error_info
104+
assert info.status == 403
105+
assert info.category == UiPathErrorCategory.DEPLOYMENT
106+
assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value)
107+
assert _DETAIL in info.detail
108+
109+
def test_other_status_falls_back_to_http_error_and_str(self) -> None:
110+
# Non-403 status, and no `detail` in the body → detail falls back to str(e).
111+
class OpenAIError(Exception):
112+
status_code = 500
113+
body: dict[str, str] = {}
114+
115+
with pytest.raises(AgentRuntimeError) as exc_info:
116+
raise_for_provider_http_error(OpenAIError("boom"))
117+
118+
info = exc_info.value.error_info
119+
assert info.status == 500
120+
assert info.category == UiPathErrorCategory.UNKNOWN
121+
assert info.code.endswith(AgentRuntimeErrorCode.HTTP_ERROR.value)
122+
assert "boom" in info.detail # str(e) fallback
123+
124+
def test_no_status_does_not_raise(self) -> None:
125+
# No extractable HTTP status → no-op (the original exception is left to
126+
# propagate from the caller). Reaching the end without raising is the assert.
127+
raise_for_provider_http_error(ValueError("unrelated transport error"))

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)