From 647549ee63a5e68fc4e0f0011c66a05bf2015a95 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Wed, 27 May 2026 15:45:48 +0300 Subject: [PATCH 1/2] feat: retry HTTP 408/502/503/504 by default and default max_retries to 3 Expands the default retry-on set in uipath.llm_client.utils.retry from {429, 529} to {408, 429, 502, 503, 504, 529} and adds the two new exception classes (UiPathRequestTimeoutError, UiPathBadGatewayError) needed to type 408/502 responses. Raises the default max_retries in UiPathHttpxClient/UiPathHttpxAsyncClient (when left as None) and on UiPathBaseLLMClient from 0 to 3, so every provider (OpenAI, Anthropic in all four flavors, Google, all three LangChain Bedrock variants, Vertex AI, Azure OpenAI, Fireworks, LiteLLM) retries transient failures out of the box. Callers can still opt out by passing max_retries=0. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++++ packages/uipath_langchain_client/CHANGELOG.md | 6 +++ .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/__version__.py | 2 +- .../uipath_langchain_client/base_client.py | 4 +- src/uipath/llm_client/__init__.py | 4 ++ src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/httpx_client.py | 12 +++-- src/uipath/llm_client/utils/exceptions.py | 16 ++++++ src/uipath/llm_client/utils/retry.py | 11 ++++- tests/core/features/test_exceptions.py | 4 ++ tests/core/features/test_httpx_client.py | 34 +++++++++++++ tests/core/features/test_retry.py | 23 +++++++++ tests/core/smoke_test.py | 8 +++ .../features/test_default_max_retries.py | 49 +++++++++++++++++++ 15 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 tests/langchain/features/test_default_max_retries.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ff954..451dae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.13.0] - 2026-05-27 + +### Added +- `UiPathRequestTimeoutError` (HTTP 408) and `UiPathBadGatewayError` (HTTP 502) exception classes. Both are registered in `_STATUS_CODE_TO_EXCEPTION`, re-exported from `uipath.llm_client`, and inherit from `UiPathAPIError` for compatibility with existing handlers. + +### Changed +- **Default retry set expanded.** `_DEFAULT_RETRY_ON_EXCEPTIONS` in `uipath.llm_client.utils.retry` now covers `UiPathRequestTimeoutError` (408), `UiPathRateLimitError` (429), `UiPathBadGatewayError` (502), `UiPathServiceUnavailableError` (503), `UiPathGatewayTimeoutError` (504), and `UiPathTooManyRequestsError` (529) — up from `{429, 529}`. Applies to every provider client (`UiPathOpenAI`, `UiPathAnthropic*`, `UiPathGoogle`) since they all share the same `UiPathHttpxClient`-backed retry transport. `Retry-After` / `x-retry-after` headers and exponential backoff with jitter behave as before. +- **`UiPathHttpxClient` / `UiPathHttpxAsyncClient` default `max_retries` raised from `0` to `3`.** Callers that pass `max_retries=None` (or omit it entirely) now get 3 retries by default. Pass `max_retries=0` explicitly to opt out — `max_retries=0` continues to disable retries, so the opt-out path is unchanged. + ## [1.12.2] - 2026-05-24 ### Changed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 013c655..e0fade7 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.13.0] - 2026-05-27 + +### Changed +- **`UiPathBaseLLMClient.max_retries` field default raised from `0` to `3`.** Every LangChain chat and embedding client built on this base (`UiPathChat`, `UiPathChatOpenAI`, `UiPathAzureChatOpenAI`, `UiPathChatAnthropic`, `UiPathChatAnthropicBedrock`, `UiPathChatBedrock`, `UiPathChatBedrockConverse`, `UiPathChatVertexAI`, `UiPathChatFireworks`, `UiPathChatLiteLLM`, plus the matching embeddings classes) now retries failed requests 3 times by default. Pass `max_retries=0` explicitly to disable retries — the opt-out path is unchanged. Combined with the expanded default retry set in `uipath-llm-client` 1.13.0, every LangChain client now retries on HTTP 408, 429, 502, 503, 504, and 529 out of the box. +- Bumped `uipath-llm-client` floor to `>=1.13.0` to pick up the expanded default retry set and the new `UiPathRequestTimeoutError` / `UiPathBadGatewayError` typed exceptions. + ## [1.12.2] - 2026-05-24 ### Changed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 7ea7365..feb4713 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15,<2.0.0", - "uipath-llm-client>=1.12.2,<2.0.0", + "uipath-llm-client>=1.13.0,<2.0.0", ] [project.optional-dependencies] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 6fa6f41..96b1a7b 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LangChain Client" __description__ = "A Python client for interacting with UiPath's LLM services via LangChain." -__version__ = "1.12.2" +__version__ = "1.13.0" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index df1e0e1..fccd696 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -145,8 +145,8 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Client-side request timeout in seconds", ) max_retries: int = Field( - default=0, - description="Maximum number of retries for failed requests", + default=3, + description="Maximum number of retries for failed requests. Pass 0 to disable retries.", ) retry_config: RetryConfig | None = Field( default=None, diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index c4e6261..da9f300 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -39,6 +39,7 @@ from uipath.llm_client.utils.exceptions import ( UiPathAPIError, UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, UiPathGatewayTimeoutError, @@ -46,6 +47,7 @@ UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathRequestTooLargeError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, @@ -69,6 +71,7 @@ # Exceptions "UiPathAPIError", "UiPathAuthenticationError", + "UiPathBadGatewayError", "UiPathBadRequestError", "UiPathConflictError", "UiPathGatewayTimeoutError", @@ -76,6 +79,7 @@ "UiPathNotFoundError", "UiPathPermissionDeniedError", "UiPathRateLimitError", + "UiPathRequestTimeoutError", "UiPathRequestTooLargeError", "UiPathServiceUnavailableError", "UiPathTooManyRequestsError", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 8d889b7..b7fb238 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.12.2" +__version__ = "1.13.0" diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index 2969fe8..dc40ced 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -68,6 +68,10 @@ # Sentinel to distinguish "not provided" from an explicit ``None`` / ``False``. _UNSET: Any = object() +# Default applied when ``max_retries`` is left as ``None``. Callers can still +# opt out by passing ``max_retries=0`` explicitly. +_DEFAULT_MAX_RETRIES: typing.Final[int] = 3 + class UiPathHttpxClient(Client): """Synchronous HTTP client configured for UiPath LLM services. @@ -135,8 +139,8 @@ def __init__( captured_headers: Case-insensitive header name prefixes to capture from responses. Captured headers are stored in a ContextVar and can be retrieved with get_captured_response_headers(). Defaults to ("x-uipath-",). - max_retries: Maximum retry attempts for failed requests. Defaults to 0 - (retries disabled). Set to a positive integer to enable retries. + max_retries: Maximum retry attempts for failed requests. Defaults to 3 + when left as ``None``. Pass ``0`` to disable retries explicitly. retry_config: Custom retry configuration (backoff, retryable status codes). logger: Logger instance for request/response logging. auth: HTTP authentication (same as httpx.Client). Derived from @@ -193,7 +197,7 @@ def __init__( # Setup retry transport if not provided if transport is None: transport = RetryableHTTPTransport( - max_retries=max_retries if max_retries is not None else 0, + max_retries=max_retries if max_retries is not None else _DEFAULT_MAX_RETRIES, retry_config=retry_config, logger=logger, ) @@ -354,7 +358,7 @@ def __init__( # Setup retry transport if not provided if transport is None: transport = RetryableAsyncHTTPTransport( - max_retries=max_retries if max_retries is not None else 0, + max_retries=max_retries if max_retries is not None else _DEFAULT_MAX_RETRIES, retry_config=retry_config, logger=logger, ) diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 3e13057..0ad93e3 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -118,6 +118,12 @@ class UiPathNotFoundError(UiPathAPIError): status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] +class UiPathRequestTimeoutError(UiPathAPIError): + """HTTP 408 Request Timeout error.""" + + status_code: Literal[408] = 408 # pyright: ignore[reportIncompatibleVariableOverride] + + class UiPathConflictError(UiPathAPIError): """HTTP 409 Conflict error.""" @@ -211,6 +217,12 @@ class UiPathInternalServerError(UiPathAPIError): status_code: Literal[500] = 500 # pyright: ignore[reportIncompatibleVariableOverride] +class UiPathBadGatewayError(UiPathAPIError): + """HTTP 502 Bad Gateway error.""" + + status_code: Literal[502] = 502 # pyright: ignore[reportIncompatibleVariableOverride] + + class UiPathServiceUnavailableError(UiPathAPIError): """HTTP 503 Service Unavailable error.""" @@ -234,11 +246,13 @@ class UiPathTooManyRequestsError(UiPathAPIError): 401: UiPathAuthenticationError, 403: UiPathPermissionDeniedError, 404: UiPathNotFoundError, + 408: UiPathRequestTimeoutError, 409: UiPathConflictError, 413: UiPathRequestTooLargeError, 422: UiPathUnprocessableEntityError, 429: UiPathRateLimitError, 500: UiPathInternalServerError, + 502: UiPathBadGatewayError, 503: UiPathServiceUnavailableError, 504: UiPathGatewayTimeoutError, 529: UiPathTooManyRequestsError, @@ -266,11 +280,13 @@ def raise_for_status() -> Response: "UiPathAuthenticationError", "UiPathPermissionDeniedError", "UiPathNotFoundError", + "UiPathRequestTimeoutError", "UiPathConflictError", "UiPathRequestTooLargeError", "UiPathUnprocessableEntityError", "UiPathRateLimitError", "UiPathInternalServerError", + "UiPathBadGatewayError", "UiPathServiceUnavailableError", "UiPathGatewayTimeoutError", "UiPathTooManyRequestsError", diff --git a/src/uipath/llm_client/utils/retry.py b/src/uipath/llm_client/utils/retry.py index 32c89a3..d4ef183 100644 --- a/src/uipath/llm_client/utils/retry.py +++ b/src/uipath/llm_client/utils/retry.py @@ -49,13 +49,22 @@ from uipath.llm_client.utils.exceptions import ( UiPathAPIError, + UiPathBadGatewayError, + UiPathGatewayTimeoutError, UiPathRateLimitError, + UiPathRequestTimeoutError, + UiPathServiceUnavailableError, UiPathTooManyRequestsError, ) # Default retry configuration values +# Status codes retried by default: 408, 429, 502, 503, 504, 529. _DEFAULT_RETRY_ON_EXCEPTIONS: tuple[type[Exception], ...] = ( + UiPathRequestTimeoutError, UiPathRateLimitError, + UiPathBadGatewayError, + UiPathServiceUnavailableError, + UiPathGatewayTimeoutError, UiPathTooManyRequestsError, ) _DEFAULT_INITIAL_DELAY: float = 2.0 @@ -127,7 +136,7 @@ class RetryConfig(TypedDict): Attributes: retry_on_exceptions: Tuple of exception types to retry on. - Defaults to (UiPathRateLimitError,). + Defaults to the typed exceptions for HTTP 408, 429, 502, 503, 504, 529. initial_delay: Initial delay in seconds before first retry. Defaults to 2.0. max_delay: Maximum delay in seconds between retries. diff --git a/tests/core/features/test_exceptions.py b/tests/core/features/test_exceptions.py index 46810af..3f7b71a 100644 --- a/tests/core/features/test_exceptions.py +++ b/tests/core/features/test_exceptions.py @@ -9,12 +9,14 @@ from uipath.llm_client.utils.exceptions import ( UiPathAPIError, UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathGatewayTimeoutError, UiPathInternalServerError, UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, patch_raise_for_status, @@ -170,8 +172,10 @@ def test_all_status_code_mappings(self): 401: UiPathAuthenticationError, 403: UiPathPermissionDeniedError, 404: UiPathNotFoundError, + 408: UiPathRequestTimeoutError, 429: UiPathRateLimitError, 500: UiPathInternalServerError, + 502: UiPathBadGatewayError, 503: UiPathServiceUnavailableError, 504: UiPathGatewayTimeoutError, 529: UiPathTooManyRequestsError, diff --git a/tests/core/features/test_httpx_client.py b/tests/core/features/test_httpx_client.py index 1d0c776..b19bd38 100644 --- a/tests/core/features/test_httpx_client.py +++ b/tests/core/features/test_httpx_client.py @@ -80,6 +80,24 @@ def test_client_with_retry_config(self): assert isinstance(client._transport, RetryableHTTPTransport) client.close() + def test_client_default_max_retries_is_three(self): + """Caller passing no ``max_retries`` should get the 3-retry default.""" + from uipath.llm_client.httpx_client import UiPathHttpxClient + + client = UiPathHttpxClient(base_url="https://example.com") + assert isinstance(client._transport, RetryableHTTPTransport) + assert client._transport.retryer is not None + client.close() + + def test_client_explicit_zero_disables_retries(self): + """Passing ``max_retries=0`` must still disable retries.""" + from uipath.llm_client.httpx_client import UiPathHttpxClient + + client = UiPathHttpxClient(base_url="https://example.com", max_retries=0) + assert isinstance(client._transport, RetryableHTTPTransport) + assert client._transport.retryer is None + client.close() + def test_client_with_byo_connection_id(self): """Test client adds BYO connection ID header.""" from uipath.llm_client.httpx_client import UiPathHttpxClient @@ -123,6 +141,22 @@ def test_async_client_with_retry_config(self): # Transport should be RetryableAsyncHTTPTransport assert isinstance(client._transport, RetryableAsyncHTTPTransport) + def test_async_client_default_max_retries_is_three(self): + """Async caller passing no ``max_retries`` should get the 3-retry default.""" + from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient + + client = UiPathHttpxAsyncClient(base_url="https://example.com") + assert isinstance(client._transport, RetryableAsyncHTTPTransport) + assert client._transport.retryer is not None + + def test_async_client_explicit_zero_disables_retries(self): + """Async client: passing ``max_retries=0`` must still disable retries.""" + from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient + + client = UiPathHttpxAsyncClient(base_url="https://example.com", max_retries=0) + assert isinstance(client._transport, RetryableAsyncHTTPTransport) + assert client._transport.retryer is None + class TestBuildRoutingHeaders: """Tests for build_routing_headers function.""" diff --git a/tests/core/features/test_retry.py b/tests/core/features/test_retry.py index 27f9a53..da1ac98 100644 --- a/tests/core/features/test_retry.py +++ b/tests/core/features/test_retry.py @@ -4,16 +4,39 @@ from unittest.mock import MagicMock, patch from uipath.llm_client.utils.exceptions import ( + UiPathBadGatewayError, + UiPathGatewayTimeoutError, UiPathInternalServerError, UiPathRateLimitError, + UiPathRequestTimeoutError, + UiPathServiceUnavailableError, + UiPathTooManyRequestsError, ) from uipath.llm_client.utils.retry import ( + _DEFAULT_RETRY_ON_EXCEPTIONS, RetryableAsyncHTTPTransport, RetryableHTTPTransport, RetryConfig, ) +class TestDefaultRetryOnExceptions: + """Pins the default retry set so HTTP 408/429/502/503/504/529 stay retryable.""" + + def test_default_set_covers_required_status_codes(self): + assert set(_DEFAULT_RETRY_ON_EXCEPTIONS) == { + UiPathRequestTimeoutError, + UiPathRateLimitError, + UiPathBadGatewayError, + UiPathServiceUnavailableError, + UiPathGatewayTimeoutError, + UiPathTooManyRequestsError, + } + + def test_internal_server_error_is_not_retried_by_default(self): + assert UiPathInternalServerError not in _DEFAULT_RETRY_ON_EXCEPTIONS + + class TestRetryConfig: """Tests for RetryConfig TypedDict.""" diff --git a/tests/core/smoke_test.py b/tests/core/smoke_test.py index a556dd4..314738e 100644 --- a/tests/core/smoke_test.py +++ b/tests/core/smoke_test.py @@ -62,6 +62,7 @@ def test_exception_imports(): from uipath.llm_client import ( UiPathAPIError, UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, UiPathGatewayTimeoutError, @@ -69,6 +70,7 @@ def test_exception_imports(): UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathRequestTooLargeError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, @@ -79,6 +81,7 @@ def test_exception_imports(): exceptions = [ UiPathAPIError, UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, UiPathGatewayTimeoutError, @@ -86,6 +89,7 @@ def test_exception_imports(): UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathRequestTooLargeError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, @@ -176,6 +180,7 @@ def test_exceptions_module_imports(): from uipath.llm_client.utils.exceptions import ( UiPathAPIError, UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, UiPathGatewayTimeoutError, @@ -183,6 +188,7 @@ def test_exceptions_module_imports(): UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathRequestTooLargeError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, @@ -198,6 +204,7 @@ def test_exceptions_module_imports(): # Verify all specific exceptions inherit from UiPathAPIError specific_exceptions = [ UiPathAuthenticationError, + UiPathBadGatewayError, UiPathBadRequestError, UiPathConflictError, UiPathGatewayTimeoutError, @@ -205,6 +212,7 @@ def test_exceptions_module_imports(): UiPathNotFoundError, UiPathPermissionDeniedError, UiPathRateLimitError, + UiPathRequestTimeoutError, UiPathRequestTooLargeError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, diff --git a/tests/langchain/features/test_default_max_retries.py b/tests/langchain/features/test_default_max_retries.py new file mode 100644 index 0000000..04fb4cb --- /dev/null +++ b/tests/langchain/features/test_default_max_retries.py @@ -0,0 +1,49 @@ +"""Tests pinning the default ``max_retries`` value on ``UiPathBaseLLMClient``. + +The default must stay at 3 so that every LangChain chat/embedding client built +on top of ``UiPathBaseLLMClient`` (OpenAI, Anthropic, Bedrock, Vertex AI, etc.) +retries transient HTTP failures by default. Callers can still opt out by +passing ``max_retries=0`` explicitly. +""" + +import os +from unittest.mock import patch + +from uipath_langchain_client.clients.normalized.chat_models import UiPathChat + +from uipath.llm_client.settings import LLMGatewaySettings +from uipath.llm_client.settings.utils import SingletonMeta +from uipath.llm_client.utils.retry import RetryableHTTPTransport + +LLMGW_ENV = { + "LLMGW_URL": "https://cloud.uipath.com", + "LLMGW_SEMANTIC_ORG_ID": "test-org-id", + "LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id", + "LLMGW_REQUESTING_PRODUCT": "test-product", + "LLMGW_REQUESTING_FEATURE": "test-feature", + "LLMGW_ACCESS_TOKEN": "test-access-token", +} + + +class TestDefaultMaxRetries: + def setup_method(self): + SingletonMeta._instances.clear() + + def teardown_method(self): + SingletonMeta._instances.clear() + + def test_default_max_retries_is_three(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat(model="gpt-4o", settings=LLMGatewaySettings()) + assert chat.max_retries == 3 + transport = chat.uipath_sync_client._transport + assert isinstance(transport, RetryableHTTPTransport) + assert transport.retryer is not None + + def test_explicit_zero_disables_retries(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat(model="gpt-4o", settings=LLMGatewaySettings(), max_retries=0) + assert chat.max_retries == 0 + transport = chat.uipath_sync_client._transport + assert isinstance(transport, RetryableHTTPTransport) + assert transport.retryer is None From e7485388a5d7a62baf9623fed1932a90328eeb02 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Thu, 28 May 2026 12:03:10 +0300 Subject: [PATCH 2/2] test: cover end-to-end retry on HTTP 504 Adds TestRetryOn504EndToEnd which drives a real request through RetryableHTTPTransport / RetryableAsyncHTTPTransport and asserts the underlying call fires max_retries times before the final 504 response is returned. The existing tests only covered configuration/wiring, not the actual retry loop behavior against a real httpx.Response. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/core/features/test_retry.py | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/core/features/test_retry.py b/tests/core/features/test_retry.py index da1ac98..0a47d33 100644 --- a/tests/core/features/test_retry.py +++ b/tests/core/features/test_retry.py @@ -3,6 +3,10 @@ import logging from unittest.mock import MagicMock, patch +import httpx +import pytest + +from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient, UiPathHttpxClient from uipath.llm_client.utils.exceptions import ( UiPathBadGatewayError, UiPathGatewayTimeoutError, @@ -19,6 +23,8 @@ RetryConfig, ) +_NO_DELAY_CONFIG: RetryConfig = {"initial_delay": 0, "max_delay": 0, "jitter": 0} + class TestDefaultRetryOnExceptions: """Pins the default retry set so HTTP 408/429/502/503/504/529 stay retryable.""" @@ -263,3 +269,79 @@ def test_with_logger_adds_before_sleep(self): result = _build_retryer(max_retries=2, retry_config=None, logger=logger) assert result is not None assert result.before_sleep is not None + + +class TestRetryOn504EndToEnd: + """End-to-end coverage that a 504 response actually triggers the retry loop. + + The other test classes verify the *configuration* (default exception set, + retryer wiring, wait math). These tests drive a real request through + ``RetryableHTTPTransport.handle_request`` / ``handle_async_request`` and + assert the underlying call fires ``max_retries`` times before the final 504 + response is returned. + """ + + def test_sync_504_retries_max_retries_times_then_returns_response(self): + calls = 0 + + def fake_504(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(504, content=b"{}", request=request) + + client = UiPathHttpxClient( + base_url="https://example.com", + max_retries=3, + retry_config=_NO_DELAY_CONFIG, + ) + try: + with patch.object(httpx.HTTPTransport, "handle_request", fake_504): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls == 3 + assert response.status_code == 504 + + def test_sync_max_retries_zero_makes_exactly_one_call(self): + calls = 0 + + def fake_504(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(504, content=b"{}", request=request) + + client = UiPathHttpxClient(base_url="https://example.com", max_retries=0) + try: + with patch.object(httpx.HTTPTransport, "handle_request", fake_504): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls == 1 + assert response.status_code == 504 + + @pytest.mark.asyncio + async def test_async_504_retries_max_retries_times_then_returns_response(self): + calls = 0 + + async def fake_504( + self: httpx.AsyncHTTPTransport, request: httpx.Request + ) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(504, content=b"{}", request=request) + + client = UiPathHttpxAsyncClient( + base_url="https://example.com", + max_retries=3, + retry_config=_NO_DELAY_CONFIG, + ) + try: + with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", fake_504): + response = await client.post("/anything", json={}) + finally: + await client.aclose() + + assert calls == 3 + assert response.status_code == 504