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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to `uipath_llm_client` (core package) will be documented in this file.

## [1.18.0] - 2026-08-19

### Added
- **Execution-deadline support for LLM calls** (PC-4871). Serverless agent runs are force-killed by the control plane after a fixed execution window (15 minutes today); a single LLM call plus its retries could outlast that window, so the process died mid-call with no logs and no timeout recorded on spans. The host runtime can now declare the run's hard deadline once at startup via `set_execution_deadline(seconds_from_now)` (new `uipath.llm_client.utils.deadline` module, exported from `uipath.llm_client`). When a deadline is set, the shared retryable transports enforce it per attempt:
- the server-side `X-UiPath-LLMGateway-TimeoutSeconds` request header is lowered to the remaining budget (never raised above its configured value) so the gateway ends an in-flight attempt at the deadline with a 504;
- backoff sleeps are capped to the remaining budget and the retry loop stops once the deadline has passed (`stop_when_deadline_exhausted`, OR-ed with the existing attempt-count stop);
- once the deadline has passed, the call fails fast with the new `UiPathExecutionDeadlineError` (error code `EXECUTION_DEADLINE_EXCEEDED`) instead of starting an attempt with no budget.

When no deadline is set (the default), behaviour is unchanged. Note: for streaming responses the httpx read timeout applies per chunk, so a slowly-trickling stream can still outlast the deadline.

## [1.17.2] - 2026-08-13

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions src/uipath/llm_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,20 @@
PlatformSettings,
get_default_client_settings,
)
from uipath.llm_client.utils.deadline import (
clear_execution_deadline,
get_execution_deadline,
remaining_time_budget,
set_execution_deadline,
)
from uipath.llm_client.utils.exceptions import (
UiPathAPIError,
UiPathAuthenticationError,
UiPathBadGatewayError,
UiPathBadRequestError,
UiPathConflictError,
UiPathError,
UiPathExecutionDeadlineError,
UiPathGatewayTimeoutError,
UiPathInternalServerError,
UiPathLLMErrorCode,
Expand Down Expand Up @@ -71,8 +78,14 @@
"UiPathHttpxAsyncClient",
# Retry
"RetryConfig",
# Execution deadline
"set_execution_deadline",
"clear_execution_deadline",
"get_execution_deadline",
"remaining_time_budget",
# Exceptions
"UiPathError",
"UiPathExecutionDeadlineError",
"UiPathLLMErrorCode",
"UiPathAPIError",
"UiPathAuthenticationError",
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.17.2"
__version__ = "1.18.0"
126 changes: 126 additions & 0 deletions src/uipath/llm_client/utils/deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Execution-deadline propagation for LLM calls.

Serverless agent runs are terminated by the control plane after a fixed
execution window (15 minutes today). A single LLM call plus its automatic
retries could previously outlast that window: the fixed 895s request timeout
left no room for retries, the retry loop was bounded by attempt count rather
than elapsed time, and the process was force-killed mid-call — no logs were
flushed and spans never recorded a timeout.

This module lets the host runtime declare the run's hard deadline once, at
startup::

from uipath.llm_client import set_execution_deadline

# serverless window minus a safety buffer for graceful shutdown
set_execution_deadline(15 * 60 - 10)

Every request sent through the shared retryable transports then:

* rewrites the ``X-UiPath-LLMGateway-TimeoutSeconds`` request header downward
so the server-side timeout never exceeds the caller's remaining budget —
the gateway ends an in-flight attempt at the deadline with a 504,
* caps backoff sleeps to the remaining budget and stops the retry loop once
the deadline has passed, and
* fails fast with ``UiPathExecutionDeadlineError`` when the deadline has
already passed, instead of starting an attempt with no budget.

When no deadline is set, behaviour is exactly as before.

The deadline is stored in a ContextVar holding a ``time.monotonic()`` timestamp.
Set it in the main task before any request-issuing tasks are spawned.
"""

import math
import time
from contextvars import ContextVar, Token

from httpx import Request

from uipath.llm_client.utils.exceptions import UiPathExecutionDeadlineError

LLM_GATEWAY_TIMEOUT_SECONDS_HEADER = "X-UiPath-LLMGateway-TimeoutSeconds"

_EXECUTION_DEADLINE: ContextVar[float | None] = ContextVar("_execution_deadline", default=None)


def set_execution_deadline(seconds_from_now: float) -> Token[float | None]:
"""Declare that the current run must finish within *seconds_from_now* seconds.

The caller owns the safety buffer: pass the execution window minus
whatever time graceful shutdown needs (log/span flushing, state save).

Returns:
A token that can be passed to :func:`clear_execution_deadline` to
restore the previous value.
"""
return _EXECUTION_DEADLINE.set(time.monotonic() + seconds_from_now)


def clear_execution_deadline(token: Token[float | None] | None = None) -> None:
"""Remove the execution deadline for the current context.

Args:
token: When given (from :func:`set_execution_deadline`), restores the
previous value instead of unconditionally clearing.
"""
if token is not None:
_EXECUTION_DEADLINE.reset(token)
else:
_EXECUTION_DEADLINE.set(None)


def get_execution_deadline() -> float | None:
"""The run's deadline as a ``time.monotonic()`` timestamp, or None."""
return _EXECUTION_DEADLINE.get()


def remaining_time_budget() -> float | None:
"""Seconds left until the execution deadline.

Returns None when no deadline is set. Never negative — a passed deadline
reports 0.0.
"""
deadline = _EXECUTION_DEADLINE.get()
if deadline is None:
return None
return max(0.0, deadline - time.monotonic())


def apply_execution_deadline(request: Request) -> None:
"""Limit a single request attempt to the remaining execution budget.

Applied by the shared transports to every outgoing LLM request (with or
without retries). No-op when no deadline is declared. Otherwise:

* raises :class:`UiPathExecutionDeadlineError` when the deadline has
already passed,
* lowers the server-side ``X-UiPath-LLMGateway-TimeoutSeconds`` timeout
limit to the remaining budget, so the gateway ends an in-flight attempt
at the deadline with a 504.

Called once per attempt so retries see a freshly shrunk budget.
"""
remaining = remaining_time_budget()
if remaining is None:
return
if remaining <= 0:
raise UiPathExecutionDeadlineError()

timeout_limit = math.ceil(remaining)
try:
configured_timeout_limit = int(request.headers[LLM_GATEWAY_TIMEOUT_SECONDS_HEADER])
except (KeyError, ValueError):
configured_timeout_limit = None
if configured_timeout_limit is None or timeout_limit < configured_timeout_limit:
request.headers[LLM_GATEWAY_TIMEOUT_SECONDS_HEADER] = str(timeout_limit)


__all__ = [
"LLM_GATEWAY_TIMEOUT_SECONDS_HEADER",
"set_execution_deadline",
"clear_execution_deadline",
"get_execution_deadline",
"remaining_time_budget",
"apply_execution_deadline",
]
19 changes: 19 additions & 0 deletions src/uipath/llm_client/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class UiPathLLMErrorCode(StrEnum):

UNSUPPORTED_MIME_TYPE = "UNSUPPORTED_MIME_TYPE"
MODEL_NOT_FOUND = "MODEL_NOT_FOUND"
EXECUTION_DEADLINE_EXCEEDED = "EXECUTION_DEADLINE_EXCEEDED"


class UiPathError(Exception):
Expand Down Expand Up @@ -114,6 +115,24 @@ def __init__(self, detail: str | None = None) -> None:
UiPathError.__init__(self, detail, error_code=UiPathLLMErrorCode.MODEL_NOT_FOUND)


class UiPathExecutionDeadlineError(UiPathError):
"""Raised when the run's execution deadline has passed before an LLM call.

Raised by the retryable transports before a request attempt (initial or
retry) when the time budget declared via
:func:`uipath.llm_client.utils.deadline.set_execution_deadline` is
exhausted. Deliberately not retryable: the run is out of time and must
fail cleanly inside its execution window instead of being force-killed by
the control plane.
"""

def __init__(self) -> None:
super().__init__(
"The run's execution deadline has passed; no time budget remains for this LLM call.",
error_code=UiPathLLMErrorCode.EXECUTION_DEADLINE_EXCEEDED,
)


class UiPathAPIError(UiPathError, HTTPStatusError):
"""Base exception for all UiPath API errors.

Expand Down
3 changes: 2 additions & 1 deletion src/uipath/llm_client/utils/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

from uipath.llm_client.settings.base import UiPathAPIConfig
from uipath.llm_client.settings.constants import ApiType, RoutingMode
from uipath.llm_client.utils.deadline import LLM_GATEWAY_TIMEOUT_SECONDS_HEADER

UIPATH_DEFAULT_REQUEST_HEADERS: dict[str, str] = {
"X-UiPath-LLMGateway-TimeoutSeconds": "895", # server side timeout
LLM_GATEWAY_TIMEOUT_SECONDS_HEADER: "895", # server side timeout; lowered per-request when an execution deadline is set
"X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — kept false to avoid PII leakage in logs
}

Expand Down
41 changes: 37 additions & 4 deletions src/uipath/llm_client/utils/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,17 @@
retry_if_exception,
retry_if_exception_type,
stop_after_attempt,
stop_any,
wait_exponential_jitter,
)
from tenacity.stop import stop_base
from tenacity.wait import wait_base
from typing_extensions import TypedDict

from uipath.llm_client.utils.deadline import (
apply_execution_deadline,
remaining_time_budget,
)
from uipath.llm_client.utils.exceptions import (
UiPathAPIError,
UiPathBadGatewayError,
Expand Down Expand Up @@ -96,6 +102,19 @@
_DEFAULT_JITTER: float = 1.0


class stop_when_deadline_exhausted(stop_base):
"""Tenacity stop strategy that ends the retry loop at the execution deadline.

Stops once the execution deadline has passed, so the retry loop is bounded
by the wall-clock time left in the run rather than only by attempt count.
Inactive when no deadline is declared.
"""

def __call__(self, retry_state: RetryCallState) -> bool:
remaining = remaining_time_budget()
return remaining is not None and remaining <= 0


class wait_retry_after_with_fallback(wait_base):
"""Custom wait strategy that uses Retry-After header when available.

Expand Down Expand Up @@ -144,14 +163,24 @@ def __call__(self, retry_state: RetryCallState) -> float:
"""
# Honor Retry-After from any API error, not just 429 — servers attach
# it to 5xx (and occasionally other) responses as an explicit wait hint.
wait: float | None = None
if retry_state.outcome is not None and retry_state.outcome.failed:
exception = retry_state.outcome.exception()
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)
wait = min(exception.retry_after, self.max_delay)

if wait is None:
# Fall back to exponential backoff with jitter
wait = self.fallback_wait(retry_state)

# Fall back to exponential backoff with jitter
return self.fallback_wait(retry_state)
# Never sleep through the execution deadline: if the backoff outlives
# the remaining budget, wake at the deadline (the next attempt then
# fails fast with UiPathExecutionDeadlineError).
remaining = remaining_time_budget()
if remaining is not None:
wait = min(wait, remaining)
return wait


class RetryConfig(TypedDict):
Expand Down Expand Up @@ -225,7 +254,7 @@ def _build_retryer(

retryer_class = AsyncRetrying if async_mode else Retrying
return retryer_class(
stop=stop_after_attempt(max_retries),
stop=stop_any(stop_after_attempt(max_retries), stop_when_deadline_exhausted()),
wait=wait_retry_after_with_fallback(
initial=initial_delay,
max=max_delay,
Expand Down Expand Up @@ -297,11 +326,13 @@ def handle_request(self, request: Request) -> Response:
instead of raising exceptions.
"""
if self.retryer is None:
apply_execution_deadline(request)
return super().handle_request(request)

parent_handle = super().handle_request

def _send() -> Response:
apply_execution_deadline(request)
response = parent_handle(request)
if response.is_error:
raise UiPathAPIError.from_response(response, request)
Expand Down Expand Up @@ -361,11 +392,13 @@ async def handle_async_request(self, request: Request) -> Response:
instead of raising exceptions.
"""
if self.retryer is None:
apply_execution_deadline(request)
return await super().handle_async_request(request)

parent_handle = super().handle_async_request

async def _send() -> Response:
apply_execution_deadline(request)
response = await parent_handle(request)
if response.is_error:
raise UiPathAPIError.from_response(response, request)
Expand Down
Loading
Loading