Skip to content

Commit 2e13349

Browse files
Skip retries for non-retryable 4xx polling errors
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent d4de8e5 commit 2e13349

File tree

2 files changed

+182
-0
lines changed

2 files changed

+182
-0
lines changed

hyperbrowser/client/polling.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
T = TypeVar("T")
1515
_MAX_OPERATION_NAME_LENGTH = 200
16+
_CLIENT_ERROR_STATUS_MIN = 400
17+
_CLIENT_ERROR_STATUS_MAX = 500
18+
_RETRYABLE_CLIENT_ERROR_STATUS_CODES = {429}
1619

1720

1821
class _NonRetryablePollingError(HyperbrowserError):
@@ -107,6 +110,18 @@ def _invoke_non_retryable_callback(
107110
) from exc
108111

109112

113+
def _is_retryable_exception(exc: Exception) -> bool:
114+
if isinstance(exc, _NonRetryablePollingError):
115+
return False
116+
if isinstance(exc, HyperbrowserError) and exc.status_code is not None:
117+
if (
118+
_CLIENT_ERROR_STATUS_MIN <= exc.status_code < _CLIENT_ERROR_STATUS_MAX
119+
and exc.status_code not in _RETRYABLE_CLIENT_ERROR_STATUS_CODES
120+
):
121+
return False
122+
return True
123+
124+
110125
def _validate_retry_config(
111126
*,
112127
max_attempts: int,
@@ -203,6 +218,8 @@ def poll_until_terminal_status(
203218
status = get_status()
204219
failures = 0
205220
except Exception as exc:
221+
if not _is_retryable_exception(exc):
222+
raise
206223
failures += 1
207224
if failures >= max_status_failures:
208225
raise HyperbrowserPollingError(
@@ -244,6 +261,8 @@ def retry_operation(
244261
try:
245262
operation_result = operation()
246263
except Exception as exc:
264+
if not _is_retryable_exception(exc):
265+
raise
247266
failures += 1
248267
if failures >= max_attempts:
249268
raise HyperbrowserError(
@@ -284,6 +303,8 @@ async def poll_until_terminal_status_async(
284303
try:
285304
status_result = get_status()
286305
except Exception as exc:
306+
if not _is_retryable_exception(exc):
307+
raise
287308
failures += 1
288309
if failures >= max_status_failures:
289310
raise HyperbrowserPollingError(
@@ -303,6 +324,8 @@ async def poll_until_terminal_status_async(
303324
status = await status_awaitable
304325
failures = 0
305326
except Exception as exc:
327+
if not _is_retryable_exception(exc):
328+
raise
306329
failures += 1
307330
if failures >= max_status_failures:
308331
raise HyperbrowserPollingError(
@@ -344,6 +367,8 @@ async def retry_operation_async(
344367
try:
345368
operation_result = operation()
346369
except Exception as exc:
370+
if not _is_retryable_exception(exc):
371+
raise
347372
failures += 1
348373
if failures >= max_attempts:
349374
raise HyperbrowserError(
@@ -361,6 +386,8 @@ async def retry_operation_async(
361386
try:
362387
return await operation_awaitable
363388
except Exception as exc:
389+
if not _is_retryable_exception(exc):
390+
raise
364391
failures += 1
365392
if failures >= max_attempts:
366393
raise HyperbrowserError(
@@ -460,6 +487,8 @@ def collect_paginated_results(
460487
except HyperbrowserPollingError:
461488
raise
462489
except Exception as exc:
490+
if not _is_retryable_exception(exc):
491+
raise
463492
failures += 1
464493
if failures >= max_attempts:
465494
raise HyperbrowserError(
@@ -565,6 +594,8 @@ async def collect_paginated_results_async(
565594
except HyperbrowserPollingError:
566595
raise
567596
except Exception as exc:
597+
if not _is_retryable_exception(exc):
598+
raise
568599
failures += 1
569600
if failures >= max_attempts:
570601
raise HyperbrowserError(

tests/test_polling.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,48 @@ def get_status() -> str:
102102
assert status == "completed"
103103

104104

105+
def test_poll_until_terminal_status_does_not_retry_non_retryable_client_errors():
106+
attempts = {"count": 0}
107+
108+
def get_status() -> str:
109+
attempts["count"] += 1
110+
raise HyperbrowserError("client failure", status_code=400)
111+
112+
with pytest.raises(HyperbrowserError, match="client failure"):
113+
poll_until_terminal_status(
114+
operation_name="sync poll client error",
115+
get_status=get_status,
116+
is_terminal_status=lambda value: value == "completed",
117+
poll_interval_seconds=0.0001,
118+
max_wait_seconds=1.0,
119+
max_status_failures=5,
120+
)
121+
122+
assert attempts["count"] == 1
123+
124+
125+
def test_poll_until_terminal_status_retries_rate_limit_errors():
126+
attempts = {"count": 0}
127+
128+
def get_status() -> str:
129+
attempts["count"] += 1
130+
if attempts["count"] < 3:
131+
raise HyperbrowserError("rate limited", status_code=429)
132+
return "completed"
133+
134+
status = poll_until_terminal_status(
135+
operation_name="sync poll rate limit retries",
136+
get_status=get_status,
137+
is_terminal_status=lambda value: value == "completed",
138+
poll_interval_seconds=0.0001,
139+
max_wait_seconds=1.0,
140+
max_status_failures=5,
141+
)
142+
143+
assert status == "completed"
144+
assert attempts["count"] == 3
145+
146+
105147
def test_poll_until_terminal_status_raises_after_status_failures():
106148
with pytest.raises(
107149
HyperbrowserPollingError, match="Failed to poll sync poll failure"
@@ -170,6 +212,24 @@ def test_retry_operation_raises_after_max_attempts():
170212
)
171213

172214

215+
def test_retry_operation_does_not_retry_non_retryable_client_errors():
216+
attempts = {"count": 0}
217+
218+
def operation() -> str:
219+
attempts["count"] += 1
220+
raise HyperbrowserError("client failure", status_code=404)
221+
222+
with pytest.raises(HyperbrowserError, match="client failure"):
223+
retry_operation(
224+
operation_name="sync retry client error",
225+
operation=operation,
226+
max_attempts=5,
227+
retry_delay_seconds=0.0001,
228+
)
229+
230+
assert attempts["count"] == 1
231+
232+
173233
def test_retry_operation_rejects_awaitable_operation_result():
174234
async def async_operation() -> str:
175235
return "ok"
@@ -240,6 +300,50 @@ async def run() -> None:
240300
asyncio.run(run())
241301

242302

303+
def test_poll_until_terminal_status_async_does_not_retry_non_retryable_client_errors():
304+
async def run() -> None:
305+
attempts = {"count": 0}
306+
307+
async def get_status() -> str:
308+
attempts["count"] += 1
309+
raise HyperbrowserError("client failure", status_code=400)
310+
311+
with pytest.raises(HyperbrowserError, match="client failure"):
312+
await poll_until_terminal_status_async(
313+
operation_name="async poll client error",
314+
get_status=get_status,
315+
is_terminal_status=lambda value: value == "completed",
316+
poll_interval_seconds=0.0001,
317+
max_wait_seconds=1.0,
318+
max_status_failures=5,
319+
)
320+
321+
assert attempts["count"] == 1
322+
323+
asyncio.run(run())
324+
325+
326+
def test_retry_operation_async_does_not_retry_non_retryable_client_errors():
327+
async def run() -> None:
328+
attempts = {"count": 0}
329+
330+
async def operation() -> str:
331+
attempts["count"] += 1
332+
raise HyperbrowserError("client failure", status_code=400)
333+
334+
with pytest.raises(HyperbrowserError, match="client failure"):
335+
await retry_operation_async(
336+
operation_name="async retry client error",
337+
operation=operation,
338+
max_attempts=5,
339+
retry_delay_seconds=0.0001,
340+
)
341+
342+
assert attempts["count"] == 1
343+
344+
asyncio.run(run())
345+
346+
243347
def test_async_poll_until_terminal_status_allows_immediate_terminal_on_zero_max_wait():
244348
async def run() -> None:
245349
status = await poll_until_terminal_status_async(
@@ -745,6 +849,28 @@ def test_collect_paginated_results_raises_after_page_failures():
745849
)
746850

747851

852+
def test_collect_paginated_results_does_not_retry_non_retryable_client_errors():
853+
attempts = {"count": 0}
854+
855+
def get_next_page(page: int) -> dict:
856+
attempts["count"] += 1
857+
raise HyperbrowserError("client failure", status_code=400)
858+
859+
with pytest.raises(HyperbrowserError, match="client failure"):
860+
collect_paginated_results(
861+
operation_name="sync paginated client error",
862+
get_next_page=get_next_page,
863+
get_current_page_batch=lambda response: response["current"],
864+
get_total_page_batches=lambda response: response["total"],
865+
on_page_success=lambda response: None,
866+
max_wait_seconds=1.0,
867+
max_attempts=5,
868+
retry_delay_seconds=0.0001,
869+
)
870+
871+
assert attempts["count"] == 1
872+
873+
748874
def test_collect_paginated_results_raises_when_page_batch_stagnates():
749875
with pytest.raises(HyperbrowserPollingError, match="No pagination progress"):
750876
collect_paginated_results(
@@ -911,6 +1037,31 @@ async def run() -> None:
9111037
asyncio.run(run())
9121038

9131039

1040+
def test_collect_paginated_results_async_does_not_retry_non_retryable_client_errors():
1041+
async def run() -> None:
1042+
attempts = {"count": 0}
1043+
1044+
async def get_next_page(page: int) -> dict:
1045+
attempts["count"] += 1
1046+
raise HyperbrowserError("client failure", status_code=404)
1047+
1048+
with pytest.raises(HyperbrowserError, match="client failure"):
1049+
await collect_paginated_results_async(
1050+
operation_name="async paginated client error",
1051+
get_next_page=get_next_page,
1052+
get_current_page_batch=lambda response: response["current"],
1053+
get_total_page_batches=lambda response: response["total"],
1054+
on_page_success=lambda response: None,
1055+
max_wait_seconds=1.0,
1056+
max_attempts=5,
1057+
retry_delay_seconds=0.0001,
1058+
)
1059+
1060+
assert attempts["count"] == 1
1061+
1062+
asyncio.run(run())
1063+
1064+
9141065
def test_wait_for_job_result_returns_fetched_value():
9151066
status_values = iter(["running", "completed"])
9161067

0 commit comments

Comments
 (0)