Skip to content

Commit e6071bc

Browse files
Harden polling exception text sanitization for broken string subclasses
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 5df8bd2 commit e6071bc

File tree

2 files changed

+98
-15
lines changed

2 files changed

+98
-15
lines changed

hyperbrowser/client/polling.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,27 @@ def _safe_exception_text(exc: Exception) -> str:
3737
exception_message = str(exc)
3838
except Exception:
3939
return f"<unstringifiable {type(exc).__name__}>"
40-
sanitized_exception_message = "".join(
41-
"?" if ord(character) < 32 or ord(character) == 127 else character
42-
for character in exception_message
43-
)
44-
if sanitized_exception_message.strip():
45-
if len(sanitized_exception_message) <= _MAX_EXCEPTION_TEXT_LENGTH:
46-
return sanitized_exception_message
47-
available_message_length = _MAX_EXCEPTION_TEXT_LENGTH - len(
48-
_TRUNCATED_EXCEPTION_TEXT_SUFFIX
49-
)
50-
if available_message_length <= 0:
51-
return _TRUNCATED_EXCEPTION_TEXT_SUFFIX
52-
return (
53-
f"{sanitized_exception_message[:available_message_length]}"
54-
f"{_TRUNCATED_EXCEPTION_TEXT_SUFFIX}"
40+
if not isinstance(exception_message, str):
41+
return f"<unstringifiable {type(exc).__name__}>"
42+
try:
43+
sanitized_exception_message = "".join(
44+
"?" if ord(character) < 32 or ord(character) == 127 else character
45+
for character in exception_message
5546
)
47+
if sanitized_exception_message.strip():
48+
if len(sanitized_exception_message) <= _MAX_EXCEPTION_TEXT_LENGTH:
49+
return sanitized_exception_message
50+
available_message_length = _MAX_EXCEPTION_TEXT_LENGTH - len(
51+
_TRUNCATED_EXCEPTION_TEXT_SUFFIX
52+
)
53+
if available_message_length <= 0:
54+
return _TRUNCATED_EXCEPTION_TEXT_SUFFIX
55+
return (
56+
f"{sanitized_exception_message[:available_message_length]}"
57+
f"{_TRUNCATED_EXCEPTION_TEXT_SUFFIX}"
58+
)
59+
except Exception:
60+
return f"<unstringifiable {type(exc).__name__}>"
5661
return f"<{type(exc).__name__}>"
5762

5863

tests/test_polling.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6744,6 +6744,32 @@ def __str__(self) -> str:
67446744
)
67456745

67466746

6747+
def test_poll_until_terminal_status_handles_runtime_errors_with_broken_string_subclasses():
6748+
class _BrokenRenderedRuntimeError(RuntimeError):
6749+
class _BrokenString(str):
6750+
def __iter__(self):
6751+
raise RuntimeError("cannot iterate rendered runtime message")
6752+
6753+
def __str__(self) -> str:
6754+
return self._BrokenString("broken runtime message")
6755+
6756+
with pytest.raises(
6757+
HyperbrowserPollingError,
6758+
match=(
6759+
r"Failed to poll sync poll after 1 attempts: "
6760+
r"<unstringifiable _BrokenRenderedRuntimeError>"
6761+
),
6762+
):
6763+
poll_until_terminal_status(
6764+
operation_name="sync poll",
6765+
get_status=lambda: (_ for _ in ()).throw(_BrokenRenderedRuntimeError()),
6766+
is_terminal_status=lambda value: value == "completed",
6767+
poll_interval_seconds=0.0,
6768+
max_wait_seconds=1.0,
6769+
max_status_failures=1,
6770+
)
6771+
6772+
67476773
def test_retry_operation_handles_unstringifiable_value_errors():
67486774
class _UnstringifiableValueError(ValueError):
67496775
def __str__(self) -> str:
@@ -6764,6 +6790,30 @@ def __str__(self) -> str:
67646790
)
67656791

67666792

6793+
def test_retry_operation_handles_value_errors_with_broken_string_subclasses():
6794+
class _BrokenRenderedValueError(ValueError):
6795+
class _BrokenString(str):
6796+
def __iter__(self):
6797+
raise RuntimeError("cannot iterate rendered value message")
6798+
6799+
def __str__(self) -> str:
6800+
return self._BrokenString("broken value message")
6801+
6802+
with pytest.raises(
6803+
HyperbrowserError,
6804+
match=(
6805+
r"sync retry failed after 1 attempts: "
6806+
r"<unstringifiable _BrokenRenderedValueError>"
6807+
),
6808+
):
6809+
retry_operation(
6810+
operation_name="sync retry",
6811+
operation=lambda: (_ for _ in ()).throw(_BrokenRenderedValueError()),
6812+
max_attempts=1,
6813+
retry_delay_seconds=0.0,
6814+
)
6815+
6816+
67676817
def test_poll_until_terminal_status_handles_unstringifiable_callback_errors():
67686818
class _UnstringifiableCallbackError(RuntimeError):
67696819
def __str__(self) -> str:
@@ -6788,6 +6838,34 @@ def __str__(self) -> str:
67886838
)
67896839

67906840

6841+
def test_poll_until_terminal_status_handles_callback_errors_with_broken_string_subclasses():
6842+
class _BrokenRenderedCallbackError(RuntimeError):
6843+
class _BrokenString(str):
6844+
def __iter__(self):
6845+
raise RuntimeError("cannot iterate rendered callback message")
6846+
6847+
def __str__(self) -> str:
6848+
return self._BrokenString("broken callback message")
6849+
6850+
with pytest.raises(
6851+
HyperbrowserError,
6852+
match=(
6853+
r"is_terminal_status failed for callback poll: "
6854+
r"<unstringifiable _BrokenRenderedCallbackError>"
6855+
),
6856+
):
6857+
poll_until_terminal_status(
6858+
operation_name="callback poll",
6859+
get_status=lambda: "running",
6860+
is_terminal_status=lambda value: (_ for _ in ()).throw(
6861+
_BrokenRenderedCallbackError()
6862+
),
6863+
poll_interval_seconds=0.0,
6864+
max_wait_seconds=1.0,
6865+
max_status_failures=1,
6866+
)
6867+
6868+
67916869
def test_poll_until_terminal_status_uses_placeholder_for_blank_error_messages():
67926870
with pytest.raises(
67936871
HyperbrowserPollingError,

0 commit comments

Comments
 (0)