Skip to content

Commit fff94a4

Browse files
Harden polling error formatting for unstringifiable exceptions
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent f502bc9 commit fff94a4

2 files changed

Lines changed: 104 additions & 14 deletions

File tree

hyperbrowser/client/polling.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ class _NonRetryablePollingError(HyperbrowserError):
3030
pass
3131

3232

33+
def _safe_exception_text(exc: Exception) -> str:
34+
try:
35+
return str(exc)
36+
except Exception:
37+
return f"<unstringifiable {type(exc).__name__}>"
38+
39+
40+
def _normalized_exception_text(exc: Exception) -> str:
41+
return _safe_exception_text(exc).lower()
42+
43+
3344
def _coerce_operation_name_component(value: object, *, fallback: str) -> str:
3445
if isinstance(value, str):
3546
return value
@@ -206,21 +217,21 @@ def _invoke_non_retryable_callback(
206217
raise
207218
except Exception as exc:
208219
raise _NonRetryablePollingError(
209-
f"{callback_name} failed for {operation_name}: {exc}"
220+
f"{callback_name} failed for {operation_name}: {_safe_exception_text(exc)}"
210221
) from exc
211222

212223

213224
def _is_reused_coroutine_runtime_error(exc: Exception) -> bool:
214225
if not isinstance(exc, RuntimeError):
215226
return False
216-
normalized_message = str(exc).lower()
227+
normalized_message = _normalized_exception_text(exc)
217228
return "coroutine" in normalized_message and "already awaited" in normalized_message
218229

219230

220231
def _is_async_generator_reuse_runtime_error(exc: Exception) -> bool:
221232
if not isinstance(exc, RuntimeError):
222233
return False
223-
normalized_message = str(exc).lower()
234+
normalized_message = _normalized_exception_text(exc)
224235
return (
225236
"asynchronous generator" in normalized_message
226237
and "already running" in normalized_message
@@ -230,13 +241,13 @@ def _is_async_generator_reuse_runtime_error(exc: Exception) -> bool:
230241
def _is_generator_reentrancy_error(exc: Exception) -> bool:
231242
if not isinstance(exc, ValueError):
232243
return False
233-
return "generator already executing" in str(exc).lower()
244+
return "generator already executing" in _normalized_exception_text(exc)
234245

235246

236247
def _is_async_loop_contract_runtime_error(exc: Exception) -> bool:
237248
if not isinstance(exc, RuntimeError):
238249
return False
239-
normalized_message = str(exc).lower()
250+
normalized_message = _normalized_exception_text(exc)
240251
if "event loop is closed" in normalized_message:
241252
return True
242253
if "event loop other than the current one" in normalized_message:
@@ -253,7 +264,7 @@ def _is_async_loop_contract_runtime_error(exc: Exception) -> bool:
253264
def _is_executor_shutdown_runtime_error(exc: Exception) -> bool:
254265
if not isinstance(exc, RuntimeError):
255266
return False
256-
normalized_message = str(exc).lower()
267+
normalized_message = _normalized_exception_text(exc)
257268
return (
258269
"cannot schedule new futures after" in normalized_message
259270
and "shutdown" in normalized_message
@@ -445,7 +456,9 @@ def poll_until_terminal_status(
445456
failures += 1
446457
if failures >= max_status_failures:
447458
raise HyperbrowserPollingError(
448-
f"Failed to poll {operation_name} after {max_status_failures} attempts: {exc}"
459+
"Failed to poll "
460+
f"{operation_name} after {max_status_failures} attempts: "
461+
f"{_safe_exception_text(exc)}"
449462
) from exc
450463
if has_exceeded_max_wait(start_time, max_wait_seconds):
451464
raise HyperbrowserTimeoutError(
@@ -494,7 +507,8 @@ def retry_operation(
494507
failures += 1
495508
if failures >= max_attempts:
496509
raise HyperbrowserError(
497-
f"{operation_name} failed after {max_attempts} attempts: {exc}"
510+
f"{operation_name} failed after {max_attempts} attempts: "
511+
f"{_safe_exception_text(exc)}"
498512
) from exc
499513
time.sleep(retry_delay_seconds)
500514
continue
@@ -536,7 +550,9 @@ async def poll_until_terminal_status_async(
536550
failures += 1
537551
if failures >= max_status_failures:
538552
raise HyperbrowserPollingError(
539-
f"Failed to poll {operation_name} after {max_status_failures} attempts: {exc}"
553+
"Failed to poll "
554+
f"{operation_name} after {max_status_failures} attempts: "
555+
f"{_safe_exception_text(exc)}"
540556
) from exc
541557
if has_exceeded_max_wait(start_time, max_wait_seconds):
542558
raise HyperbrowserTimeoutError(
@@ -557,7 +573,9 @@ async def poll_until_terminal_status_async(
557573
failures += 1
558574
if failures >= max_status_failures:
559575
raise HyperbrowserPollingError(
560-
f"Failed to poll {operation_name} after {max_status_failures} attempts: {exc}"
576+
"Failed to poll "
577+
f"{operation_name} after {max_status_failures} attempts: "
578+
f"{_safe_exception_text(exc)}"
561579
) from exc
562580
if has_exceeded_max_wait(start_time, max_wait_seconds):
563581
raise HyperbrowserTimeoutError(
@@ -606,7 +624,8 @@ async def retry_operation_async(
606624
failures += 1
607625
if failures >= max_attempts:
608626
raise HyperbrowserError(
609-
f"{operation_name} failed after {max_attempts} attempts: {exc}"
627+
f"{operation_name} failed after {max_attempts} attempts: "
628+
f"{_safe_exception_text(exc)}"
610629
) from exc
611630
await asyncio.sleep(retry_delay_seconds)
612631
continue
@@ -625,7 +644,8 @@ async def retry_operation_async(
625644
failures += 1
626645
if failures >= max_attempts:
627646
raise HyperbrowserError(
628-
f"{operation_name} failed after {max_attempts} attempts: {exc}"
647+
f"{operation_name} failed after {max_attempts} attempts: "
648+
f"{_safe_exception_text(exc)}"
629649
) from exc
630650
await asyncio.sleep(retry_delay_seconds)
631651

@@ -726,7 +746,9 @@ def collect_paginated_results(
726746
failures += 1
727747
if failures >= max_attempts:
728748
raise HyperbrowserError(
729-
f"Failed to fetch page batch {current_page_batch + 1} for {operation_name} after {max_attempts} attempts: {exc}"
749+
"Failed to fetch page batch "
750+
f"{current_page_batch + 1} for {operation_name} after "
751+
f"{max_attempts} attempts: {_safe_exception_text(exc)}"
730752
) from exc
731753
if should_sleep:
732754
if has_exceeded_max_wait(start_time, max_wait_seconds):
@@ -833,7 +855,9 @@ async def collect_paginated_results_async(
833855
failures += 1
834856
if failures >= max_attempts:
835857
raise HyperbrowserError(
836-
f"Failed to fetch page batch {current_page_batch + 1} for {operation_name} after {max_attempts} attempts: {exc}"
858+
"Failed to fetch page batch "
859+
f"{current_page_batch + 1} for {operation_name} after "
860+
f"{max_attempts} attempts: {_safe_exception_text(exc)}"
837861
) from exc
838862
if should_sleep:
839863
if has_exceeded_max_wait(start_time, max_wait_seconds):

tests/test_polling.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6656,3 +6656,69 @@ async def validate_async_operation_name() -> None:
66566656
)
66576657

66586658
asyncio.run(validate_async_operation_name())
6659+
6660+
6661+
def test_poll_until_terminal_status_handles_unstringifiable_runtime_errors():
6662+
class _UnstringifiableRuntimeError(RuntimeError):
6663+
def __str__(self) -> str:
6664+
raise RuntimeError("cannot stringify runtime error")
6665+
6666+
with pytest.raises(
6667+
HyperbrowserPollingError,
6668+
match=(
6669+
r"Failed to poll sync poll after 1 attempts: "
6670+
r"<unstringifiable _UnstringifiableRuntimeError>"
6671+
),
6672+
):
6673+
poll_until_terminal_status(
6674+
operation_name="sync poll",
6675+
get_status=lambda: (_ for _ in ()).throw(_UnstringifiableRuntimeError()),
6676+
is_terminal_status=lambda value: value == "completed",
6677+
poll_interval_seconds=0.0,
6678+
max_wait_seconds=1.0,
6679+
max_status_failures=1,
6680+
)
6681+
6682+
6683+
def test_retry_operation_handles_unstringifiable_value_errors():
6684+
class _UnstringifiableValueError(ValueError):
6685+
def __str__(self) -> str:
6686+
raise RuntimeError("cannot stringify value error")
6687+
6688+
with pytest.raises(
6689+
HyperbrowserError,
6690+
match=(
6691+
r"sync retry failed after 1 attempts: "
6692+
r"<unstringifiable _UnstringifiableValueError>"
6693+
),
6694+
):
6695+
retry_operation(
6696+
operation_name="sync retry",
6697+
operation=lambda: (_ for _ in ()).throw(_UnstringifiableValueError()),
6698+
max_attempts=1,
6699+
retry_delay_seconds=0.0,
6700+
)
6701+
6702+
6703+
def test_poll_until_terminal_status_handles_unstringifiable_callback_errors():
6704+
class _UnstringifiableCallbackError(RuntimeError):
6705+
def __str__(self) -> str:
6706+
raise RuntimeError("cannot stringify callback error")
6707+
6708+
with pytest.raises(
6709+
HyperbrowserError,
6710+
match=(
6711+
r"is_terminal_status failed for callback poll: "
6712+
r"<unstringifiable _UnstringifiableCallbackError>"
6713+
),
6714+
):
6715+
poll_until_terminal_status(
6716+
operation_name="callback poll",
6717+
get_status=lambda: "running",
6718+
is_terminal_status=lambda value: (_ for _ in ()).throw(
6719+
_UnstringifiableCallbackError()
6720+
),
6721+
poll_interval_seconds=0.0,
6722+
max_wait_seconds=1.0,
6723+
max_status_failures=1,
6724+
)

0 commit comments

Comments
 (0)