Skip to content

Commit 1be8ebb

Browse files
Fail fast on terminal callback errors and verify 5xx retries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 5e1e378 commit 1be8ebb

File tree

2 files changed

+202
-2
lines changed

2 files changed

+202
-2
lines changed

hyperbrowser/client/polling.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,14 @@ def poll_until_terminal_status(
233233
continue
234234

235235
status = _ensure_status_string(status, operation_name=operation_name)
236+
terminal_status_result = _invoke_non_retryable_callback(
237+
is_terminal_status,
238+
status,
239+
callback_name="is_terminal_status",
240+
operation_name=operation_name,
241+
)
236242
if _ensure_boolean_terminal_result(
237-
is_terminal_status(status), operation_name=operation_name
243+
terminal_status_result, operation_name=operation_name
238244
):
239245
return status
240246
if has_exceeded_max_wait(start_time, max_wait_seconds):
@@ -339,8 +345,14 @@ async def poll_until_terminal_status_async(
339345
continue
340346

341347
status = _ensure_status_string(status, operation_name=operation_name)
348+
terminal_status_result = _invoke_non_retryable_callback(
349+
is_terminal_status,
350+
status,
351+
callback_name="is_terminal_status",
352+
operation_name=operation_name,
353+
)
342354
if _ensure_boolean_terminal_result(
343-
is_terminal_status(status), operation_name=operation_name
355+
terminal_status_result, operation_name=operation_name
344356
):
345357
return status
346358
if has_exceeded_max_wait(start_time, max_wait_seconds):

tests/test_polling.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ def get_status() -> str:
144144
assert attempts["count"] == 3
145145

146146

147+
def test_poll_until_terminal_status_retries_server_errors():
148+
attempts = {"count": 0}
149+
150+
def get_status() -> str:
151+
attempts["count"] += 1
152+
if attempts["count"] < 3:
153+
raise HyperbrowserError("server failure", status_code=503)
154+
return "completed"
155+
156+
status = poll_until_terminal_status(
157+
operation_name="sync poll server error retries",
158+
get_status=get_status,
159+
is_terminal_status=lambda value: value == "completed",
160+
poll_interval_seconds=0.0001,
161+
max_wait_seconds=1.0,
162+
max_status_failures=5,
163+
)
164+
165+
assert status == "completed"
166+
assert attempts["count"] == 3
167+
168+
147169
def test_poll_until_terminal_status_raises_after_status_failures():
148170
with pytest.raises(
149171
HyperbrowserPollingError, match="Failed to poll sync poll failure"
@@ -183,6 +205,26 @@ def get_status() -> object:
183205
assert attempts["count"] == 1
184206

185207

208+
def test_poll_until_terminal_status_fails_fast_when_terminal_callback_raises():
209+
attempts = {"count": 0}
210+
211+
def get_status() -> str:
212+
attempts["count"] += 1
213+
return "completed"
214+
215+
with pytest.raises(HyperbrowserError, match="is_terminal_status failed"):
216+
poll_until_terminal_status(
217+
operation_name="sync terminal callback exception",
218+
get_status=get_status,
219+
is_terminal_status=lambda value: (_ for _ in ()).throw(ValueError("boom")),
220+
poll_interval_seconds=0.0001,
221+
max_wait_seconds=1.0,
222+
max_status_failures=5,
223+
)
224+
225+
assert attempts["count"] == 1
226+
227+
186228
def test_retry_operation_retries_and_returns_value():
187229
attempts = {"count": 0}
188230

@@ -230,6 +272,26 @@ def operation() -> str:
230272
assert attempts["count"] == 1
231273

232274

275+
def test_retry_operation_retries_server_errors():
276+
attempts = {"count": 0}
277+
278+
def operation() -> str:
279+
attempts["count"] += 1
280+
if attempts["count"] < 3:
281+
raise HyperbrowserError("server failure", status_code=502)
282+
return "ok"
283+
284+
result = retry_operation(
285+
operation_name="sync retry server error",
286+
operation=operation,
287+
max_attempts=5,
288+
retry_delay_seconds=0.0001,
289+
)
290+
291+
assert result == "ok"
292+
assert attempts["count"] == 3
293+
294+
233295
def test_retry_operation_rejects_awaitable_operation_result():
234296
async def async_operation() -> str:
235297
return "ok"
@@ -323,6 +385,56 @@ async def get_status() -> str:
323385
asyncio.run(run())
324386

325387

388+
def test_poll_until_terminal_status_async_retries_server_errors():
389+
async def run() -> None:
390+
attempts = {"count": 0}
391+
392+
async def get_status() -> str:
393+
attempts["count"] += 1
394+
if attempts["count"] < 3:
395+
raise HyperbrowserError("server failure", status_code=503)
396+
return "completed"
397+
398+
status = await poll_until_terminal_status_async(
399+
operation_name="async poll server error retries",
400+
get_status=get_status,
401+
is_terminal_status=lambda value: value == "completed",
402+
poll_interval_seconds=0.0001,
403+
max_wait_seconds=1.0,
404+
max_status_failures=5,
405+
)
406+
407+
assert status == "completed"
408+
assert attempts["count"] == 3
409+
410+
asyncio.run(run())
411+
412+
413+
def test_poll_until_terminal_status_async_fails_fast_when_terminal_callback_raises():
414+
async def run() -> None:
415+
attempts = {"count": 0}
416+
417+
async def get_status() -> str:
418+
attempts["count"] += 1
419+
return "completed"
420+
421+
with pytest.raises(HyperbrowserError, match="is_terminal_status failed"):
422+
await poll_until_terminal_status_async(
423+
operation_name="async terminal callback exception",
424+
get_status=get_status,
425+
is_terminal_status=lambda value: (_ for _ in ()).throw(
426+
ValueError("boom")
427+
),
428+
poll_interval_seconds=0.0001,
429+
max_wait_seconds=1.0,
430+
max_status_failures=5,
431+
)
432+
433+
assert attempts["count"] == 1
434+
435+
asyncio.run(run())
436+
437+
326438
def test_retry_operation_async_does_not_retry_non_retryable_client_errors():
327439
async def run() -> None:
328440
attempts = {"count": 0}
@@ -344,6 +456,29 @@ async def operation() -> str:
344456
asyncio.run(run())
345457

346458

459+
def test_retry_operation_async_retries_server_errors():
460+
async def run() -> None:
461+
attempts = {"count": 0}
462+
463+
async def operation() -> str:
464+
attempts["count"] += 1
465+
if attempts["count"] < 3:
466+
raise HyperbrowserError("server failure", status_code=503)
467+
return "ok"
468+
469+
result = await retry_operation_async(
470+
operation_name="async retry server error",
471+
operation=operation,
472+
max_attempts=5,
473+
retry_delay_seconds=0.0001,
474+
)
475+
476+
assert result == "ok"
477+
assert attempts["count"] == 3
478+
479+
asyncio.run(run())
480+
481+
347482
def test_async_poll_until_terminal_status_allows_immediate_terminal_on_zero_max_wait():
348483
async def run() -> None:
349484
status = await poll_until_terminal_status_async(
@@ -871,6 +1006,31 @@ def get_next_page(page: int) -> dict:
8711006
assert attempts["count"] == 1
8721007

8731008

1009+
def test_collect_paginated_results_retries_server_errors():
1010+
attempts = {"count": 0}
1011+
collected = []
1012+
1013+
def get_next_page(page: int) -> dict:
1014+
attempts["count"] += 1
1015+
if attempts["count"] < 3:
1016+
raise HyperbrowserError("server failure", status_code=502)
1017+
return {"current": 1, "total": 1, "items": ["a"]}
1018+
1019+
collect_paginated_results(
1020+
operation_name="sync paginated server error retries",
1021+
get_next_page=get_next_page,
1022+
get_current_page_batch=lambda response: response["current"],
1023+
get_total_page_batches=lambda response: response["total"],
1024+
on_page_success=lambda response: collected.extend(response["items"]),
1025+
max_wait_seconds=1.0,
1026+
max_attempts=5,
1027+
retry_delay_seconds=0.0001,
1028+
)
1029+
1030+
assert collected == ["a"]
1031+
assert attempts["count"] == 3
1032+
1033+
8741034
def test_collect_paginated_results_raises_when_page_batch_stagnates():
8751035
with pytest.raises(HyperbrowserPollingError, match="No pagination progress"):
8761036
collect_paginated_results(
@@ -1062,6 +1222,34 @@ async def get_next_page(page: int) -> dict:
10621222
asyncio.run(run())
10631223

10641224

1225+
def test_collect_paginated_results_async_retries_server_errors():
1226+
async def run() -> None:
1227+
attempts = {"count": 0}
1228+
collected = []
1229+
1230+
async def get_next_page(page: int) -> dict:
1231+
attempts["count"] += 1
1232+
if attempts["count"] < 3:
1233+
raise HyperbrowserError("server failure", status_code=503)
1234+
return {"current": 1, "total": 1, "items": ["a"]}
1235+
1236+
await collect_paginated_results_async(
1237+
operation_name="async paginated server error retries",
1238+
get_next_page=get_next_page,
1239+
get_current_page_batch=lambda response: response["current"],
1240+
get_total_page_batches=lambda response: response["total"],
1241+
on_page_success=lambda response: collected.extend(response["items"]),
1242+
max_wait_seconds=1.0,
1243+
max_attempts=5,
1244+
retry_delay_seconds=0.0001,
1245+
)
1246+
1247+
assert collected == ["a"]
1248+
assert attempts["count"] == 3
1249+
1250+
asyncio.run(run())
1251+
1252+
10651253
def test_wait_for_job_result_returns_fetched_value():
10661254
status_values = iter(["running", "completed"])
10671255

0 commit comments

Comments
 (0)