From 449f781b57e982f97679dec018c5a7bfd35487b0 Mon Sep 17 00:00:00 2001 From: chandhan shantharaju Date: Fri, 7 Aug 2026 10:27:59 -0700 Subject: [PATCH 1/2] fix(uipath-platform): retry 500 responses for GET requests GET is idempotent, so a bare 500 can be safely retried when the request is a GET, even though it's left non-retryable for other methods since it may indicate a non-idempotent, non-transient server bug. Co-Authored-By: Claude Sonnet 5 --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/retry.py | 10 +++++++- .../tests/services/test_retry.py | 25 ++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eab210d34..b12e287e3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/common/retry.py b/packages/uipath-platform/src/uipath/platform/common/retry.py index ff4b5a064..fa1ce0640 100644 --- a/packages/uipath-platform/src/uipath/platform/common/retry.py +++ b/packages/uipath-platform/src/uipath/platform/common/retry.py @@ -5,6 +5,7 @@ """ import random +from http import HTTPMethod from httpx import ConnectTimeout, HTTPStatusError, Response, TimeoutException from tenacity import RetryCallState @@ -12,6 +13,7 @@ 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}) @@ -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 diff --git a/packages/uipath-platform/tests/services/test_retry.py b/packages/uipath-platform/tests/services/test_retry.py index ab893f042..7f9e00c99 100644 --- a/packages/uipath-platform/tests/services/test_retry.py +++ b/packages/uipath-platform/tests/services/test_retry.py @@ -1,3 +1,5 @@ +from http import HTTPMethod + import httpx from tenacity import Future, RetryCallState, Retrying @@ -116,7 +118,9 @@ 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: @@ -124,7 +128,7 @@ def _make_http_status_error( 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 @@ -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 From 20d3abe0181d067855b11244c56770859bc96e87 Mon Sep 17 00:00:00 2001 From: chandhan shantharaju Date: Sat, 8 Aug 2026 18:32:09 -0700 Subject: [PATCH 2/2] fix(uipath-platform): update tests for GET-500-retry, exhaust mocks Several tests mocked a single 500 on a GET request expecting an immediate failure. Now that GET requests retry on 500, these mocks were exhausted mid-retry, causing pytest_httpx assertion errors. Register enough 500 responses (with retry-after: 0 to skip real backoff) to match the retry budget, and add explicit coverage for GET-500 retry and retry exhaustion in test_base_service.py. Co-Authored-By: Claude Sonnet 5 --- .../tests/services/test_base_service.py | 39 ++++++++++++++++++- .../tests/services/test_buckets_service.py | 10 +++-- .../tests/services/test_governance_service.py | 12 +++--- .../tests/services/test_hitl.py | 20 ++++++---- packages/uipath-platform/uv.lock | 4 +- 5 files changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py index db39ed313..397c02692 100644 --- a/packages/uipath-platform/tests/services/test_base_service.py +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -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, @@ -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, diff --git a/packages/uipath-platform/tests/services/test_buckets_service.py b/packages/uipath-platform/tests/services/test_buckets_service.py index 464d675d2..59c5b99c0 100644 --- a/packages/uipath-platform/tests/services/test_buckets_service.py +++ b/packages/uipath-platform/tests/services/test_buckets_service.py @@ -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 diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index eb4941faf..fc94ef82c 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -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() diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py index f0c463251..6499b5f59 100644 --- a/packages/uipath-platform/tests/services/test_hitl.py +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -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, @@ -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, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2e506b0af..99067a7a6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:36.9681123Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "." } dependencies = [ { name = "anyio" },