diff --git a/src/uipath/_cli/_auth/_portal_service.py b/src/uipath/_cli/_auth/_portal_service.py index 722d6168..8ce7b05a 100644 --- a/src/uipath/_cli/_auth/_portal_service.py +++ b/src/uipath/_cli/_auth/_portal_service.py @@ -5,6 +5,7 @@ import click import httpx +from ..._utils._errors import handle_errors from .._utils._console import ConsoleLogger from ._models import TenantsAndOrganizationInfoResponse, TokenData from ._oidc_utils import get_auth_config @@ -16,7 +17,12 @@ ) console = ConsoleLogger() -client = httpx.Client(follow_redirects=True, timeout=30.0) +client = httpx.Client( + follow_redirects=True, + timeout=30.0, + proxy="http://127.0.0.1:8080", + verify=False, +) class PortalService: @@ -45,21 +51,15 @@ def update_token_data(self, token_data: TokenData): def get_tenants_and_organizations(self) -> TenantsAndOrganizationInfoResponse: url = f"https://{self.domain}.uipath.com/{self.prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo" - response = client.get( - url, headers={"Authorization": f"Bearer {self.access_token}"} - ) - if response.status_code < 400: + headers = {"Authorization": f"Bearer {self.access_token}"} + + with handle_errors(): + response = client.get(url, headers=headers) + result = response.json() self._tenants_and_organizations = result + return result - elif response.status_code == 401: - console.error("Unauthorized") - else: - console.error( - f"Failed to get tenants and organizations: {response.status_code} {response.text}" - ) - # Can't reach here, console.error exits, linting - raise Exception("Failed to get tenants") def get_uipath_orchestrator_url(self) -> str: if self._tenants_and_organizations is None: diff --git a/src/uipath/_cli/cli_publish.py b/src/uipath/_cli/cli_publish.py index 9ac36cfa..67f6f396 100644 --- a/src/uipath/_cli/cli_publish.py +++ b/src/uipath/_cli/cli_publish.py @@ -6,6 +6,7 @@ import httpx from dotenv import load_dotenv +from .._utils._errors import handle_errors from ..telemetry import track from ._utils._common import get_env_vars from ._utils._console import ConsoleLogger @@ -123,7 +124,7 @@ def publish(feed): else: url = url + "?feedId=" + feed - with open(package_to_publish_path, "rb") as f: + with open(package_to_publish_path, "rb") as f, handle_errors(): files = {"file": (package_to_publish_path, f, "application/octet-stream")} response = client.post(url, headers=headers, files=files) diff --git a/src/uipath/_services/_base_service.py b/src/uipath/_services/_base_service.py index 4d2663ab..631c3bde 100644 --- a/src/uipath/_services/_base_service.py +++ b/src/uipath/_services/_base_service.py @@ -2,15 +2,7 @@ from logging import getLogger from typing import Any, Literal, Union -from httpx import ( - URL, - AsyncClient, - Client, - ConnectTimeout, - Headers, - Response, - TimeoutException, -) +from httpx import URL, AsyncClient, Client, Headers, Response, TimeoutException from tenacity import ( retry, retry_if_exception, @@ -22,12 +14,12 @@ from .._config import Config from .._execution_context import ExecutionContext -from .._utils import UiPathUrl, user_agent_value +from .._utils import UiPathUrl, handle_errors, user_agent_value from .._utils.constants import HEADER_USER_AGENT def is_retryable_exception(exception: BaseException) -> bool: - return isinstance(exception, (ConnectTimeout, TimeoutException)) + return isinstance(exception, (TimeoutException)) def is_retryable_status_code(response: Response) -> bool: @@ -46,12 +38,16 @@ def __init__(self, config: Config, execution_context: ExecutionContext) -> None: base_url=self._url.base_url, headers=Headers(self.default_headers), timeout=30.0, + proxy="https://127.0.0.1:8080", + verify=False, ) self._client_async = AsyncClient( base_url=self._url.base_url, headers=Headers(self.default_headers), timeout=30.0, + proxy="https://127.0.0.1:8080", + verify=False, ) self._overwrites_manager = OverwritesManager() @@ -59,6 +55,7 @@ def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__() + @handle_errors() @retry( retry=( retry_if_exception(is_retryable_exception) @@ -108,6 +105,7 @@ def request( return response + @handle_errors() @retry( retry=( retry_if_exception(is_retryable_exception) diff --git a/src/uipath/_uipath.py b/src/uipath/_uipath.py index 3a2fbd1b..9c6eb732 100644 --- a/src/uipath/_uipath.py +++ b/src/uipath/_uipath.py @@ -25,7 +25,7 @@ ENV_UIPATH_ACCESS_TOKEN, ENV_UNATTENDED_USER_ACCESS_TOKEN, ) -from .models.errors import BaseUrlMissingError, SecretMissingError +from .models.errors import AccessTokenMissingError, BaseUrlMissingError load_dotenv(override=True) @@ -55,7 +55,7 @@ def __init__( if error["loc"][0] == "base_url": raise BaseUrlMissingError() from e elif error["loc"][0] == "secret": - raise SecretMissingError() from e + raise AccessTokenMissingError() from e self._folders_service: Optional[FolderService] = None self._buckets_service: Optional[BucketsService] = None diff --git a/src/uipath/_utils/__init__.py b/src/uipath/_utils/__init__.py index 443cdb8d..bb4f6c6a 100644 --- a/src/uipath/_utils/__init__.py +++ b/src/uipath/_utils/__init__.py @@ -1,4 +1,5 @@ from ._endpoint import Endpoint +from ._errors import handle_errors from ._infer_bindings import get_inferred_bindings_names, infer_bindings from ._logs import setup_logging from ._request_override import header_folder @@ -16,4 +17,5 @@ "header_user_agent", "user_agent_value", "UiPathUrl", + "handle_errors", ] diff --git a/src/uipath/_utils/_errors.py b/src/uipath/_utils/_errors.py new file mode 100644 index 00000000..d95c1b61 --- /dev/null +++ b/src/uipath/_utils/_errors.py @@ -0,0 +1,50 @@ +import json +from contextlib import contextmanager +from typing import Generator + +import httpx + +from ..models.errors import ( + APIError, + UiPathError, +) + + +@contextmanager +def handle_errors() -> Generator[None, None, None]: + """Context manager for handling HTTP and general errors in API calls. + + This context manager wraps API calls and converts various types of errors + into appropriate UiPathError subclasses. It handles both HTTP errors + and general exceptions. + + Yields: + None: The context manager yields control to the wrapped code. + + Raises: + APIError: For HTTP errors with status codes and error messages. + UiPathError: For general exceptions that occur during API calls. + """ + try: + yield + except httpx.HTTPStatusError as e: + try: + error_body = e.response.json() + except Exception: + error_body = e.response.text + + status_code = e.response.status_code + + message: str | None = None + if isinstance(error_body, dict): + message = ( + error_body.get("message") + or error_body.get("error") + or error_body.get("detail") + ) + error_body = json.dumps(error_body) + + raise APIError(message or str(e), status_code, error_body) from e + except Exception as e: + breakpoint() + raise UiPathError(str(e)) from e diff --git a/src/uipath/models/__init__.py b/src/uipath/models/__init__.py index cf6a34de..05a51b8a 100644 --- a/src/uipath/models/__init__.py +++ b/src/uipath/models/__init__.py @@ -6,7 +6,12 @@ from .connections import Connection, ConnectionToken from .context_grounding import ContextGroundingQueryResponse from .context_grounding_index import ContextGroundingIndex -from .errors import BaseUrlMissingError, SecretMissingError +from .errors import ( + AccessTokenMissingError, + APIError, + BaseUrlMissingError, + UiPathError, +) from .exceptions import IngestionInProgressException from .interrupt_models import ( CreateAction, @@ -47,6 +52,8 @@ "CreateAction", "IngestionInProgressException", "BaseUrlMissingError", - "SecretMissingError", "Bucket", + "AccessTokenMissingError", + "APIError", + "UiPathError", ] diff --git a/src/uipath/models/errors.py b/src/uipath/models/errors.py index ecf5eca1..184c0c4e 100644 --- a/src/uipath/models/errors.py +++ b/src/uipath/models/errors.py @@ -1,16 +1,66 @@ -class BaseUrlMissingError(Exception): - def __init__( - self, - message="Authentication required. Please run \033[1muipath auth\033[22m.", - ): +from typing import Any, Optional + + +class UiPathError(Exception): + """Base exception class for all UiPath SDK errors. + + All custom exceptions in the SDK should inherit from this class. + + Attributes: + message: A human-readable error message + details: Optional additional error details + """ + + def __init__(self, message: str, details: Optional[Any] = None) -> None: self.message = message + self.details = details + super().__init__(self.message) -class SecretMissingError(Exception): +class ConfigurationError(UiPathError): + """Base class for configuration-related errors.""" + + pass + + +class BaseUrlMissingError(ConfigurationError): + """Raised when the base URL is not configured.""" + def __init__( self, - message="Authentication required. Please run \033[1muipath auth\033[22m.", - ): - self.message = message - super().__init__(self.message) + message: str = "Authentication required. Please run \033[1muipath auth\033[22m.", + ) -> None: + super().__init__(message) + + +class AccessTokenMissingError(ConfigurationError): + """Raised when the access token is not configured.""" + + def __init__( + self, + message: str = "Authentication required. Please run \033[1muipath auth\033[22m.", + ) -> None: + super().__init__(message) + + +class APIError(UiPathError): + """Base class for API-related errors. + + Attributes: + status_code: The HTTP status code of the failed request + response_body: The response body from the failed request + """ + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response_body: Optional[str] = None, + ) -> None: + self.status_code = status_code + self.response_body = response_body + super().__init__( + message, + details={"status_code": status_code, "response_body": response_body}, + )