diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a304aca15..9dd30533b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index fa3c0da9e..31e1a2de3 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -24,6 +24,7 @@ from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..errors._datafabric_error import attach_datafabric_error_mapping from ..orchestrator._folder_service import FolderService from ._entity_data_service import EntityDataService, FileContent from ._entity_resolution import ( @@ -1788,6 +1789,7 @@ async def retrieve_records_async( limit=limit, ) + @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -1812,6 +1814,7 @@ def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: """ return self._data.query_entity_records(sql_query) + @attach_datafabric_error_mapping("query_entity_records_async") @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py index 97f7e6d98..5ac51d326 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -22,6 +22,7 @@ from ._context_grounding_index_not_found_exception import ( ContextGroundingIndexNotFoundError, ) +from ._datafabric_error import DataFabricError from ._enriched_exception import EnrichedException, ExtractedErrorInfo from ._folder_not_found_exception import FolderNotFoundException from ._ingestion_in_progress_exception import IngestionInProgressException @@ -29,9 +30,12 @@ from ._operation_not_complete_exception import OperationNotCompleteException from ._secret_missing_error import SecretMissingError from ._unsupported_data_source_exception import UnsupportedDataSourceException +from .datafabric_error_codes import DataFabricErrorCategory __all__ = [ "BaseUrlMissingError", + "DataFabricError", + "DataFabricErrorCategory", "BatchTransformFailedException", "BatchTransformNotCompleteException", "ContextGroundingIndexNotFoundError", diff --git a/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py new file mode 100644 index 000000000..e697ac164 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py @@ -0,0 +1,109 @@ +"""Data Fabric query engine error classification. + +Maps error codes from the DF query engine invoking the "query_execute" endpoint to actionable +categories so that callers (e.g. the agent SQL sub-graph) can decide +whether to retry, ask the LLM to fix the SQL, or surface an infra error. + +The server error response JSON has the shape: + {"error": "", "code": "", "traceId": ""} +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +from ._extractors._helpers import extract_service_prefix +from .datafabric_error_codes import ( + _QUERY_ENTITY_RECORDS_ERROR_CODES, + DataFabricErrorCategory, + classify_error_code, +) + +if TYPE_CHECKING: + from ._enriched_exception import EnrichedException + + +TCallable = TypeVar("TCallable", bound=Callable[..., Any]) + + +def attach_datafabric_error_mapping( + method_name: str, +) -> Callable[[TCallable], TCallable]: + """Attach Data Fabric error metadata to a query method.""" + + def decorator(func: TCallable) -> TCallable: + func.__uipath_datafabric_method__ = method_name # type: ignore[attr-defined] + func.__uipath_datafabric_error_codes__ = ( # type: ignore[attr-defined] + _QUERY_ENTITY_RECORDS_ERROR_CODES + ) + return func + + return decorator + + +@dataclass(frozen=True) +class DataFabricError: + """Structured error parsed from a DF query engine response.""" + + code: str | None + message: str | None + trace_id: str | None + category: DataFabricErrorCategory + + @property + def is_retryable(self) -> bool: + return self.category == DataFabricErrorCategory.RETRYABLE + + @property + def is_bad_sql(self) -> bool: + return self.category == DataFabricErrorCategory.BAD_SQL + + @staticmethod + def from_enriched_exception(exc: EnrichedException) -> DataFabricError | None: + """Extract a DataFabricError from an EnrichedException, if applicable. + + Returns None if the exception is not from a Data Fabric endpoint. + """ + if extract_service_prefix(exc.url) != "datafabric_": + return None + + info = exc.error_info + code = info.error_code if info else None + message = info.message if info else None + trace_id = info.trace_id if info else None + + return DataFabricError( + code=code, + message=message, + trace_id=trace_id, + category=classify_error_code(code), + ) + + @staticmethod + def from_response_body(body: dict[str, Any]) -> DataFabricError: + """Parse a DataFabricError directly from a response body dict.""" + raw_code = body.get("code") + code = ( + str(raw_code) + if raw_code is not None and not isinstance(raw_code, (dict, list)) + else None + ) + message = body.get("error") + if not isinstance(message, str): + message = ( + body.get("message") if isinstance(body.get("message"), str) else None + ) + trace_id = body.get("traceId") + if not isinstance(trace_id, str): + trace_id = ( + body.get("requestId") + if isinstance(body.get("requestId"), str) + else None + ) + return DataFabricError( + code=code, + message=message, + trace_id=trace_id, + category=classify_error_code(code), + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py new file mode 100644 index 000000000..57682a24f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_datafabric.py @@ -0,0 +1,21 @@ +"""Data Fabric query engine error payload extractor. + +DF returns: {"error": "", "code": "", "traceId": ""} +""" + +from typing import Any + +from .._enriched_exception import ExtractedErrorInfo +from ._helpers import get_str_field, get_typed_field + + +def extract_datafabric(body: dict[str, Any]) -> ExtractedErrorInfo: + message = get_typed_field(body, str, "error", "message") + error_code = get_str_field(body, "code", "errorCode") + trace_id = get_typed_field(body, str, "traceId", "requestId") + + return ExtractedErrorInfo( + message=message, + error_code=error_code, + trace_id=trace_id, + ) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py index 37369113b..901dbc089 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_extractors/_router.py @@ -14,6 +14,7 @@ from .._enriched_exception import ExtractedErrorInfo from ._agenthub import extract_agenthub from ._apps import extract_apps +from ._datafabric import extract_datafabric from ._elements import extract_elements from ._generic import extract_generic from ._helpers import extract_service_prefix, is_llm_path @@ -29,6 +30,7 @@ "agenthub_": extract_agenthub, "agentsruntime_": extract_agenthub, "apps_": extract_apps, + "datafabric_": extract_datafabric, "elements_": extract_elements, "llmopstenant_": extract_llmops, } diff --git a/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py new file mode 100644 index 000000000..81130db95 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py @@ -0,0 +1,69 @@ +"""Data Fabric query-engine error code constants.""" + +from __future__ import annotations + +from enum import Enum + +_RETRYABLE_CODES: frozenset[str] = frozenset( + { + "EXECUTION_TIMEOUT", + "SQLITE_BUSY", + "EXECUTION_INTERRUPTED", + } +) + +_BAD_SQL_CODES: frozenset[str] = frozenset( + { + "SQL_PARSING", + "SQL_VALIDATION", + } +) + +_INFRASTRUCTURE_CODES: frozenset[str] = frozenset( + { + "SQLITE_MEMORY_FULL", + "EPHEMERAL_STORAGE_ERROR", + "INTERNAL_ERROR", + "FQS_ERROR", + } +) + +_DATA_ISSUE_CODES: frozenset[str] = frozenset( + { + "FRAGMENT_EXECUTION_FAILURE", + "CONTEXT_CREATION", + "UNKNOWN_ENTITY", + "EXECUTION_ERROR", + "RESULT_TOO_LARGE", + } +) + +_QUERY_ENTITY_RECORDS_ERROR_CODES: frozenset[str] = frozenset( + {*_RETRYABLE_CODES, *_BAD_SQL_CODES, *_INFRASTRUCTURE_CODES, *_DATA_ISSUE_CODES} +) + + +class DataFabricErrorCategory(str, Enum): + """Actionable error category for Data Fabric query failures.""" + + RETRYABLE = "retryable" + BAD_SQL = "bad_sql" + INFRASTRUCTURE = "infrastructure" + DATA_ISSUE = "data_issue" + UNKNOWN = "unknown" + + +def classify_error_code(code: str | None) -> DataFabricErrorCategory: + """Classify a DF error code string into an actionable category.""" + if not code: + return DataFabricErrorCategory.UNKNOWN + upper = code.upper() + if upper in _RETRYABLE_CODES: + return DataFabricErrorCategory.RETRYABLE + if upper in _BAD_SQL_CODES: + return DataFabricErrorCategory.BAD_SQL + if upper in _INFRASTRUCTURE_CODES: + return DataFabricErrorCategory.INFRASTRUCTURE + if upper in _DATA_ISSUE_CODES: + return DataFabricErrorCategory.DATA_ISSUE + return DataFabricErrorCategory.UNKNOWN diff --git a/packages/uipath-platform/tests/errors/test_datafabric_errors.py b/packages/uipath-platform/tests/errors/test_datafabric_errors.py new file mode 100644 index 000000000..39c477ab5 --- /dev/null +++ b/packages/uipath-platform/tests/errors/test_datafabric_errors.py @@ -0,0 +1,189 @@ +"""Tests for Data Fabric error classification, extraction, and routing.""" + +import json + +import httpx + +from uipath.platform.errors import ( + DataFabricError, + DataFabricErrorCategory, + EnrichedException, +) +from uipath.platform.errors._extractors._datafabric import extract_datafabric +from uipath.platform.errors._extractors._router import extract_error_info +from uipath.platform.errors.datafabric_error_codes import classify_error_code + +_DATAFABRIC_URL = "https://cloud.uipath.com/org/tenant/datafabric_/api/v1" +_NON_DF_URL = "https://cloud.uipath.com/org/tenant/orchestrator_/api/v1" + + +def _make_enriched( + url: str = _DATAFABRIC_URL, + body: str = "{}", + status_code: int = 400, +) -> EnrichedException: + raw = httpx.HTTPStatusError( + message=f"Server error {status_code}", + request=httpx.Request("POST", url), + response=httpx.Response( + status_code, + content=body.encode(), + headers={"content-type": "application/json"}, + ), + ) + return EnrichedException(raw) + + +# ---------- classify_error_code ---------- + + +class TestClassifyErrorCode: + def test_retryable_codes(self) -> None: + for code in ("EXECUTION_TIMEOUT", "SQLITE_BUSY", "EXECUTION_INTERRUPTED"): + assert classify_error_code(code) == DataFabricErrorCategory.RETRYABLE + + def test_bad_sql_codes(self) -> None: + for code in ("SQL_PARSING", "SQL_VALIDATION"): + assert classify_error_code(code) == DataFabricErrorCategory.BAD_SQL + + def test_infrastructure_codes(self) -> None: + for code in ( + "SQLITE_MEMORY_FULL", + "EPHEMERAL_STORAGE_ERROR", + "INTERNAL_ERROR", + "FQS_ERROR", + ): + assert classify_error_code(code) == DataFabricErrorCategory.INFRASTRUCTURE + + def test_data_issue_codes(self) -> None: + for code in ( + "FRAGMENT_EXECUTION_FAILURE", + "CONTEXT_CREATION", + "UNKNOWN_ENTITY", + "EXECUTION_ERROR", + "RESULT_TOO_LARGE", + ): + assert classify_error_code(code) == DataFabricErrorCategory.DATA_ISSUE + + def test_unknown_code(self) -> None: + assert classify_error_code("NEVER_HEARD_OF") == DataFabricErrorCategory.UNKNOWN + + def test_none_code(self) -> None: + assert classify_error_code(None) == DataFabricErrorCategory.UNKNOWN + + def test_empty_string(self) -> None: + assert classify_error_code("") == DataFabricErrorCategory.UNKNOWN + + def test_case_insensitive(self) -> None: + assert classify_error_code("sql_parsing") == DataFabricErrorCategory.BAD_SQL + assert ( + classify_error_code("Execution_Timeout") + == DataFabricErrorCategory.RETRYABLE + ) + + +# ---------- DataFabricError ---------- + + +class TestDataFabricError: + def test_is_retryable(self) -> None: + err = DataFabricError( + code="EXECUTION_TIMEOUT", + message="timed out", + trace_id="abc", + category=DataFabricErrorCategory.RETRYABLE, + ) + assert err.is_retryable is True + assert err.is_bad_sql is False + + def test_is_bad_sql(self) -> None: + err = DataFabricError( + code="SQL_PARSING", + message="bad sql", + trace_id="abc", + category=DataFabricErrorCategory.BAD_SQL, + ) + assert err.is_bad_sql is True + assert err.is_retryable is False + + def test_from_response_body(self) -> None: + body = { + "error": "something went wrong", + "code": "INTERNAL_ERROR", + "traceId": "trace-123", + } + err = DataFabricError.from_response_body(body) + assert err.code == "INTERNAL_ERROR" + assert err.message == "something went wrong" + assert err.trace_id == "trace-123" + assert err.category == DataFabricErrorCategory.INFRASTRUCTURE + + def test_from_response_body_missing_fields(self) -> None: + err = DataFabricError.from_response_body({}) + assert err.code is None + assert err.message is None + assert err.trace_id is None + assert err.category == DataFabricErrorCategory.UNKNOWN + + def test_from_enriched_exception_datafabric_url(self) -> None: + body = json.dumps({"error": "bad sql", "code": "SQL_PARSING", "traceId": "t-1"}) + exc = _make_enriched(url=_DATAFABRIC_URL, body=body) + err = DataFabricError.from_enriched_exception(exc) + assert err is not None + assert err.code == "SQL_PARSING" + assert err.message == "bad sql" + assert err.trace_id == "t-1" + assert err.category == DataFabricErrorCategory.BAD_SQL + + def test_from_enriched_exception_non_datafabric_url_returns_none(self) -> None: + body = json.dumps({"error": "oops", "code": "SQL_PARSING"}) + exc = _make_enriched(url=_NON_DF_URL, body=body) + assert DataFabricError.from_enriched_exception(exc) is None + + def test_from_enriched_exception_no_error_info(self) -> None: + exc = _make_enriched(url=_DATAFABRIC_URL, body="not json at all {{{") + err = DataFabricError.from_enriched_exception(exc) + assert err is not None + assert err.code is None + assert err.message is None + assert err.category == DataFabricErrorCategory.UNKNOWN + + +# ---------- extract_datafabric ---------- + + +class TestExtractDatafabric: + def test_extracts_all_fields(self) -> None: + body = {"error": "msg", "code": "SQL_PARSING", "traceId": "t-1"} + info = extract_datafabric(body) + assert info.message == "msg" + assert info.error_code == "SQL_PARSING" + assert info.trace_id == "t-1" + + def test_falls_back_to_message_key(self) -> None: + body = {"message": "fallback msg", "code": "X"} + info = extract_datafabric(body) + assert info.message == "fallback msg" + + def test_missing_fields(self) -> None: + info = extract_datafabric({}) + assert info.message is None + assert info.error_code is None + assert info.trace_id is None + + +# ---------- Router: datafabric prefix ---------- + + +class TestRouterDatafabric: + def test_routes_to_datafabric_extractor(self) -> None: + body = json.dumps( + {"error": "timeout", "code": "EXECUTION_TIMEOUT", "traceId": "t-2"} + ) + info = extract_error_info(_DATAFABRIC_URL, body) + assert info is not None + assert info.error_code == "EXECUTION_TIMEOUT" + assert info.trace_id == "t-2" + + def test_non_json_returns_none(self) -> None: + assert extract_error_info(_DATAFABRIC_URL, "not json") is None diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 258c3a9d8..2bef45193 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -54,6 +54,19 @@ def record_schema_optional(request): class TestEntitiesService: + def test_query_entity_records_has_datafabric_error_mapping(self) -> None: + assert ( + EntitiesService.query_entity_records.__uipath_datafabric_method__ # type: ignore[attr-defined] + == "query_entity_records" + ) + assert ( + EntitiesService.query_entity_records.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + == EntitiesService.query_entity_records_async.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + ) + assert "SQL_PARSING" in ( + EntitiesService.query_entity_records.__uipath_datafabric_error_codes__ # type: ignore[attr-defined] + ) + def test_retrieve( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3424b09e9..5ae4cb5d6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f40bccd88..87ca9e781 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.89" +version = "0.1.90" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },