Skip to content

Commit 68b7311

Browse files
authored
Added handler for Retry-After header (#16)
1 parent 18788aa commit 68b7311

7 files changed

Lines changed: 180 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

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

5+
## [1.0.11] - 2026-02-05
6+
7+
### Feature
8+
- Added retry handler on 429 to include the retry-after header
9+
510
## [1.0.10] - 2026-02-04
611

712
### Type fix
@@ -38,7 +43,7 @@ All notable changes to `uipath_llm_client` (core package) will be documented in
3843
## [1.0.3] - 2026-02-02
3944

4045
### Refactor
41-
- moved the logic of get_httpx_client_kwargs from the uipath package to this package;
46+
- moved the logic of get_httpx_ssl_client_kwargs from the uipath package to this package;
4247

4348
## [1.0.2] - 2026-02-02
4449

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__titile__ = "UiPath LLM Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services."
3-
__version__ = "1.0.10"
3+
__version__ = "1.0.11"

src/uipath_llm_client/httpx_client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
RetryableHTTPTransport,
4747
RetryConfig,
4848
)
49-
from uipath_llm_client.utils.ssl_config import get_httpx_client_kwargs
49+
from uipath_llm_client.utils.ssl_config import get_httpx_ssl_client_kwargs
5050

5151

5252
def build_routing_headers(
@@ -99,7 +99,7 @@ class UiPathHttpxClient(Client):
9999

100100
_streaming_header: str = "X-UiPath-Streaming-Enabled"
101101
_default_headers: Mapping[str, str] = {
102-
"X-UiPath-LLMGateway-TimeoutSeconds": "30", # server side timeout, default is 10, maximum is 300
102+
"X-UiPath-LLMGateway-TimeoutSeconds": "300", # server side timeout, default is 10, maximum is 300
103103
"X-UiPath-LLMGateway-AllowFull4xxResponse": "true", # allow full 4xx responses (default is false)
104104
}
105105

@@ -173,7 +173,7 @@ def __init__(
173173
event_hooks["response"].append(logging_config.log_error)
174174

175175
# setup ssl context
176-
kwargs.update(get_httpx_client_kwargs())
176+
kwargs.update(get_httpx_ssl_client_kwargs())
177177

178178
super().__init__(
179179
headers=merged_headers, transport=transport, event_hooks=event_hooks, **kwargs
@@ -293,7 +293,7 @@ def __init__(
293293
event_hooks["response"].append(logging_config.alog_error)
294294

295295
# setup ssl context
296-
kwargs.update(get_httpx_client_kwargs())
296+
kwargs.update(get_httpx_ssl_client_kwargs())
297297

298298
super().__init__(
299299
headers=merged_headers, transport=transport, event_hooks=event_hooks, **kwargs

src/uipath_llm_client/utils/exceptions.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,73 @@ class UiPathUnprocessableEntityError(UiPathAPIError):
135135

136136

137137
class UiPathRateLimitError(UiPathAPIError):
138-
"""HTTP 429 Too Many Requests error."""
138+
"""HTTP 429 Too Many Requests error.
139+
140+
Attributes:
141+
retry_after: Seconds to wait before retrying (from Retry-After header), or None.
142+
"""
139143

140144
status_code: int = 429
141145

146+
def __init__(
147+
self,
148+
message: str,
149+
*,
150+
request: Request,
151+
response: Response,
152+
body: str | dict | None = None,
153+
):
154+
super().__init__(message, request=request, response=response, body=body)
155+
self._retry_after = self._parse_retry_after(response)
156+
157+
@property
158+
def retry_after(self) -> float | None:
159+
"""Get the retry-after value in seconds, if available."""
160+
return self._retry_after
161+
162+
@staticmethod
163+
def _parse_retry_after(response: Response) -> float | None:
164+
"""Parse the Retry-After or x-retry-after header from the response.
165+
166+
The Retry-After header can be either:
167+
- A number of seconds (e.g., "120")
168+
- An HTTP-date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT")
169+
170+
Args:
171+
response: The httpx Response object.
172+
173+
Returns:
174+
The number of seconds to wait, or None if not present/parseable.
175+
"""
176+
import time
177+
from datetime import datetime, timezone
178+
179+
# Check both header variants (case-insensitive in httpx)
180+
retry_after_value = response.headers.get("retry-after") or response.headers.get(
181+
"x-retry-after"
182+
)
183+
184+
if retry_after_value is None:
185+
return None
186+
187+
# Try parsing as integer (seconds)
188+
try:
189+
return float(retry_after_value)
190+
except ValueError:
191+
pass
192+
193+
# Try parsing as HTTP-date (RFC 7231 IMF-fixdate format)
194+
# Example: "Wed, 21 Oct 2015 07:28:00 GMT"
195+
try:
196+
retry_date = datetime.strptime(retry_after_value, "%a, %d %b %Y %H:%M:%S GMT")
197+
retry_date = retry_date.replace(tzinfo=timezone.utc)
198+
delay = retry_date.timestamp() - time.time()
199+
return max(0.0, delay) # Don't return negative delays
200+
except ValueError:
201+
pass
202+
203+
return None
204+
142205

143206
class UiPathInternalServerError(UiPathAPIError):
144207
"""HTTP 500 Internal Server Error."""

src/uipath_llm_client/utils/retry.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
exponential backoff and jitter. It uses tenacity for retry handling
66
and integrates with httpx transports.
77
8+
The retry logic automatically respects the `Retry-After` or `x-retry-after`
9+
HTTP headers when present in error responses. If the header specifies a wait
10+
time, that value is used (capped at max_delay). Otherwise, exponential backoff
11+
with jitter is applied.
12+
813
Example:
914
>>> from uipath_llm_client.utils.retry import RetryableHTTPTransport, RetryConfig
1015
>>>
@@ -32,12 +37,14 @@
3237
from httpx import AsyncHTTPTransport, HTTPTransport, Request, Response
3338
from tenacity import (
3439
AsyncRetrying,
40+
RetryCallState,
3541
Retrying,
3642
before_sleep_log,
3743
retry_if_exception_type,
3844
stop_after_attempt,
3945
wait_exponential_jitter,
4046
)
47+
from tenacity.wait import wait_base
4148
from typing_extensions import TypedDict
4249

4350
from uipath_llm_client.utils.exceptions import UiPathAPIError, UiPathRateLimitError
@@ -57,6 +64,62 @@
5764
_DEFAULT_JITTER: float = 1.0
5865

5966

67+
class wait_retry_after_with_fallback(wait_base):
68+
"""Custom wait strategy that uses Retry-After header when available.
69+
70+
This wait strategy checks if the exception has a retry_after attribute
71+
(from the Retry-After or x-retry-after HTTP headers) and uses that value.
72+
If not available, falls back to exponential backoff with jitter.
73+
74+
Attributes:
75+
fallback_wait: The fallback wait strategy (exponential backoff with jitter).
76+
max_delay: Maximum delay in seconds (caps retry-after values).
77+
"""
78+
79+
def __init__(
80+
self,
81+
*,
82+
initial: float,
83+
max: float,
84+
exp_base: float,
85+
jitter: float,
86+
) -> None:
87+
"""Initialize the wait strategy.
88+
89+
Args:
90+
initial: Initial delay for exponential backoff.
91+
max: Maximum delay in seconds (also caps retry-after values).
92+
exp_base: Exponential backoff base multiplier.
93+
jitter: Random jitter to add to delays.
94+
"""
95+
self.fallback_wait = wait_exponential_jitter(
96+
initial=initial,
97+
max=max,
98+
exp_base=exp_base,
99+
jitter=jitter,
100+
)
101+
self.max_delay = max
102+
103+
def __call__(self, retry_state: RetryCallState) -> float:
104+
"""Calculate the wait time for the next retry.
105+
106+
Args:
107+
retry_state: The current retry state from tenacity.
108+
109+
Returns:
110+
The number of seconds to wait before the next retry.
111+
"""
112+
# Check if we have a rate limit exception with retry_after
113+
if retry_state.outcome is not None and retry_state.outcome.failed:
114+
exception = retry_state.outcome.exception()
115+
if isinstance(exception, UiPathRateLimitError) and exception.retry_after is not None:
116+
# Use retry-after value, but cap at max_delay
117+
return min(exception.retry_after, self.max_delay)
118+
119+
# Fall back to exponential backoff with jitter
120+
return self.fallback_wait(retry_state)
121+
122+
60123
class RetryConfig(TypedDict):
61124
"""Configuration for retry behavior on failed requests.
62125
@@ -126,7 +189,7 @@ def _build_retryer(
126189
retryer_class = AsyncRetrying if async_mode else Retrying
127190
return retryer_class(
128191
stop=stop_after_attempt(max_retries),
129-
wait=wait_exponential_jitter(
192+
wait=wait_retry_after_with_fallback(
130193
initial=initial_delay,
131194
max=max_delay,
132195
exp_base=exp_base,

src/uipath_llm_client/utils/ssl_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def create_ssl_context():
3434
)
3535

3636

37-
def get_httpx_client_kwargs() -> dict[str, Any]:
37+
def get_httpx_ssl_client_kwargs() -> dict[str, Any]:
3838
"""Get standardized httpx client configuration."""
3939
client_kwargs: dict[str, Any] = {"follow_redirects": True, "timeout": 30.0}
4040

tests/core/test_base_client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,11 +842,51 @@ def test_exception_from_response(self):
842842
mock_response.reason_phrase = "Too Many Requests"
843843
mock_response.json.return_value = {"error": "rate limited"}
844844
mock_response.request = MagicMock(spec=Request)
845+
mock_response.headers = {} # Required for UiPathRateLimitError._parse_retry_after
845846

846847
exc = UiPathAPIError.from_response(mock_response)
847848
assert isinstance(exc, UiPathRateLimitError)
848849
assert exc.status_code == 429
849850

851+
def test_exception_from_response_with_retry_after(self):
852+
"""Test UiPathRateLimitError parses Retry-After header."""
853+
mock_response = MagicMock(spec=Response)
854+
mock_response.status_code = 429
855+
mock_response.reason_phrase = "Too Many Requests"
856+
mock_response.json.return_value = {"error": "rate limited"}
857+
mock_response.request = MagicMock(spec=Request)
858+
mock_response.headers = {"retry-after": "30"}
859+
860+
exc = UiPathAPIError.from_response(mock_response)
861+
assert isinstance(exc, UiPathRateLimitError)
862+
assert exc.retry_after == 30.0
863+
864+
def test_exception_from_response_with_x_retry_after(self):
865+
"""Test UiPathRateLimitError parses x-retry-after header."""
866+
mock_response = MagicMock(spec=Response)
867+
mock_response.status_code = 429
868+
mock_response.reason_phrase = "Too Many Requests"
869+
mock_response.json.return_value = {"error": "rate limited"}
870+
mock_response.request = MagicMock(spec=Request)
871+
mock_response.headers = {"x-retry-after": "45"}
872+
873+
exc = UiPathAPIError.from_response(mock_response)
874+
assert isinstance(exc, UiPathRateLimitError)
875+
assert exc.retry_after == 45.0
876+
877+
def test_exception_retry_after_none_when_not_present(self):
878+
"""Test UiPathRateLimitError.retry_after is None when header missing."""
879+
mock_response = MagicMock(spec=Response)
880+
mock_response.status_code = 429
881+
mock_response.reason_phrase = "Too Many Requests"
882+
mock_response.json.return_value = {"error": "rate limited"}
883+
mock_response.request = MagicMock(spec=Request)
884+
mock_response.headers = {}
885+
886+
exc = UiPathAPIError.from_response(mock_response)
887+
assert isinstance(exc, UiPathRateLimitError)
888+
assert exc.retry_after is None
889+
850890

851891
# ============================================================================
852892
# Test Singleton Utility

0 commit comments

Comments
 (0)