Skip to content

Commit da80e9e

Browse files
Normalize sentinel and numeric-like method tokens
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 3216e16 commit da80e9e

File tree

4 files changed

+136
-1
lines changed

4 files changed

+136
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Polling timeouts and repeated polling failures are surfaced as:
187187

188188
`HyperbrowserPollingError` also covers stalled pagination (no page-batch progress during result collection).
189189
Transport-level request failures include HTTP method + URL context in error messages.
190-
URL-like fallback objects are stringified for transport diagnostics, bytes-like fallback methods/URLs are decoded when valid, and missing/malformed/sentinel URL inputs (for example `None`, booleans, invalid bytes, `null`/`undefined`/`true`/`false`/`nan`, or numeric-like values such as `123`/`1.5`/`1e6`) are normalized to `unknown URL`.
190+
Method-like fallback objects are stringified for diagnostics (with strict token validation), bytes-like fallback methods/URLs are decoded when valid, malformed/sentinel method inputs (for example `null`/`undefined`/`true`/`false`/`nan` or numeric-like values such as `1`/`1.5`/`1e3`) are normalized to `UNKNOWN`, and missing/malformed/sentinel URL inputs (for example `None`, booleans, invalid bytes, `null`/`undefined`/`true`/`false`/`nan`, or numeric-like values such as `123`/`1.5`/`1e6`) are normalized to `unknown URL`.
191191

192192
```python
193193
from hyperbrowser import Hyperbrowser

hyperbrowser/transport/error_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,26 @@
99
_NUMERIC_LIKE_URL_PATTERN = re.compile(
1010
r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$"
1111
)
12+
_NUMERIC_LIKE_METHOD_PATTERN = re.compile(
13+
r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$"
14+
)
1215
_MAX_ERROR_MESSAGE_LENGTH = 2000
1316
_MAX_REQUEST_URL_DISPLAY_LENGTH = 1000
1417
_MAX_REQUEST_METHOD_LENGTH = 50
18+
_INVALID_METHOD_SENTINELS = {
19+
"none",
20+
"null",
21+
"undefined",
22+
"true",
23+
"false",
24+
"nan",
25+
"inf",
26+
"+inf",
27+
"-inf",
28+
"infinity",
29+
"+infinity",
30+
"-infinity",
31+
}
1532
_INVALID_URL_SENTINELS = {
1633
"none",
1734
"null",
@@ -47,6 +64,12 @@ def _normalize_request_method(method: Any) -> str:
4764
if not isinstance(raw_method, str) or not raw_method.strip():
4865
return "UNKNOWN"
4966
normalized_method = raw_method.strip().upper()
67+
lowered_method = normalized_method.lower()
68+
if (
69+
lowered_method in _INVALID_METHOD_SENTINELS
70+
or _NUMERIC_LIKE_METHOD_PATTERN.fullmatch(normalized_method)
71+
):
72+
return "UNKNOWN"
5073
if len(normalized_method) > _MAX_REQUEST_METHOD_LENGTH:
5174
return "UNKNOWN"
5275
if not _HTTP_METHOD_TOKEN_PATTERN.fullmatch(normalized_method):

tests/test_transport_error_utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,32 @@ def test_format_request_failure_message_normalizes_non_string_fallback_values():
310310
assert message == "Request UNKNOWN unknown URL failed"
311311

312312

313+
@pytest.mark.parametrize("sentinel_method", ["null", "undefined", "true", "false"])
314+
def test_format_request_failure_message_normalizes_sentinel_fallback_methods(
315+
sentinel_method: str,
316+
):
317+
message = format_request_failure_message(
318+
httpx.RequestError("network down"),
319+
fallback_method=sentinel_method,
320+
fallback_url="https://example.com/fallback",
321+
)
322+
323+
assert message == "Request UNKNOWN https://example.com/fallback failed"
324+
325+
326+
@pytest.mark.parametrize("numeric_like_method", ["1", "1.5", "-1.25", "+2", ".75", "1e3"])
327+
def test_format_request_failure_message_normalizes_numeric_like_fallback_methods(
328+
numeric_like_method: str,
329+
):
330+
message = format_request_failure_message(
331+
httpx.RequestError("network down"),
332+
fallback_method=numeric_like_method,
333+
fallback_url="https://example.com/fallback",
334+
)
335+
336+
assert message == "Request UNKNOWN https://example.com/fallback failed"
337+
338+
313339
def test_format_request_failure_message_supports_ascii_bytes_method_values():
314340
message = format_request_failure_message(
315341
httpx.RequestError("network down"),
@@ -525,6 +551,30 @@ def test_format_generic_request_failure_message_normalizes_non_string_method_val
525551
assert message == "Request UNKNOWN https://example.com/path failed"
526552

527553

554+
@pytest.mark.parametrize("sentinel_method", ["null", "undefined", "true", "false"])
555+
def test_format_generic_request_failure_message_normalizes_sentinel_method_values(
556+
sentinel_method: str,
557+
):
558+
message = format_generic_request_failure_message(
559+
method=sentinel_method,
560+
url="https://example.com/path",
561+
)
562+
563+
assert message == "Request UNKNOWN https://example.com/path failed"
564+
565+
566+
@pytest.mark.parametrize("numeric_like_method", ["1", "1.5", "-1.25", "+2", ".75", "1e3"])
567+
def test_format_generic_request_failure_message_normalizes_numeric_like_method_values(
568+
numeric_like_method: str,
569+
):
570+
message = format_generic_request_failure_message(
571+
method=numeric_like_method,
572+
url="https://example.com/path",
573+
)
574+
575+
assert message == "Request UNKNOWN https://example.com/path failed"
576+
577+
528578
def test_format_generic_request_failure_message_supports_stringifiable_method_values():
529579
class _MethodLike:
530580
def __str__(self) -> str:

tests/test_transport_response_handling.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,34 @@ def test_sync_handle_response_with_request_error_normalizes_method_casing():
6767
transport.close()
6868

6969

70+
def test_sync_handle_response_with_request_error_normalizes_sentinel_method():
71+
transport = SyncTransport(api_key="test-key")
72+
try:
73+
with pytest.raises(
74+
HyperbrowserError,
75+
match="Request UNKNOWN https://example.com/network failed",
76+
):
77+
transport._handle_response(
78+
_RequestErrorResponse("null", "https://example.com/network")
79+
)
80+
finally:
81+
transport.close()
82+
83+
84+
def test_sync_handle_response_with_request_error_normalizes_numeric_like_method():
85+
transport = SyncTransport(api_key="test-key")
86+
try:
87+
with pytest.raises(
88+
HyperbrowserError,
89+
match="Request UNKNOWN https://example.com/network failed",
90+
):
91+
transport._handle_response(
92+
_RequestErrorResponse("1e3", "https://example.com/network")
93+
)
94+
finally:
95+
transport.close()
96+
97+
7098
def test_async_handle_response_with_non_json_success_body_returns_status_only():
7199
async def run() -> None:
72100
transport = AsyncTransport(api_key="test-key")
@@ -117,6 +145,40 @@ async def run() -> None:
117145
asyncio.run(run())
118146

119147

148+
def test_async_handle_response_with_request_error_normalizes_sentinel_method():
149+
async def run() -> None:
150+
transport = AsyncTransport(api_key="test-key")
151+
try:
152+
with pytest.raises(
153+
HyperbrowserError,
154+
match="Request UNKNOWN https://example.com/network failed",
155+
):
156+
await transport._handle_response(
157+
_RequestErrorResponse("undefined", "https://example.com/network")
158+
)
159+
finally:
160+
await transport.close()
161+
162+
asyncio.run(run())
163+
164+
165+
def test_async_handle_response_with_request_error_normalizes_numeric_like_method():
166+
async def run() -> None:
167+
transport = AsyncTransport(api_key="test-key")
168+
try:
169+
with pytest.raises(
170+
HyperbrowserError,
171+
match="Request UNKNOWN https://example.com/network failed",
172+
):
173+
await transport._handle_response(
174+
_RequestErrorResponse("1.5", "https://example.com/network")
175+
)
176+
finally:
177+
await transport.close()
178+
179+
asyncio.run(run())
180+
181+
120182
def test_sync_handle_response_with_request_error_without_request_context():
121183
transport = SyncTransport(api_key="test-key")
122184
try:

0 commit comments

Comments
 (0)