Skip to content

Commit 844fb89

Browse files
Harden response model operation-name and key display normalization
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 9e098c6 commit 844fb89

File tree

2 files changed

+111
-9
lines changed

2 files changed

+111
-9
lines changed

hyperbrowser/client/managers/response_utils.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111

1212

1313
def _normalize_operation_name_for_error(operation_name: str) -> str:
14-
normalized_name = "".join(
15-
"?" if ord(character) < 32 or ord(character) == 127 else character
16-
for character in operation_name
17-
).strip()
14+
try:
15+
normalized_name = "".join(
16+
"?" if ord(character) < 32 or ord(character) == 127 else character
17+
for character in operation_name
18+
).strip()
19+
if not isinstance(normalized_name, str):
20+
raise TypeError("normalized operation name must be a string")
21+
except Exception:
22+
return "operation"
1823
if not normalized_name:
1924
return "operation"
2025
if len(normalized_name) <= _MAX_OPERATION_NAME_DISPLAY_LENGTH:
@@ -28,10 +33,15 @@ def _normalize_operation_name_for_error(operation_name: str) -> str:
2833

2934

3035
def _normalize_response_key_for_error(key: str) -> str:
31-
normalized_key = "".join(
32-
"?" if ord(character) < 32 or ord(character) == 127 else character
33-
for character in key
34-
).strip()
36+
try:
37+
normalized_key = "".join(
38+
"?" if ord(character) < 32 or ord(character) == 127 else character
39+
for character in key
40+
).strip()
41+
if not isinstance(normalized_key, str):
42+
raise TypeError("normalized response key must be a string")
43+
except Exception:
44+
return "<unreadable key>"
3545
if not normalized_key:
3646
return "<blank key>"
3747
if len(normalized_key) <= _MAX_KEY_DISPLAY_LENGTH:
@@ -48,7 +58,20 @@ def parse_response_model(
4858
model: Type[T],
4959
operation_name: str,
5060
) -> T:
51-
if not isinstance(operation_name, str) or not operation_name.strip():
61+
if not isinstance(operation_name, str):
62+
raise HyperbrowserError("operation_name must be a non-empty string")
63+
try:
64+
normalized_operation_name_input = operation_name.strip()
65+
if not isinstance(normalized_operation_name_input, str):
66+
raise TypeError("normalized operation_name must be a string")
67+
except HyperbrowserError:
68+
raise
69+
except Exception as exc:
70+
raise HyperbrowserError(
71+
"Failed to normalize operation_name",
72+
original_error=exc,
73+
) from exc
74+
if not normalized_operation_name_input:
5275
raise HyperbrowserError("operation_name must be a non-empty string")
5376
normalized_operation_name = _normalize_operation_name_for_error(operation_name)
5477
if not isinstance(response_data, Mapping):

tests/test_response_utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,56 @@ def test_parse_response_model_sanitizes_operation_name_in_errors():
206206
)
207207

208208

209+
def test_parse_response_model_wraps_operation_name_strip_failures():
210+
class _BrokenOperationName(str):
211+
def strip(self, chars=None): # type: ignore[override]
212+
_ = chars
213+
raise RuntimeError("operation name strip exploded")
214+
215+
with pytest.raises(HyperbrowserError, match="Failed to normalize operation_name") as exc_info:
216+
parse_response_model(
217+
{"success": True},
218+
model=BasicResponse,
219+
operation_name=_BrokenOperationName("basic operation"),
220+
)
221+
222+
assert isinstance(exc_info.value.original_error, RuntimeError)
223+
224+
225+
def test_parse_response_model_preserves_hyperbrowser_operation_name_strip_failures():
226+
class _BrokenOperationName(str):
227+
def strip(self, chars=None): # type: ignore[override]
228+
_ = chars
229+
raise HyperbrowserError("custom operation name strip failure")
230+
231+
with pytest.raises(
232+
HyperbrowserError, match="custom operation name strip failure"
233+
) as exc_info:
234+
parse_response_model(
235+
{"success": True},
236+
model=BasicResponse,
237+
operation_name=_BrokenOperationName("basic operation"),
238+
)
239+
240+
assert exc_info.value.original_error is None
241+
242+
243+
def test_parse_response_model_wraps_non_string_operation_name_strip_results():
244+
class _BrokenOperationName(str):
245+
def strip(self, chars=None): # type: ignore[override]
246+
_ = chars
247+
return object()
248+
249+
with pytest.raises(HyperbrowserError, match="Failed to normalize operation_name") as exc_info:
250+
parse_response_model(
251+
{"success": True},
252+
model=BasicResponse,
253+
operation_name=_BrokenOperationName("basic operation"),
254+
)
255+
256+
assert isinstance(exc_info.value.original_error, TypeError)
257+
258+
209259
def test_parse_response_model_truncates_operation_name_in_errors():
210260
long_operation_name = "basic operation " + ("x" * 200)
211261

@@ -223,6 +273,35 @@ def test_parse_response_model_truncates_operation_name_in_errors():
223273
)
224274

225275

276+
def test_parse_response_model_falls_back_for_unreadable_key_display():
277+
class _BrokenKey(str):
278+
def __iter__(self):
279+
raise RuntimeError("key iteration exploded")
280+
281+
class _BrokenValueLookupMapping(Mapping[str, object]):
282+
def __iter__(self):
283+
yield _BrokenKey("success")
284+
285+
def __len__(self) -> int:
286+
return 1
287+
288+
def __getitem__(self, key: str) -> object:
289+
_ = key
290+
raise RuntimeError("cannot read value")
291+
292+
with pytest.raises(
293+
HyperbrowserError,
294+
match="Failed to read basic operation response value for key '<unreadable key>'",
295+
) as exc_info:
296+
parse_response_model(
297+
_BrokenValueLookupMapping(),
298+
model=BasicResponse,
299+
operation_name="basic operation",
300+
)
301+
302+
assert isinstance(exc_info.value.original_error, RuntimeError)
303+
304+
226305
def test_parse_response_model_wraps_mapping_read_failures():
227306
with pytest.raises(
228307
HyperbrowserError,

0 commit comments

Comments
 (0)