|
3 | 3 | import logging |
4 | 4 | from unittest.mock import MagicMock, patch |
5 | 5 |
|
| 6 | +import httpx |
| 7 | +import pytest |
| 8 | + |
| 9 | +from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient, UiPathHttpxClient |
6 | 10 | from uipath.llm_client.utils.exceptions import ( |
7 | 11 | UiPathBadGatewayError, |
8 | 12 | UiPathGatewayTimeoutError, |
|
19 | 23 | RetryConfig, |
20 | 24 | ) |
21 | 25 |
|
| 26 | +_NO_DELAY_CONFIG: RetryConfig = {"initial_delay": 0, "max_delay": 0, "jitter": 0} |
| 27 | + |
22 | 28 |
|
23 | 29 | class TestDefaultRetryOnExceptions: |
24 | 30 | """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): |
263 | 269 | result = _build_retryer(max_retries=2, retry_config=None, logger=logger) |
264 | 270 | assert result is not None |
265 | 271 | assert result.before_sleep is not None |
| 272 | + |
| 273 | + |
| 274 | +class TestRetryOn504EndToEnd: |
| 275 | + """End-to-end coverage that a 504 response actually triggers the retry loop. |
| 276 | +
|
| 277 | + The other test classes verify the *configuration* (default exception set, |
| 278 | + retryer wiring, wait math). These tests drive a real request through |
| 279 | + ``RetryableHTTPTransport.handle_request`` / ``handle_async_request`` and |
| 280 | + assert the underlying call fires ``max_retries`` times before the final 504 |
| 281 | + response is returned. |
| 282 | + """ |
| 283 | + |
| 284 | + def test_sync_504_retries_max_retries_times_then_returns_response(self): |
| 285 | + calls = 0 |
| 286 | + |
| 287 | + def fake_504(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: |
| 288 | + nonlocal calls |
| 289 | + calls += 1 |
| 290 | + return httpx.Response(504, content=b"{}", request=request) |
| 291 | + |
| 292 | + client = UiPathHttpxClient( |
| 293 | + base_url="https://example.com", |
| 294 | + max_retries=3, |
| 295 | + retry_config=_NO_DELAY_CONFIG, |
| 296 | + ) |
| 297 | + try: |
| 298 | + with patch.object(httpx.HTTPTransport, "handle_request", fake_504): |
| 299 | + response = client.post("/anything", json={}) |
| 300 | + finally: |
| 301 | + client.close() |
| 302 | + |
| 303 | + assert calls == 3 |
| 304 | + assert response.status_code == 504 |
| 305 | + |
| 306 | + def test_sync_max_retries_zero_makes_exactly_one_call(self): |
| 307 | + calls = 0 |
| 308 | + |
| 309 | + def fake_504(self: httpx.HTTPTransport, request: httpx.Request) -> httpx.Response: |
| 310 | + nonlocal calls |
| 311 | + calls += 1 |
| 312 | + return httpx.Response(504, content=b"{}", request=request) |
| 313 | + |
| 314 | + client = UiPathHttpxClient(base_url="https://example.com", max_retries=0) |
| 315 | + try: |
| 316 | + with patch.object(httpx.HTTPTransport, "handle_request", fake_504): |
| 317 | + response = client.post("/anything", json={}) |
| 318 | + finally: |
| 319 | + client.close() |
| 320 | + |
| 321 | + assert calls == 1 |
| 322 | + assert response.status_code == 504 |
| 323 | + |
| 324 | + @pytest.mark.asyncio |
| 325 | + async def test_async_504_retries_max_retries_times_then_returns_response(self): |
| 326 | + calls = 0 |
| 327 | + |
| 328 | + async def fake_504( |
| 329 | + self: httpx.AsyncHTTPTransport, request: httpx.Request |
| 330 | + ) -> httpx.Response: |
| 331 | + nonlocal calls |
| 332 | + calls += 1 |
| 333 | + return httpx.Response(504, content=b"{}", request=request) |
| 334 | + |
| 335 | + client = UiPathHttpxAsyncClient( |
| 336 | + base_url="https://example.com", |
| 337 | + max_retries=3, |
| 338 | + retry_config=_NO_DELAY_CONFIG, |
| 339 | + ) |
| 340 | + try: |
| 341 | + with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", fake_504): |
| 342 | + response = await client.post("/anything", json={}) |
| 343 | + finally: |
| 344 | + await client.aclose() |
| 345 | + |
| 346 | + assert calls == 3 |
| 347 | + assert response.status_code == 504 |
0 commit comments