Skip to content

Commit 294412f

Browse files
Improve response parser value-read diagnostics
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent f2482c3 commit 294412f

2 files changed

Lines changed: 96 additions & 2 deletions

File tree

hyperbrowser/client/managers/response_utils.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
T = TypeVar("T")
77
_MAX_OPERATION_NAME_DISPLAY_LENGTH = 120
88
_TRUNCATED_OPERATION_NAME_SUFFIX = "... (truncated)"
9+
_MAX_KEY_DISPLAY_LENGTH = 120
10+
_TRUNCATED_KEY_DISPLAY_SUFFIX = "... (truncated)"
911

1012

1113
def _normalize_operation_name_for_error(operation_name: str) -> str:
@@ -25,6 +27,21 @@ def _normalize_operation_name_for_error(operation_name: str) -> str:
2527
return f"{normalized_name[:available_length]}{_TRUNCATED_OPERATION_NAME_SUFFIX}"
2628

2729

30+
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()
35+
if not normalized_key:
36+
return "<blank key>"
37+
if len(normalized_key) <= _MAX_KEY_DISPLAY_LENGTH:
38+
return normalized_key
39+
available_length = _MAX_KEY_DISPLAY_LENGTH - len(_TRUNCATED_KEY_DISPLAY_SUFFIX)
40+
if available_length <= 0:
41+
return _TRUNCATED_KEY_DISPLAY_SUFFIX
42+
return f"{normalized_key[:available_length]}{_TRUNCATED_KEY_DISPLAY_SUFFIX}"
43+
44+
2845
def parse_response_model(
2946
response_data: Any,
3047
*,
@@ -39,20 +56,33 @@ def parse_response_model(
3956
f"Expected {normalized_operation_name} response to be an object"
4057
)
4158
try:
42-
response_payload = dict(response_data)
59+
response_keys = list(response_data.keys())
4360
except HyperbrowserError:
4461
raise
4562
except Exception as exc:
4663
raise HyperbrowserError(
4764
f"Failed to read {normalized_operation_name} response data",
4865
original_error=exc,
4966
) from exc
50-
for key in response_payload.keys():
67+
for key in response_keys:
5168
if isinstance(key, str):
5269
continue
5370
raise HyperbrowserError(
5471
f"Expected {normalized_operation_name} response object keys to be strings"
5572
)
73+
response_payload: dict[str, object] = {}
74+
for key in response_keys:
75+
try:
76+
response_payload[key] = response_data[key]
77+
except HyperbrowserError:
78+
raise
79+
except Exception as exc:
80+
key_display = _normalize_response_key_for_error(key)
81+
raise HyperbrowserError(
82+
f"Failed to read {normalized_operation_name} response value for key "
83+
f"'{key_display}'",
84+
original_error=exc,
85+
) from exc
5686
try:
5787
return model(**response_payload)
5888
except HyperbrowserError:

tests/test_response_utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ def __getitem__(self, key: str) -> object:
133133
return self._payload[key]
134134

135135

136+
class _BrokenValueLookupMapping(Mapping[str, object]):
137+
def __init__(self, *, key: str, error: Exception):
138+
self._key = key
139+
self._error = error
140+
141+
def __iter__(self):
142+
yield self._key
143+
144+
def __len__(self) -> int:
145+
return 1
146+
147+
def __getitem__(self, key: str) -> object:
148+
_ = key
149+
raise self._error
150+
151+
136152
def test_parse_response_model_parses_mapping_payloads():
137153
response_model = parse_response_model(
138154
{"success": True},
@@ -221,6 +237,54 @@ def test_parse_response_model_wraps_mapping_read_failures():
221237
assert exc_info.value.original_error is not None
222238

223239

240+
def test_parse_response_model_wraps_mapping_value_read_failures():
241+
with pytest.raises(
242+
HyperbrowserError,
243+
match="Failed to read basic operation response value for key 'success'",
244+
) as exc_info:
245+
parse_response_model(
246+
_BrokenValueLookupMapping(
247+
key="success",
248+
error=RuntimeError("cannot read value"),
249+
),
250+
model=BasicResponse,
251+
operation_name="basic operation",
252+
)
253+
254+
assert exc_info.value.original_error is not None
255+
256+
257+
def test_parse_response_model_sanitizes_key_display_in_value_read_failures():
258+
with pytest.raises(
259+
HyperbrowserError,
260+
match="Failed to read basic operation response value for key 'bad\\?key'",
261+
) as exc_info:
262+
parse_response_model(
263+
_BrokenValueLookupMapping(
264+
key="bad\tkey",
265+
error=RuntimeError("cannot read value"),
266+
),
267+
model=BasicResponse,
268+
operation_name="basic operation",
269+
)
270+
271+
assert exc_info.value.original_error is not None
272+
273+
274+
def test_parse_response_model_preserves_hyperbrowser_value_read_failures():
275+
with pytest.raises(HyperbrowserError, match="custom read failure") as exc_info:
276+
parse_response_model(
277+
_BrokenValueLookupMapping(
278+
key="success",
279+
error=HyperbrowserError("custom read failure"),
280+
),
281+
model=BasicResponse,
282+
operation_name="basic operation",
283+
)
284+
285+
assert exc_info.value.original_error is None
286+
287+
224288
def test_sync_team_manager_rejects_invalid_response_shape():
225289
class _SyncTransport:
226290
def get(self, url, params=None, follow_redirects=False):

0 commit comments

Comments
 (0)