Skip to content

Commit a463d53

Browse files
Safeguard APIResponse model name resolution
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent b112c61 commit a463d53

File tree

2 files changed

+54
-1
lines changed

2 files changed

+54
-1
lines changed

hyperbrowser/transport/base.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
T = TypeVar("T")
88

99

10+
def _safe_model_name(model: object) -> str:
11+
try:
12+
model_name = getattr(model, "__name__", "response model")
13+
except Exception:
14+
return "response model"
15+
if not isinstance(model_name, str):
16+
return "response model"
17+
normalized_model_name = model_name.strip()
18+
if not normalized_model_name:
19+
return "response model"
20+
return normalized_model_name
21+
22+
1023
class APIResponse(Generic[T]):
1124
"""
1225
Wrapper for API responses to standardize sync/async handling.
@@ -21,7 +34,7 @@ def from_json(
2134
cls, json_data: Mapping[str, object], model: Type[T]
2235
) -> "APIResponse[T]":
2336
"""Create an APIResponse from JSON data with a specific model."""
24-
model_name = getattr(model, "__name__", "response model")
37+
model_name = _safe_model_name(model)
2538
if not isinstance(json_data, MappingABC):
2639
actual_type_name = type(json_data).__name__
2740
raise HyperbrowserError(

tests/test_transport_base.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ def __getitem__(self, key: str) -> object:
4444
raise KeyError(key)
4545

4646

47+
class _BrokenNameMeta(type):
48+
def __getattribute__(cls, name: str):
49+
if name == "__name__":
50+
raise RuntimeError("cannot read model name")
51+
return super().__getattribute__(name)
52+
53+
54+
class _BrokenNameModel(metaclass=_BrokenNameMeta):
55+
def __init__(self, **kwargs):
56+
self.name = kwargs["name"]
57+
self.retries = kwargs.get("retries", 0)
58+
59+
60+
class _BlankNameCallableModel:
61+
__name__ = " "
62+
63+
def __call__(self, **kwargs):
64+
_ = kwargs
65+
raise RuntimeError("call failed")
66+
67+
4768
def test_api_response_from_json_parses_model_data() -> None:
4869
response = APIResponse.from_json(
4970
{"name": "job-1", "retries": 2}, _SampleResponseModel
@@ -93,6 +114,14 @@ def test_api_response_from_json_wraps_non_hyperbrowser_errors() -> None:
93114
assert exc_info.value.original_error is not None
94115

95116

117+
def test_api_response_from_json_parses_model_when_name_lookup_fails() -> None:
118+
response = APIResponse.from_json({"name": "job-1"}, _BrokenNameModel)
119+
120+
assert isinstance(response.data, _BrokenNameModel)
121+
assert response.data.name == "job-1"
122+
assert response.status_code == 200
123+
124+
96125
def test_api_response_from_json_wraps_unreadable_mapping_keys() -> None:
97126
with pytest.raises(
98127
HyperbrowserError,
@@ -119,6 +148,17 @@ def test_api_response_from_json_wraps_unreadable_mapping_values() -> None:
119148
assert exc_info.value.original_error is not None
120149

121150

151+
def test_api_response_from_json_uses_default_name_for_blank_model_name() -> None:
152+
with pytest.raises(
153+
HyperbrowserError,
154+
match="Failed to parse response data for response model",
155+
):
156+
APIResponse.from_json(
157+
{"name": "job-1"},
158+
cast("type[_SampleResponseModel]", _BlankNameCallableModel()),
159+
)
160+
161+
122162
def test_api_response_from_json_preserves_hyperbrowser_errors() -> None:
123163
with pytest.raises(HyperbrowserError, match="model validation failed") as exc_info:
124164
APIResponse.from_json({}, _RaisesHyperbrowserModel)

0 commit comments

Comments
 (0)