Skip to content

Commit 7b517dd

Browse files
Sanitize APIResponse model and key display diagnostics
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 4d35504 commit 7b517dd

File tree

2 files changed

+101
-2
lines changed

2 files changed

+101
-2
lines changed

hyperbrowser/transport/base.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@
55
from hyperbrowser.exceptions import HyperbrowserError
66

77
T = TypeVar("T")
8+
_TRUNCATED_DISPLAY_SUFFIX = "... (truncated)"
9+
_MAX_MODEL_NAME_DISPLAY_LENGTH = 120
10+
_MAX_MAPPING_KEY_DISPLAY_LENGTH = 120
11+
12+
13+
def _sanitize_display_text(value: str, *, max_length: int) -> str:
14+
sanitized_value = "".join(
15+
"?" if ord(character) < 32 or ord(character) == 127 else character
16+
for character in value
17+
).strip()
18+
if not sanitized_value:
19+
return ""
20+
if len(sanitized_value) <= max_length:
21+
return sanitized_value
22+
available_length = max_length - len(_TRUNCATED_DISPLAY_SUFFIX)
23+
if available_length <= 0:
24+
return _TRUNCATED_DISPLAY_SUFFIX
25+
return f"{sanitized_value[:available_length]}{_TRUNCATED_DISPLAY_SUFFIX}"
826

927

1028
def _safe_model_name(model: object) -> str:
@@ -14,12 +32,23 @@ def _safe_model_name(model: object) -> str:
1432
return "response model"
1533
if not isinstance(model_name, str):
1634
return "response model"
17-
normalized_model_name = model_name.strip()
35+
normalized_model_name = _sanitize_display_text(
36+
model_name, max_length=_MAX_MODEL_NAME_DISPLAY_LENGTH
37+
)
1838
if not normalized_model_name:
1939
return "response model"
2040
return normalized_model_name
2141

2242

43+
def _format_mapping_key_for_error(key: str) -> str:
44+
normalized_key = _sanitize_display_text(
45+
key, max_length=_MAX_MAPPING_KEY_DISPLAY_LENGTH
46+
)
47+
if normalized_key:
48+
return normalized_key
49+
return "<blank key>"
50+
51+
2352
class APIResponse(Generic[T]):
2453
"""
2554
Wrapper for API responses to standardize sync/async handling.
@@ -66,9 +95,10 @@ def from_json(
6695
except HyperbrowserError:
6796
raise
6897
except Exception as exc:
98+
key_display = _format_mapping_key_for_error(key)
6999
raise HyperbrowserError(
70100
f"Failed to parse response data for {model_name}: "
71-
f"unable to read value for key '{key}'",
101+
f"unable to read value for key '{key_display}'",
72102
original_error=exc,
73103
) from exc
74104
try:

tests/test_transport_base.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ def __call__(self, **kwargs):
6565
raise RuntimeError("call failed")
6666

6767

68+
class _LongControlNameCallableModel:
69+
__name__ = " Model\t" + ("x" * 200)
70+
71+
def __call__(self, **kwargs):
72+
_ = kwargs
73+
raise RuntimeError("call failed")
74+
75+
76+
class _BrokenBlankKeyValueMapping(Mapping[str, object]):
77+
def __iter__(self):
78+
return iter([" "])
79+
80+
def __len__(self) -> int:
81+
return 1
82+
83+
def __getitem__(self, key: str) -> object:
84+
if key == " ":
85+
raise RuntimeError("cannot read blank key value")
86+
raise KeyError(key)
87+
88+
89+
class _BrokenLongKeyValueMapping(Mapping[str, object]):
90+
_KEY = "bad\t" + ("k" * 200)
91+
92+
def __iter__(self):
93+
return iter([self._KEY])
94+
95+
def __len__(self) -> int:
96+
return 1
97+
98+
def __getitem__(self, key: str) -> object:
99+
if key == self._KEY:
100+
raise RuntimeError("cannot read long key value")
101+
raise KeyError(key)
102+
103+
68104
def test_api_response_from_json_parses_model_data() -> None:
69105
response = APIResponse.from_json(
70106
{"name": "job-1", "retries": 2}, _SampleResponseModel
@@ -159,6 +195,39 @@ def test_api_response_from_json_uses_default_name_for_blank_model_name() -> None
159195
)
160196

161197

198+
def test_api_response_from_json_sanitizes_and_truncates_model_name_in_errors() -> None:
199+
with pytest.raises(
200+
HyperbrowserError,
201+
match=r"Failed to parse response data for Model\?x+\.\.\. \(truncated\)",
202+
):
203+
APIResponse.from_json(
204+
{"name": "job-1"},
205+
cast("type[_SampleResponseModel]", _LongControlNameCallableModel()),
206+
)
207+
208+
209+
def test_api_response_from_json_uses_placeholder_for_blank_mapping_key_in_errors() -> None:
210+
with pytest.raises(
211+
HyperbrowserError,
212+
match=(
213+
"Failed to parse response data for _SampleResponseModel: "
214+
"unable to read value for key '<blank key>'"
215+
),
216+
):
217+
APIResponse.from_json(_BrokenBlankKeyValueMapping(), _SampleResponseModel)
218+
219+
220+
def test_api_response_from_json_sanitizes_and_truncates_mapping_keys_in_errors() -> None:
221+
with pytest.raises(
222+
HyperbrowserError,
223+
match=(
224+
"Failed to parse response data for _SampleResponseModel: "
225+
r"unable to read value for key 'bad\?k+\.\.\. \(truncated\)'"
226+
),
227+
):
228+
APIResponse.from_json(_BrokenLongKeyValueMapping(), _SampleResponseModel)
229+
230+
162231
def test_api_response_from_json_preserves_hyperbrowser_errors() -> None:
163232
with pytest.raises(HyperbrowserError, match="model validation failed") as exc_info:
164233
APIResponse.from_json({}, _RaisesHyperbrowserModel)

0 commit comments

Comments
 (0)