Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.17"
version = "0.2.18"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
10 changes: 9 additions & 1 deletion packages/uipath-platform/src/uipath/platform/common/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
"""

import random
from http import HTTPMethod

from httpx import ConnectTimeout, HTTPStatusError, Response, TimeoutException
from tenacity import RetryCallState

from ..errors import EnrichedException

RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({408, 429, 502, 503, 504, 524})
RETRYABLE_STATUS_CODES_ON_GET_ONLY: frozenset[int] = frozenset({500})
NON_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({400, 401, 403, 404, 413, 422})


Expand Down Expand Up @@ -70,7 +72,13 @@ def is_retryable_platform_exception(exception: BaseException) -> bool:
if isinstance(exception, (ConnectTimeout, TimeoutException)):
return True
if isinstance(exception, EnrichedException):
return exception.status_code in RETRYABLE_STATUS_CODES
if exception.status_code in RETRYABLE_STATUS_CODES:
return True
if (
exception.status_code in RETRYABLE_STATUS_CODES_ON_GET_ONLY
and exception.http_method.upper() == HTTPMethod.GET
):
return True
return False


Expand Down
39 changes: 37 additions & 2 deletions packages/uipath-platform/tests/services/test_base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def test_404_not_retried(
assert exc_info.value.status_code == 404
assert len(httpx_mock.get_requests()) == 1

def test_500_not_retried(
def test_500_not_retried_for_post(
self,
httpx_mock: HTTPXMock,
service: BaseService,
Expand All @@ -193,10 +193,45 @@ def test_500_not_retried(
httpx_mock.add_response(url=url, status_code=500)

with pytest.raises(EnrichedException) as exc_info:
service.request("GET", "/endpoint")
service.request("POST", "/endpoint")
assert exc_info.value.status_code == 500
assert len(httpx_mock.get_requests()) == 1

def test_500_retried_for_get(
self,
httpx_mock: HTTPXMock,
service: BaseService,
base_url: str,
org: str,
tenant: str,
):
url = self._url(base_url, org, tenant)
httpx_mock.add_response(url=url, status_code=500, headers={"retry-after": "0"})
httpx_mock.add_response(url=url, status_code=200, json={"ok": True})

response = service.request("GET", "/endpoint")
assert response.json() == {"ok": True}
assert len(httpx_mock.get_requests()) == 2

def test_500_max_retries_exhausted_for_get(
self,
httpx_mock: HTTPXMock,
service: BaseService,
base_url: str,
org: str,
tenant: str,
):
url = self._url(base_url, org, tenant)
for _ in range(5):
httpx_mock.add_response(
url=url, status_code=500, headers={"retry-after": "0"}
)

with pytest.raises(EnrichedException) as exc_info:
service.request("GET", "/endpoint")
assert exc_info.value.status_code == 500
assert len(httpx_mock.get_requests()) == 5

def test_max_retries_exhausted(
self,
httpx_mock: HTTPXMock,
Expand Down
10 changes: 6 additions & 4 deletions packages/uipath-platform/tests/services/test_buckets_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,10 +478,12 @@ def test_exists_propagates_network_errors(
tenant: str,
):
"""Test exists() propagates non-LookupError exceptions."""
httpx_mock.add_response(
url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'error-bucket'&$top=1",
status_code=500,
)
for _ in range(5):
httpx_mock.add_response(
url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'error-bucket'&$top=1",
status_code=500,
headers={"retry-after": "0"},
)

# Should raise exception (not return False)
from uipath.platform.errors import EnrichedException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,13 @@ def test_raises_on_http_error(
) -> None:
from uipath.platform.errors import EnrichedException

httpx_mock.add_response(
url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy",
status_code=500,
text="boom",
)
for _ in range(5):
httpx_mock.add_response(
url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/policy",
status_code=500,
text="boom",
headers={"retry-after": "0"},
)

with pytest.raises(EnrichedException):
service.retrieve_policy()
Expand Down
20 changes: 12 additions & 8 deletions packages/uipath-platform/tests/services/test_hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,10 +501,12 @@ async def test_read_api_trigger_failure(
"""Test reading an API trigger with a failed response."""
inbox_id = str(uuid.uuid4())

httpx_mock.add_response(
url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}",
status_code=500,
)
for _ in range(5):
httpx_mock.add_response(
url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}",
status_code=500,
headers={"retry-after": "0"},
)

resume_trigger = UiPathResumeTrigger(
trigger_type=UiPathResumeTriggerType.API,
Expand Down Expand Up @@ -580,10 +582,12 @@ async def test_read_inbox_trigger_failure(
"""Test reading an Inbox trigger with a failed payload response."""
inbox_id = str(uuid.uuid4())

httpx_mock.add_response(
url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}",
status_code=500,
)
for _ in range(5):
httpx_mock.add_response(
url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}",
status_code=500,
headers={"retry-after": "0"},
)

resume_trigger = UiPathResumeTrigger(
trigger_type=UiPathResumeTriggerType.INBOX,
Expand Down
25 changes: 21 additions & 4 deletions packages/uipath-platform/tests/services/test_retry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from http import HTTPMethod

import httpx
from tenacity import Future, RetryCallState, Retrying

Expand Down Expand Up @@ -116,15 +118,17 @@ def test_negative_retry_after_ignored(self):


def _make_http_status_error(
status_code: int, retry_after: str | None = None
status_code: int,
retry_after: str | None = None,
method: HTTPMethod = HTTPMethod.GET,
) -> httpx.HTTPStatusError:
headers = {}
if retry_after is not None:
headers["retry-after"] = retry_after
response = httpx.Response(
status_code=status_code,
headers=headers,
request=httpx.Request("GET", "https://example.com"),
request=httpx.Request(method, "https://example.com"),
)
return httpx.HTTPStatusError(
message=f"{status_code}", request=response.request, response=response
Expand Down Expand Up @@ -184,11 +188,24 @@ def test_enriched_400_not_retryable(self):
err = EnrichedException(http_err)
assert is_retryable_platform_exception(err) is False

def test_enriched_500_not_retryable(self):
http_err = _make_http_status_error(500)
def test_enriched_500_post_not_retryable(self):
http_err = _make_http_status_error(500, method=HTTPMethod.POST)
err = EnrichedException(http_err)
assert is_retryable_platform_exception(err) is False

def test_enriched_500_get_retryable(self):
http_err = _make_http_status_error(500, method=HTTPMethod.GET)
err = EnrichedException(http_err)
assert is_retryable_platform_exception(err) is True

def test_enriched_500_get_lowercase_retryable(self):
# httpx.Request normalizes method casing itself, so set http_method
# directly to exercise our own case-insensitive comparison.
http_err = _make_http_status_error(500, method=HTTPMethod.GET)
err = EnrichedException(http_err)
err.http_method = "get"
assert is_retryable_platform_exception(err) is True

def test_raw_http_error_not_matched(self):
err = _make_http_status_error(429)
assert is_retryable_platform_exception(err) is False
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading