Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/uipath/llm_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
UiPathInternalServerError,
UiPathLLMErrorCode,
UiPathNotFoundError,
UiPathOriginTimeoutError,
UiPathPermissionDeniedError,
UiPathRateLimitError,
UiPathRequestTimeoutError,
Expand Down Expand Up @@ -81,6 +82,7 @@
"UiPathGatewayTimeoutError",
"UiPathInternalServerError",
"UiPathNotFoundError",
"UiPathOriginTimeoutError",
"UiPathPermissionDeniedError",
"UiPathRateLimitError",
"UiPathRequestTimeoutError",
Expand Down
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 4 additions & 4 deletions src/uipath/llm_client/httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,15 @@

# 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):
"""Synchronous HTTP client configured for UiPath LLM services.

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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
125 changes: 69 additions & 56 deletions src/uipath/llm_client/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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} "
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""

Expand All @@ -324,6 +335,7 @@ class UiPathTooManyRequestsError(UiPathAPIError):
502: UiPathBadGatewayError,
503: UiPathServiceUnavailableError,
504: UiPathGatewayTimeoutError,
524: UiPathOriginTimeoutError,
529: UiPathTooManyRequestsError,
}

Expand Down Expand Up @@ -468,6 +480,7 @@ def wrap_provider_errors() -> Iterator[None]:
"UiPathBadGatewayError",
"UiPathServiceUnavailableError",
"UiPathGatewayTimeoutError",
"UiPathOriginTimeoutError",
"UiPathTooManyRequestsError",
"as_uipath_error",
"wrap_provider_errors",
Expand Down
Loading
Loading