Skip to content

Commit 7bf6c09

Browse files
feat: add per-service error extraction to EnrichedException (#1455)
1 parent 81ef074 commit 7bf6c09

File tree

15 files changed

+653
-18
lines changed

15 files changed

+653
-18
lines changed

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.0.26"
3+
version = "0.0.27"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/errors/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from ._base_url_missing_error import BaseUrlMissingError
1818
from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException
19-
from ._enriched_exception import EnrichedException
19+
from ._enriched_exception import EnrichedException, ExtractedErrorInfo
2020
from ._folder_not_found_exception import FolderNotFoundException
2121
from ._ingestion_in_progress_exception import IngestionInProgressException
2222
from ._operation_failed_exception import OperationFailedException
@@ -28,10 +28,11 @@
2828
"BaseUrlMissingError",
2929
"BatchTransformNotCompleteException",
3030
"EnrichedException",
31+
"ExtractedErrorInfo",
3132
"FolderNotFoundException",
3233
"IngestionInProgressException",
33-
"SecretMissingError",
34-
"OperationNotCompleteException",
3534
"OperationFailedException",
35+
"OperationNotCompleteException",
36+
"SecretMissingError",
3637
"UnsupportedDataSourceException",
3738
]
Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,58 @@
1+
from dataclasses import dataclass
2+
from functools import cached_property
3+
14
from httpx import HTTPStatusError
25

36

7+
@dataclass(frozen=True)
8+
class ExtractedErrorInfo:
9+
message: str | None = None
10+
error_code: str | None = None
11+
trace_id: str | None = None
12+
13+
414
class EnrichedException(Exception):
515
"""Enriched HTTP error with detailed request/response information.
616
7-
This exception wraps HTTPStatusError and provides additional context about
8-
the failed HTTP request, including URL, method, status code, and response content.
17+
Wraps HTTPStatusError with URL, method, status code, and truncated response
18+
content in __str__. For structured error fields, use ``error_info`` which
19+
delegates to per-service extractors.
20+
21+
Does not retain a reference to the original HTTPStatusError — all needed
22+
data is eagerly extracted. Callers needing the raw response can still
23+
access ``__cause__`` (set by ``raise EnrichedException(e) from e``).
924
"""
1025

1126
def __init__(self, error: HTTPStatusError) -> None:
12-
# Extract the relevant details from the HTTPStatusError
13-
self.status_code = error.response.status_code if error.response else "Unknown"
14-
self.url = str(error.request.url) if error.request else "Unknown"
15-
self.http_method = (
27+
# while status code 0 is the correct one according to http standard;
28+
# it has a totally oposite meaning as return codes in CLIs;
29+
# opted for -1 to avoid confusion
30+
self.status_code: int = error.response.status_code if error.response else -1
31+
self.url: str = str(error.request.url) if error.request else "Unknown"
32+
self.http_method: str = (
1633
error.request.method
1734
if error.request and error.request.method
1835
else "Unknown"
1936
)
37+
38+
self._response_body: str | None = None
39+
self._content_type: str | None = None
40+
if error.response is not None:
41+
self._content_type = error.response.headers.get("content-type")
42+
if error.response.content:
43+
try:
44+
self._response_body = error.response.content.decode("utf-8")
45+
except Exception:
46+
pass
47+
2048
max_content_length = 200
21-
if error.response and error.response.content:
22-
content = error.response.content.decode("utf-8")
23-
if len(content) > max_content_length:
24-
self.response_content = content[:max_content_length] + "... (truncated)"
49+
if self._response_body:
50+
if len(self._response_body) > max_content_length:
51+
self.response_content = (
52+
self._response_body[:max_content_length] + "... (truncated)"
53+
)
2554
else:
26-
self.response_content = content
55+
self.response_content = self._response_body
2756
else:
2857
self.response_content = "No content"
2958

@@ -34,5 +63,13 @@ def __init__(self, error: HTTPStatusError) -> None:
3463
f"\nResponse Content: {self.response_content}"
3564
)
3665

37-
# Initialize the parent Exception class with the formatted message
3866
super().__init__(enriched_message)
67+
68+
@cached_property
69+
def error_info(self) -> ExtractedErrorInfo | None:
70+
"""Service-aware extraction of message, error_code, trace_id."""
71+
from ._extractors._router import extract_error_info
72+
73+
if not self._response_body:
74+
return None
75+
return extract_error_info(self.url, self._response_body, self._content_type)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""AgentHub / AgentsRuntime error payload extractor."""
2+
3+
from typing import Any
4+
5+
from .._enriched_exception import ExtractedErrorInfo
6+
from ._helpers import get_typed_field
7+
8+
9+
def extract_agenthub(body: dict[str, Any]) -> ExtractedErrorInfo:
10+
message = get_typed_field(body, str, "message", "title")
11+
error_code = get_typed_field(body, str, "errorCode", "code")
12+
trace_id = get_typed_field(body, str, "traceId")
13+
14+
problem = get_typed_field(body, dict, "problem")
15+
if problem is not None:
16+
message = message or get_typed_field(problem, str, "detail", "title")
17+
error_code = error_code or get_typed_field(problem, str, "errorCode", "type")
18+
trace_id = trace_id or get_typed_field(problem, str, "traceId")
19+
20+
return ExtractedErrorInfo(
21+
message=message,
22+
error_code=error_code,
23+
trace_id=trace_id,
24+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Apps service error payload extractor."""
2+
3+
from typing import Any
4+
5+
from .._enriched_exception import ExtractedErrorInfo
6+
from ._helpers import get_typed_field
7+
8+
9+
def extract_apps(body: dict[str, Any]) -> ExtractedErrorInfo:
10+
return ExtractedErrorInfo(
11+
message=get_typed_field(body, str, "errorMessageV2", "message"),
12+
error_code=get_typed_field(body, str, "errorCode", "code"),
13+
trace_id=get_typed_field(body, str, "traceId"),
14+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Elements service error payload extractor."""
2+
3+
from typing import Any
4+
5+
from .._enriched_exception import ExtractedErrorInfo
6+
from ._helpers import get_str_field, get_typed_field
7+
8+
9+
def extract_elements(body: dict[str, Any]) -> ExtractedErrorInfo:
10+
message = get_typed_field(body, str, "providerMessage") or get_typed_field(
11+
body, str, "message"
12+
)
13+
error_code = get_str_field(body, "providerErrorCode", "status")
14+
trace_id = get_typed_field(body, str, "requestId")
15+
16+
return ExtractedErrorInfo(
17+
message=message,
18+
error_code=error_code,
19+
trace_id=trace_id,
20+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Generic fallback error payload extractor.
2+
3+
Tries structural RFC 7807 detection first, then common field patterns
4+
covering Orchestrator, Connections, ECS, and most other services.
5+
"""
6+
7+
from typing import Any
8+
9+
from .._enriched_exception import ExtractedErrorInfo
10+
from ._helpers import get_field, get_str_field, get_typed_field
11+
from ._rfc7807 import extract_rfc7807, looks_like_rfc7807
12+
13+
14+
def extract_generic(body: dict[str, Any]) -> ExtractedErrorInfo:
15+
if looks_like_rfc7807(body):
16+
return extract_rfc7807(body)
17+
18+
message: str | None
19+
error = get_field(body, "error")
20+
if isinstance(error, str):
21+
message = error
22+
elif isinstance(error, dict):
23+
message = get_typed_field(error, str, "message")
24+
else:
25+
message = get_typed_field(body, str, "message")
26+
27+
# str() conversion handles services that return numeric error codes
28+
error_code = get_str_field(body, "errorCode", "code")
29+
trace_id = get_typed_field(body, str, "traceId", "requestId")
30+
31+
return ExtractedErrorInfo(
32+
message=message,
33+
error_code=error_code,
34+
trace_id=trace_id,
35+
)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Shared helpers for error payload extractors."""
2+
3+
from typing import Any, TypeVar
4+
from urllib.parse import urlparse
5+
6+
T = TypeVar("T")
7+
8+
9+
def get_field(body: dict[str, Any], *keys: str) -> Any:
10+
"""Return the first non-None value found for the given keys.
11+
12+
Automatically tries both camelCase and PascalCase for each key.
13+
"""
14+
for key in keys:
15+
val = body.get(key)
16+
if val is not None:
17+
return val
18+
val = body.get(key[0].swapcase() + key[1:])
19+
if val is not None:
20+
return val
21+
return None
22+
23+
24+
def get_typed_field(body: dict[str, Any], type_: type[T], *keys: str) -> T | None:
25+
"""Return the first non-None value matching *type_* for the given keys.
26+
27+
Skips values that don't match the expected type, trying the next key.
28+
"""
29+
for key in keys:
30+
val = body.get(key)
31+
if val is None:
32+
val = body.get(key[0].swapcase() + key[1:])
33+
if isinstance(val, type_):
34+
return val
35+
return None
36+
37+
38+
def get_str_field(body: dict[str, Any], *keys: str) -> str | None:
39+
"""Return the first scalar value for the given keys, converted to str.
40+
41+
Skips dicts, lists, and None.
42+
"""
43+
for key in keys:
44+
val = body.get(key)
45+
if val is None:
46+
val = body.get(key[0].swapcase() + key[1:])
47+
if val is not None and not isinstance(val, (dict, list)):
48+
return str(val)
49+
return None
50+
51+
52+
def extract_service_prefix(url: str) -> str | None:
53+
"""Extract the service prefix (e.g. 'orchestrator_') from a UiPath URL path."""
54+
path = urlparse(url).path if "://" in url else url
55+
for segment in path.strip("/").split("/"):
56+
if segment.endswith("_") and len(segment) > 1:
57+
return segment
58+
return None
59+
60+
61+
def is_llm_path(url: str) -> bool:
62+
"""Check if the URL targets the LLM Gateway."""
63+
return "/llm/" in url
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""LLM Gateway error payload extractor."""
2+
3+
from typing import Any
4+
5+
from .._enriched_exception import ExtractedErrorInfo
6+
from ._helpers import get_typed_field
7+
8+
9+
def extract_llm_gateway(body: dict[str, Any]) -> ExtractedErrorInfo:
10+
message = get_typed_field(body, str, "message")
11+
error_code = get_typed_field(body, str, "errorCode", "code")
12+
trace_id = get_typed_field(body, str, "traceId")
13+
14+
error = get_typed_field(body, dict, "error")
15+
if error is not None:
16+
message = message or get_typed_field(error, str, "message")
17+
error_code = error_code or get_typed_field(error, str, "code", "type")
18+
19+
return ExtractedErrorInfo(
20+
message=message,
21+
error_code=error_code,
22+
trace_id=trace_id,
23+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""LLMOps service error payload extractor."""
2+
3+
from typing import Any
4+
5+
from .._enriched_exception import ExtractedErrorInfo
6+
from ._helpers import get_typed_field
7+
8+
9+
def extract_llmops(body: dict[str, Any]) -> ExtractedErrorInfo:
10+
return ExtractedErrorInfo(
11+
message=get_typed_field(body, str, "errorMessage"),
12+
error_code=get_typed_field(body, str, "code"),
13+
trace_id=get_typed_field(body, str, "requestId"),
14+
)

0 commit comments

Comments
 (0)