Skip to content

Commit 55aec54

Browse files
feat: add example for process tool http error handling (#707)
1 parent db97414 commit 55aec54

File tree

7 files changed

+250
-12
lines changed

7 files changed

+250
-12
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ uv build
4040
- `tools/` — Structured tools: context, escalation, extraction, integration, process, MCP adapters, durable interrupts. All inherit from `BaseUiPathStructuredTool`.
4141
- `guardrails/` — Input/output validation within agent execution
4242
- `multimodal/` — Multimodal invoke support
43+
- `exceptions/` — Structured error types (`AgentRuntimeError`, `AgentStartupError`) and helpers for agent error handling
4344
- `wrappers/` — Agent decorators and wrappers
4445

4546
- **`chat/`** — LLM provider interfaces for OpenAI, Azure OpenAI, AWS Bedrock, Google Vertex AI. Uses **lazy imports** via `__getattr__` in `__init__.py` to keep CLI startup fast. Includes `hitl.py` with the `requires_approval` decorator for human-in-the-loop workflows. Factory pattern via `chat_model_factory.py`.
@@ -77,3 +78,5 @@ The package registers two entry points consumed by the `uipath` CLI:
7778
- **Linting**: Ruff with rules E, F, B, I. Line length 88. mypy with pydantic plugin for type checking.
7879

7980
- **Bedrock/Vertex imports**: `bedrock.py` and `vertex.py` have per-file E402 ignores for conditional imports.
81+
82+
- **Exception handling in `agent/`**: Use the error types and helpers from `agent/exceptions/`. Do not raise raw exceptions or invent new error types. New error codes may be defined.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.8.27"
3+
version = "0.8.28"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath>=2.10.19, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.0.23, <0.1.0",
10+
"uipath-platform>=0.0.27, <0.1.0",
1111
"uipath-runtime>=0.9.1, <0.10.0",
1212
"langgraph>=1.0.0, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",

src/uipath_langchain/agent/exceptions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
AgentStartupError,
55
AgentStartupErrorCode,
66
)
7+
from .helpers import raise_for_enriched
78

89
__all__ = [
910
"AgentStartupError",
1011
"AgentRuntimeError",
1112
"AgentStartupErrorCode",
1213
"AgentRuntimeErrorCode",
14+
"raise_for_enriched",
1315
]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Helpers for raising structured errors from HTTP exceptions."""
2+
3+
from collections import defaultdict
4+
5+
from uipath.platform.errors import EnrichedException
6+
from uipath.runtime.errors import UiPathErrorCategory
7+
8+
from .exceptions import AgentRuntimeError, AgentRuntimeErrorCode
9+
10+
11+
def raise_for_enriched(
12+
e: EnrichedException,
13+
known_errors: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]],
14+
*,
15+
title: str,
16+
**context: str,
17+
) -> None:
18+
"""Raise AgentRuntimeError if the exception matches a known error pattern.
19+
20+
Matches on ``(status_code, error_code)`` pairs. Use ``None`` as error_code
21+
to match any error with that status code. More specific matches (with
22+
error_code) are tried first.
23+
24+
Each value is a ``(template, category)`` pair. Message templates can use
25+
``{keyword}`` placeholders filled from *context*, plus ``{message}`` for
26+
the server's own error message.
27+
28+
Does nothing if no match is found — caller should re-raise the original.
29+
30+
Example::
31+
32+
try:
33+
await client.processes.invoke_async(name=name, folder_path=folder)
34+
except EnrichedException as e:
35+
raise_for_enriched(
36+
e,
37+
{
38+
(404, "1002"): ("Process not found.", UiPathErrorCategory.DEPLOYMENT),
39+
(409, None): ("Conflict: {message}", UiPathErrorCategory.DEPLOYMENT),
40+
},
41+
title=f"Failed to execute tool '{tool_name}'",
42+
)
43+
raise
44+
"""
45+
info = e.error_info
46+
error_code = info.error_code if info else None
47+
server_message = (info.message if info else None) or ""
48+
context["message"] = server_message
49+
50+
# Try specific match first, then wildcard
51+
entry = known_errors.get((e.status_code, error_code))
52+
if entry is None:
53+
entry = known_errors.get((e.status_code, None))
54+
if entry is None:
55+
return
56+
57+
template, category = entry
58+
detail = template.format_map(defaultdict(lambda: "<unknown>", context))
59+
raise AgentRuntimeError(
60+
code=AgentRuntimeErrorCode.HTTP_ERROR,
61+
title=title,
62+
detail=detail,
63+
category=category,
64+
status=e.status_code,
65+
should_wrap=False,
66+
) from e

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
from uipath.eval.mocks import mockable
1111
from uipath.platform import UiPath
1212
from uipath.platform.common import WaitJobRaw
13+
from uipath.platform.errors import EnrichedException
1314
from uipath.platform.orchestrator import JobState
15+
from uipath.runtime.errors import UiPathErrorCategory
1416

1517
from uipath_langchain._utils import get_execution_folder_path
1618
from uipath_langchain._utils.durable_interrupt import durable_interrupt
19+
from uipath_langchain.agent.exceptions import raise_for_enriched
1720
from uipath_langchain.agent.react.job_attachments import get_job_attachments
1821
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
1922
from uipath_langchain.agent.react.types import AgentGraphState
@@ -27,6 +30,21 @@
2730

2831
from .utils import sanitize_tool_name
2932

33+
_START_JOBS_ERRORS: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = {
34+
(404, "1002"): (
35+
"Could not find process for tool '{tool}'. Please check if the process is deployed in the configured folder.",
36+
UiPathErrorCategory.DEPLOYMENT,
37+
),
38+
(400, "1100"): (
39+
"Could not find folder for tool '{tool}'. Please check if the folder exists and is accessible by the robot.",
40+
UiPathErrorCategory.DEPLOYMENT,
41+
),
42+
(409, None): (
43+
"Cannot start process for tool '{tool}': {message}",
44+
UiPathErrorCategory.DEPLOYMENT,
45+
),
46+
}
47+
3048

3149
def create_process_tool(resource: AgentProcessToolResourceConfig) -> StructuredTool:
3250
"""Uses interrupt() to suspend graph execution until process completes (handled by runtime)."""
@@ -61,14 +79,23 @@ async def invoke_process(**_tool_kwargs: Any):
6179
@durable_interrupt
6280
async def start_job():
6381
client = UiPath()
64-
job = await client.processes.invoke_async(
65-
name=process_name,
66-
input_arguments=input_arguments,
67-
folder_path=folder_path,
68-
attachments=attachments,
69-
parent_span_id=parent_span_id,
70-
parent_operation_id=parent_operation_id,
71-
)
82+
try:
83+
job = await client.processes.invoke_async(
84+
name=process_name,
85+
input_arguments=input_arguments,
86+
folder_path=folder_path,
87+
attachments=attachments,
88+
parent_span_id=parent_span_id,
89+
parent_operation_id=parent_operation_id,
90+
)
91+
except EnrichedException as e:
92+
raise_for_enriched(
93+
e,
94+
_START_JOBS_ERRORS,
95+
title=f"Failed to execute tool '{resource.name}'",
96+
tool=resource.name,
97+
)
98+
raise
7299

73100
if job.key:
74101
bts_key = (
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Tests for raise_for_enriched helper."""
2+
3+
import json
4+
5+
import httpx
6+
import pytest
7+
from uipath.platform.errors import EnrichedException
8+
from uipath.runtime.errors import UiPathErrorCategory
9+
10+
from uipath_langchain.agent.exceptions import AgentRuntimeError, AgentRuntimeErrorCode
11+
from uipath_langchain.agent.exceptions.helpers import raise_for_enriched
12+
13+
14+
def _make_enriched(
15+
status: int,
16+
body: dict[str, object] | None = None,
17+
url: str = "https://cloud.uipath.com/org/tenant/orchestrator_/api/v1",
18+
) -> EnrichedException:
19+
content = json.dumps(body).encode() if body else b""
20+
request = httpx.Request("POST", url)
21+
response = httpx.Response(
22+
status_code=status,
23+
request=request,
24+
headers={"content-type": "application/json"},
25+
content=content,
26+
)
27+
http_err = httpx.HTTPStatusError("error", request=request, response=response)
28+
enriched = EnrichedException(http_err)
29+
enriched.__cause__ = http_err
30+
return enriched
31+
32+
33+
_KNOWN_ERRORS: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = {
34+
(404, "1002"): (
35+
"Could not find process for tool '{tool}'.",
36+
UiPathErrorCategory.USER,
37+
),
38+
(400, "1100"): (
39+
"Folder not found for tool '{tool}'.",
40+
UiPathErrorCategory.USER,
41+
),
42+
(409, None): (
43+
"Cannot start tool '{tool}': {message}",
44+
UiPathErrorCategory.DEPLOYMENT,
45+
),
46+
}
47+
48+
_TITLE = "Failed to execute tool 'MyProcess'"
49+
50+
51+
class TestMatching:
52+
def test_exact_match(self) -> None:
53+
err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"})
54+
with pytest.raises(AgentRuntimeError) as exc_info:
55+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="MyProcess")
56+
assert exc_info.value.error_info.category == UiPathErrorCategory.USER
57+
assert "MyProcess" in exc_info.value.error_info.detail
58+
59+
def test_wildcard_match(self) -> None:
60+
err = _make_enriched(409, {"message": "Already running"})
61+
with pytest.raises(AgentRuntimeError) as exc_info:
62+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="MyTool")
63+
assert "Already running" in exc_info.value.error_info.detail
64+
assert "MyTool" in exc_info.value.error_info.detail
65+
66+
def test_specific_beats_wildcard(self) -> None:
67+
errors: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = {
68+
(404, "1002"): ("specific: {tool}", UiPathErrorCategory.DEPLOYMENT),
69+
(404, None): ("wildcard: {tool}", UiPathErrorCategory.SYSTEM),
70+
}
71+
err = _make_enriched(404, {"errorCode": "1002"})
72+
with pytest.raises(AgentRuntimeError) as exc_info:
73+
raise_for_enriched(err, errors, title=_TITLE, tool="T")
74+
assert exc_info.value.error_info.detail == "specific: T"
75+
assert exc_info.value.error_info.category == UiPathErrorCategory.DEPLOYMENT
76+
77+
def test_no_match_does_nothing(self) -> None:
78+
err = _make_enriched(500, {"message": "Server error"})
79+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
80+
81+
def test_unknown_error_code_does_nothing(self) -> None:
82+
err = _make_enriched(404, {"errorCode": "9999"})
83+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
84+
85+
86+
class TestTitleAndDetail:
87+
def test_title_is_fixed(self) -> None:
88+
err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"})
89+
with pytest.raises(AgentRuntimeError) as exc_info:
90+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
91+
assert exc_info.value.error_info.title == _TITLE
92+
93+
def test_detail_uses_template(self) -> None:
94+
err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"})
95+
with pytest.raises(AgentRuntimeError) as exc_info:
96+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="InvoiceBot")
97+
assert (
98+
exc_info.value.error_info.detail
99+
== "Could not find process for tool 'InvoiceBot'."
100+
)
101+
102+
def test_message_placeholder(self) -> None:
103+
err = _make_enriched(409, {"message": "Job conflict"})
104+
with pytest.raises(AgentRuntimeError) as exc_info:
105+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
106+
assert "Job conflict" in exc_info.value.error_info.detail
107+
108+
def test_empty_message_when_no_error_info(self) -> None:
109+
err = _make_enriched(409, body=None)
110+
with pytest.raises(AgentRuntimeError) as exc_info:
111+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
112+
assert "Cannot start tool 'T': " in exc_info.value.error_info.detail
113+
114+
def test_missing_context_renders_as_unknown(self) -> None:
115+
err = _make_enriched(404, {"errorCode": "1002"})
116+
with pytest.raises(AgentRuntimeError) as exc_info:
117+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE)
118+
assert "<unknown>" in exc_info.value.error_info.detail
119+
120+
121+
class TestErrorProperties:
122+
def test_error_code(self) -> None:
123+
err = _make_enriched(404, {"errorCode": "1002"})
124+
with pytest.raises(AgentRuntimeError) as exc_info:
125+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
126+
assert exc_info.value.error_info.code == AgentRuntimeError.full_code(
127+
AgentRuntimeErrorCode.HTTP_ERROR
128+
)
129+
130+
def test_status_code_preserved(self) -> None:
131+
err = _make_enriched(400, {"errorCode": "1100"})
132+
with pytest.raises(AgentRuntimeError) as exc_info:
133+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
134+
assert exc_info.value.error_info.status == 400
135+
136+
def test_original_exception_chained(self) -> None:
137+
err = _make_enriched(404, {"errorCode": "1002"})
138+
with pytest.raises(AgentRuntimeError) as exc_info:
139+
raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T")
140+
assert exc_info.value.__cause__ is err

uv.lock

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

0 commit comments

Comments
 (0)