diff --git a/CHANGELOG.md b/CHANGELOG.md index 9205eb4..976e527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.17.0] - 2026-07-14 + +### Changed +- Restored retry parity with the legacy uipath-langchain-python chat-client retryers (`BedrockRetryer`) that the shared transports replaced. Under throttling that outlasted the previous budget, runs that the legacy client absorbed silently were now failing. + - Default retry budget is now **5 attempts** (was 3) with exponential backoff starting at **5s** (was 2s) capped at **120s** (was 60s) — waits of ~5/10/20/40s, an absorption window of roughly 75s instead of ~6s. + - **Retry-After / x-retry-after is honored on any error status**, not just 429: the header caps the wait for every `UiPathAPIError`, and its presence forces a retry even for otherwise non-retryable statuses (an explicit server retry request). `retry_after` moved from `UiPathRateLimitError` up to `UiPathAPIError`. + - **HTTP 524** (Cloudflare origin timeout) is retryable again via the new `UiPathOriginTimeoutError`; 529 remains retryable. + - **Connection-level failures are retried**: `httpx.TimeoutException` (connect/read/write/pool timeouts), `httpx.ConnectError`, and `httpx.RemoteProtocolError` — the union of what the legacy Bedrock (botocore timeout/connection errors) and Vertex (httpx timeouts, connect errors, remote-protocol errors) retryers handled. + +### Added +- `UiPathOriginTimeoutError` (HTTP 524), exported from `uipath.llm_client`. + ## [1.16.3] - 2026-07-13 ### Fixed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 276b768..96d1f65 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.17.0] - 2026-07-14 + +### Changed +- Default `max_retries` on `UiPathBaseLLMClient` raised from 3 to 5 for parity with the legacy uipath-langchain-python chat-client retryers. Requires `uipath-llm-client>=1.17.0`, which also restores the legacy backoff window (5s initial / 120s cap), honors Retry-After on any status, and retries 524 and connection-level failures. + ## [1.16.1] - 2026-07-03 ### Fixed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 44ef969..aec80cb 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.16.0,<2.0.0", + "uipath-llm-client>=1.17.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 67ef7a7..c7cde98 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.16.1" +__version__ = "1.17.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 9189ce2..8d064a6 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 @@ -146,7 +146,7 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Client-side request timeout in seconds", ) max_retries: int = Field( - default=3, + default=5, description="Maximum number of retries for failed requests. Pass 0 to disable retries.", ) retry_config: RetryConfig | None = Field( diff --git a/src/uipath/llm_client/__init__.py b/src/uipath/llm_client/__init__.py index 47e1f50..2411fa4 100644 --- a/src/uipath/llm_client/__init__.py +++ b/src/uipath/llm_client/__init__.py @@ -47,6 +47,7 @@ UiPathInternalServerError, UiPathLLMErrorCode, UiPathNotFoundError, + UiPathOriginTimeoutError, UiPathPermissionDeniedError, UiPathRateLimitError, UiPathRequestTimeoutError, @@ -81,6 +82,7 @@ "UiPathGatewayTimeoutError", "UiPathInternalServerError", "UiPathNotFoundError", + "UiPathOriginTimeoutError", "UiPathPermissionDeniedError", "UiPathRateLimitError", "UiPathRequestTimeoutError", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 859435b..c3f66b2 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.16.3" +__version__ = "1.17.0" diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index 9e544aa..6df7a44 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -71,7 +71,7 @@ # 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 +_DEFAULT_MAX_RETRIES: typing.Final[int] = 5 class UiPathHttpxClient(Client): @@ -79,7 +79,7 @@ class UiPathHttpxClient(Client): Extends httpx.Client with: - Default UiPath headers (server timeout, full 4xx responses) - - Automatic retry on transient failures (429, 5xx) + - Automatic retry on transient failures (429, 5xx, Retry-After hints, connection errors) - Request/response duration logging - Streaming header injection (X-UiPath-Streaming-Enabled) - Optional URL freezing to prevent vendor SDK mutations @@ -140,7 +140,7 @@ 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 3 + max_retries: Maximum retry attempts for failed requests. Defaults to 5 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. @@ -279,7 +279,7 @@ class UiPathHttpxAsyncClient(AsyncClient): Extends httpx.AsyncClient with: - Default UiPath headers (server timeout, full 4xx responses) - - Automatic retry on transient failures (429, 5xx) + - Automatic retry on transient failures (429, 5xx, Retry-After hints, connection errors) - Request/response duration logging - Streaming header injection (X-UiPath-Streaming-Enabled) - Optional URL freezing to prevent vendor SDK mutations diff --git a/src/uipath/llm_client/utils/exceptions.py b/src/uipath/llm_client/utils/exceptions.py index 823d906..d130902 100644 --- a/src/uipath/llm_client/utils/exceptions.py +++ b/src/uipath/llm_client/utils/exceptions.py @@ -108,6 +108,10 @@ class UiPathAPIError(UiPathError, HTTPStatusError): body: The response body (parsed JSON dict or raw string). request: The original httpx.Request object. response: The original httpx.Response object. + retry_after: Seconds to wait before retrying (from the Retry-After / + x-retry-after header), or None. A server can attach Retry-After to + any status code, so this lives on the base class — the retry layer + treats its presence as an explicit retry request. """ def __init__( @@ -126,6 +130,61 @@ def __init__( self.message = message self.body = body + @property + def retry_after(self) -> float | None: + """Get the retry-after value in seconds, if available. + + Parsed lazily from ``self.response`` (the Retry-After / x-retry-after + header). + """ + response = getattr(self, "response", None) + if not isinstance(response, Response): + return None + return self._parse_retry_after(response) + + @staticmethod + def _parse_retry_after(response: Response) -> float | None: + """Parse the Retry-After or x-retry-after header from the response. + + The Retry-After header can be either: + - A number of seconds (e.g., "120") + - An HTTP-date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + + Args: + response: The httpx Response object. + + Returns: + The number of seconds to wait, or None if not present/parseable. + """ + import time + from datetime import datetime, timezone + + # Check both header variants (case-insensitive in httpx) + retry_after_value = response.headers.get("retry-after") + if retry_after_value is None: + retry_after_value = response.headers.get("x-retry-after") + + if retry_after_value is None: + return None + + # Try parsing as integer (seconds) + try: + return float(retry_after_value) + except ValueError: + pass + + # Try parsing as HTTP-date (RFC 7231 IMF-fixdate format) + # Example: "Wed, 21 Oct 2015 07:28:00 GMT" + try: + retry_date = datetime.strptime(retry_after_value, "%a, %d %b %Y %H:%M:%S GMT") + retry_date = retry_date.replace(tzinfo=timezone.utc) + delay = retry_date.timestamp() - time.time() + return max(0.0, delay) # Don't return negative delays + except ValueError: + pass + + return None + def __str__(self) -> str: return ( f"{self.__class__.__name__}: {self.message} " @@ -219,66 +278,12 @@ class UiPathRateLimitError(UiPathAPIError): """HTTP 429 Too Many Requests error. Attributes: - retry_after: Seconds to wait before retrying (from Retry-After header), or None. + retry_after: Seconds to wait before retrying (from Retry-After header), + or None. Inherited from :class:`UiPathAPIError`. """ status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] - @property - def retry_after(self) -> float | None: - """Get the retry-after value in seconds, if available. - - Parsed lazily from ``self.response`` (the Retry-After / x-retry-after - header). - """ - response = getattr(self, "response", None) - if not isinstance(response, Response): - return None - return self._parse_retry_after(response) - - @staticmethod - def _parse_retry_after(response: Response) -> float | None: - """Parse the Retry-After or x-retry-after header from the response. - - The Retry-After header can be either: - - A number of seconds (e.g., "120") - - An HTTP-date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") - - Args: - response: The httpx Response object. - - Returns: - The number of seconds to wait, or None if not present/parseable. - """ - import time - from datetime import datetime, timezone - - # Check both header variants (case-insensitive in httpx) - retry_after_value = response.headers.get("retry-after") - if retry_after_value is None: - retry_after_value = response.headers.get("x-retry-after") - - if retry_after_value is None: - return None - - # Try parsing as integer (seconds) - try: - return float(retry_after_value) - except ValueError: - pass - - # Try parsing as HTTP-date (RFC 7231 IMF-fixdate format) - # Example: "Wed, 21 Oct 2015 07:28:00 GMT" - try: - retry_date = datetime.strptime(retry_after_value, "%a, %d %b %Y %H:%M:%S GMT") - retry_date = retry_date.replace(tzinfo=timezone.utc) - delay = retry_date.timestamp() - time.time() - return max(0.0, delay) # Don't return negative delays - except ValueError: - pass - - return None - class UiPathInternalServerError(UiPathAPIError): """HTTP 500 Internal Server Error.""" @@ -304,6 +309,12 @@ class UiPathGatewayTimeoutError(UiPathAPIError): status_code: Literal[504] = 504 # pyright: ignore[reportIncompatibleVariableOverride] +class UiPathOriginTimeoutError(UiPathAPIError): + """HTTP 524 A Timeout Occurred (Cloudflare origin timeout) error.""" + + status_code: Literal[524] = 524 # pyright: ignore[reportIncompatibleVariableOverride] + + class UiPathTooManyRequestsError(UiPathAPIError): """HTTP 529 Too Many Requests (Anthropic overload) error.""" @@ -324,6 +335,7 @@ class UiPathTooManyRequestsError(UiPathAPIError): 502: UiPathBadGatewayError, 503: UiPathServiceUnavailableError, 504: UiPathGatewayTimeoutError, + 524: UiPathOriginTimeoutError, 529: UiPathTooManyRequestsError, } @@ -468,6 +480,7 @@ def wrap_provider_errors() -> Iterator[None]: "UiPathBadGatewayError", "UiPathServiceUnavailableError", "UiPathGatewayTimeoutError", + "UiPathOriginTimeoutError", "UiPathTooManyRequestsError", "as_uipath_error", "wrap_provider_errors", diff --git a/src/uipath/llm_client/utils/retry.py b/src/uipath/llm_client/utils/retry.py index d4ef183..3fe8a75 100644 --- a/src/uipath/llm_client/utils/retry.py +++ b/src/uipath/llm_client/utils/retry.py @@ -34,12 +34,22 @@ import logging from typing import Any, Callable, NotRequired -from httpx import AsyncHTTPTransport, HTTPTransport, Request, Response +from httpx import ( + AsyncHTTPTransport, + ConnectError, + HTTPTransport, + RemoteProtocolError, + Request, + Response, + TimeoutException, +) from tenacity import ( AsyncRetrying, RetryCallState, Retrying, before_sleep_log, + retry_any, + retry_if_exception, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter, @@ -51,24 +61,37 @@ UiPathAPIError, UiPathBadGatewayError, UiPathGatewayTimeoutError, + UiPathOriginTimeoutError, UiPathRateLimitError, UiPathRequestTimeoutError, UiPathServiceUnavailableError, UiPathTooManyRequestsError, ) -# Default retry configuration values -# Status codes retried by default: 408, 429, 502, 503, 504, 529. +# Default retry configuration values, aligned with the legacy +# uipath-langchain-python chat-client retryers (BedrockRetryer) they replaced. +# Status codes retried by default: 408, 429, 502, 503, 504, 524, 529. +# Connection-level failures are retried too, matching the union of the legacy +# providers: httpx.TimeoutException (connect/read/write/pool timeouts — covers +# botocore's ConnectTimeoutError / ReadTimeoutError equivalents), ConnectError +# (botocore EndpointConnectionError equivalent), and RemoteProtocolError +# (connection reset mid-exchange). Independently of status, any error +# response carrying a Retry-After header is treated as an explicit server +# request to retry (see _build_retryer). _DEFAULT_RETRY_ON_EXCEPTIONS: tuple[type[Exception], ...] = ( UiPathRequestTimeoutError, UiPathRateLimitError, UiPathBadGatewayError, UiPathServiceUnavailableError, UiPathGatewayTimeoutError, + UiPathOriginTimeoutError, UiPathTooManyRequestsError, + TimeoutException, + ConnectError, + RemoteProtocolError, ) -_DEFAULT_INITIAL_DELAY: float = 2.0 -_DEFAULT_MAX_DELAY: float = 60.0 +_DEFAULT_INITIAL_DELAY: float = 5.0 +_DEFAULT_MAX_DELAY: float = 120.0 _DEFAULT_EXP_BASE: float = 2.0 _DEFAULT_JITTER: float = 1.0 @@ -76,9 +99,10 @@ class wait_retry_after_with_fallback(wait_base): """Custom wait strategy that uses Retry-After header when available. - This wait strategy checks if the exception has a retry_after attribute - (from the Retry-After or x-retry-after HTTP headers) and uses that value. - If not available, falls back to exponential backoff with jitter. + This wait strategy checks if the exception is a ``UiPathAPIError`` whose + response carried a Retry-After / x-retry-after header (any status code) + and uses that value. If not available, falls back to exponential backoff + with jitter. Attributes: fallback_wait: The fallback wait strategy (exponential backoff with jitter). @@ -118,10 +142,11 @@ def __call__(self, retry_state: RetryCallState) -> float: Returns: The number of seconds to wait before the next retry. """ - # Check if we have a rate limit exception with retry_after + # Honor Retry-After from any API error, not just 429 — servers attach + # it to 5xx (and occasionally other) responses as an explicit wait hint. if retry_state.outcome is not None and retry_state.outcome.failed: exception = retry_state.outcome.exception() - if isinstance(exception, UiPathRateLimitError) and exception.retry_after is not None: + if isinstance(exception, UiPathAPIError) and exception.retry_after is not None: # Use retry-after value, but cap at max_delay return min(exception.retry_after, self.max_delay) @@ -136,11 +161,14 @@ class RetryConfig(TypedDict): Attributes: retry_on_exceptions: Tuple of exception types to retry on. - Defaults to the typed exceptions for HTTP 408, 429, 502, 503, 504, 529. + Defaults to the typed exceptions for HTTP 408, 429, 502, 503, 504, + 524, 529 plus httpx connection failures (``TimeoutException``, + ``ConnectError``, ``RemoteProtocolError``). Independently of this + tuple, any error response carrying a Retry-After header is retried. initial_delay: Initial delay in seconds before first retry. - Defaults to 2.0. + Defaults to 5.0. max_delay: Maximum delay in seconds between retries. - Defaults to 60.0. + Defaults to 120.0. exp_base: Exponential backoff base multiplier. Defaults to 2.0. jitter: Random jitter in seconds to add to delay. @@ -204,12 +232,23 @@ def _build_retryer( exp_base=exp_base, jitter=jitter, ), - retry=retry_if_exception_type(retry_on), + # Retry on the configured exception types, and additionally on ANY + # error response carrying a Retry-After header — the server explicitly + # asked for a retry, regardless of status code (legacy-client parity). + retry=retry_any( + retry_if_exception_type(retry_on), + retry_if_exception(_server_requested_retry), + ), reraise=True, before_sleep=before_sleep, ) +def _server_requested_retry(exception: BaseException) -> bool: + """True when an error response carries a Retry-After / x-retry-after hint.""" + return isinstance(exception, UiPathAPIError) and exception.retry_after is not None + + class RetryableHTTPTransport(HTTPTransport): """HTTP transport with automatic retry on failures. diff --git a/tests/core/features/test_retry.py b/tests/core/features/test_retry.py index 0a47d33..1a3b3f7 100644 --- a/tests/core/features/test_retry.py +++ b/tests/core/features/test_retry.py @@ -11,6 +11,7 @@ UiPathBadGatewayError, UiPathGatewayTimeoutError, UiPathInternalServerError, + UiPathOriginTimeoutError, UiPathRateLimitError, UiPathRequestTimeoutError, UiPathServiceUnavailableError, @@ -27,7 +28,9 @@ class TestDefaultRetryOnExceptions: - """Pins the default retry set so HTTP 408/429/502/503/504/529 stay retryable.""" + """Pins the default retry set: HTTP 408/429/502/503/504/524/529 plus + connection-level failures, matching the legacy uipath-langchain-python + chat-client retryers.""" def test_default_set_covers_required_status_codes(self): assert set(_DEFAULT_RETRY_ON_EXCEPTIONS) == { @@ -36,7 +39,11 @@ def test_default_set_covers_required_status_codes(self): UiPathBadGatewayError, UiPathServiceUnavailableError, UiPathGatewayTimeoutError, + UiPathOriginTimeoutError, UiPathTooManyRequestsError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.RemoteProtocolError, } def test_internal_server_error_is_not_retried_by_default(self): @@ -166,15 +173,33 @@ def test_caps_retry_after_at_max_delay(self): wait_time = strategy(retry_state) assert wait_time == 10.0 # Capped at max + def test_uses_retry_after_from_any_api_error(self): + """Retry-After is honored for any status error, not just 429 (legacy parity).""" + from uipath.llm_client.utils.retry import wait_retry_after_with_fallback + + strategy = wait_retry_after_with_fallback(initial=1, max=60, exp_base=2, jitter=0) + + mock_outcome = MagicMock() + mock_outcome.failed = True + exc = MagicMock(spec=UiPathServiceUnavailableError) + exc.retry_after = 7.0 + mock_outcome.exception.return_value = exc + + retry_state = MagicMock() + retry_state.outcome = mock_outcome + + assert strategy(retry_state) == 7.0 + def test_fallback_to_exponential_backoff(self): from uipath.llm_client.utils.retry import wait_retry_after_with_fallback strategy = wait_retry_after_with_fallback(initial=1, max=60, exp_base=2, jitter=0) - # Non-rate-limit error + # Error without a Retry-After hint mock_outcome = MagicMock() mock_outcome.failed = True exc = MagicMock(spec=UiPathInternalServerError) + exc.retry_after = None mock_outcome.exception.return_value = exc retry_state = MagicMock() @@ -345,3 +370,138 @@ async def fake_504( assert calls == 3 assert response.status_code == 504 + + +class TestLegacyParityEndToEnd: + """End-to-end coverage for the legacy-parity retry behaviors: default + 5-attempt budget, Retry-After forcing retries on any status, 524, and + connection-error retries.""" + + @staticmethod + def _counting_handler(status: int, headers: dict[str, str] | None = None): + calls = {"n": 0} + + def handler(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(status, content=b"{}", headers=headers, request=request) + + return calls, handler + + def test_default_budget_is_5_attempts(self): + calls, handler = self._counting_handler(504) + client = UiPathHttpxClient(base_url="https://example.com", retry_config=_NO_DELAY_CONFIG) + try: + with patch.object(httpx.HTTPTransport, "handle_request", handler): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 5 + assert response.status_code == 504 + + def test_retry_after_forces_retry_on_non_retryable_status(self): + """A 403 carrying Retry-After is an explicit server retry request.""" + calls, handler = self._counting_handler(403, headers={"Retry-After": "0"}) + client = UiPathHttpxClient( + base_url="https://example.com", max_retries=3, retry_config=_NO_DELAY_CONFIG + ) + try: + with patch.object(httpx.HTTPTransport, "handle_request", handler): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 3 + assert response.status_code == 403 + + def test_plain_403_is_not_retried(self): + calls, handler = self._counting_handler(403) + client = UiPathHttpxClient( + base_url="https://example.com", max_retries=3, retry_config=_NO_DELAY_CONFIG + ) + try: + with patch.object(httpx.HTTPTransport, "handle_request", handler): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 1 + assert response.status_code == 403 + + def test_524_is_retried(self): + calls, handler = self._counting_handler(524) + client = UiPathHttpxClient( + base_url="https://example.com", max_retries=3, retry_config=_NO_DELAY_CONFIG + ) + try: + with patch.object(httpx.HTTPTransport, "handle_request", handler): + response = client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 3 + assert response.status_code == 524 + + def test_connect_error_is_retried_then_raised(self): + calls = {"n": 0} + + def raise_connect_error( + self: httpx.HTTPTransport, request: httpx.Request + ) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("connection refused", 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", raise_connect_error): + with pytest.raises(httpx.ConnectError): + client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 3 + + def test_remote_protocol_error_is_retried_then_raised(self): + """Connection reset mid-exchange (legacy Vertex retryer parity).""" + calls = {"n": 0} + + def raise_remote_protocol_error( + self: httpx.HTTPTransport, request: httpx.Request + ) -> httpx.Response: + calls["n"] += 1 + raise httpx.RemoteProtocolError("server disconnected", 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", raise_remote_protocol_error): + with pytest.raises(httpx.RemoteProtocolError): + client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 3 + + def test_pool_timeout_is_retried_then_raised(self): + """PoolTimeout is covered via the TimeoutException base class + (legacy Vertex retryer retried all httpx timeouts).""" + calls = {"n": 0} + + def raise_pool_timeout(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + raise httpx.PoolTimeout("pool exhausted", 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", raise_pool_timeout): + with pytest.raises(httpx.PoolTimeout): + client.post("/anything", json={}) + finally: + client.close() + + assert calls["n"] == 3 diff --git a/tests/langchain/features/test_default_max_retries.py b/tests/langchain/features/test_default_max_retries.py index 04fb4cb..a4dd8fe 100644 --- a/tests/langchain/features/test_default_max_retries.py +++ b/tests/langchain/features/test_default_max_retries.py @@ -1,9 +1,10 @@ """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. +The default must stay at 5 (legacy uipath-langchain-python chat-client parity) +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 @@ -32,10 +33,10 @@ def setup_method(self): def teardown_method(self): SingletonMeta._instances.clear() - def test_default_max_retries_is_three(self): + def test_default_max_retries_is_five(self): with patch.dict(os.environ, LLMGW_ENV, clear=True): chat = UiPathChat(model="gpt-4o", settings=LLMGatewaySettings()) - assert chat.max_retries == 3 + assert chat.max_retries == 5 transport = chat.uipath_sync_client._transport assert isinstance(transport, RetryableHTTPTransport) assert transport.retryer is not None