Skip to content

Commit 9504754

Browse files
Harden retry status-code normalization for string and bytes inputs
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 98d4fed commit 9504754

2 files changed

Lines changed: 120 additions & 5 deletions

File tree

hyperbrowser/client/polling.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_CLIENT_ERROR_STATUS_MIN = 400
1919
_CLIENT_ERROR_STATUS_MAX = 500
2020
_RETRYABLE_CLIENT_ERROR_STATUS_CODES = {408, 429}
21+
_MAX_STATUS_CODE_TEXT_LENGTH = 6
2122

2223

2324
class _NonRetryablePollingError(HyperbrowserError):
@@ -177,14 +178,28 @@ def _normalize_status_code_for_retry(status_code: object) -> Optional[int]:
177178
return None
178179
if isinstance(status_code, int):
179180
return status_code
180-
if isinstance(status_code, str):
181-
normalized_status = status_code.strip()
181+
status_text: Optional[str] = None
182+
if isinstance(status_code, (bytes, bytearray)):
183+
try:
184+
status_text = bytes(status_code).decode("ascii")
185+
except UnicodeDecodeError:
186+
return None
187+
elif isinstance(status_code, str):
188+
status_text = status_code
189+
190+
if status_text is not None:
191+
normalized_status = status_text.strip()
182192
if not normalized_status:
183193
return None
184-
try:
185-
return int(normalized_status, 10)
186-
except ValueError:
194+
if len(normalized_status) > _MAX_STATUS_CODE_TEXT_LENGTH:
195+
return None
196+
if normalized_status[0] in {"+", "-"}:
197+
digits = normalized_status[1:]
198+
else:
199+
digits = normalized_status
200+
if not digits or not digits.isdigit():
187201
return None
202+
return int(normalized_status, 10)
188203
return None
189204

190205

tests/test_polling.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,31 @@ def get_status() -> str:
144144
assert attempts["count"] == 1
145145

146146

147+
def test_poll_until_terminal_status_retries_overlong_numeric_status_codes():
148+
attempts = {"count": 0}
149+
150+
def get_status() -> str:
151+
attempts["count"] += 1
152+
if attempts["count"] < 3:
153+
raise HyperbrowserError(
154+
"oversized status metadata",
155+
status_code="4000000000000", # type: ignore[arg-type]
156+
)
157+
return "completed"
158+
159+
status = poll_until_terminal_status(
160+
operation_name="sync poll oversized numeric status retries",
161+
get_status=get_status,
162+
is_terminal_status=lambda value: value == "completed",
163+
poll_interval_seconds=0.0001,
164+
max_wait_seconds=1.0,
165+
max_status_failures=5,
166+
)
167+
168+
assert status == "completed"
169+
assert attempts["count"] == 3
170+
171+
147172
def test_poll_until_terminal_status_does_not_retry_stop_iteration_errors():
148173
attempts = {"count": 0}
149174

@@ -602,6 +627,27 @@ def operation() -> str:
602627
assert attempts["count"] == 3
603628

604629

630+
def test_retry_operation_does_not_retry_numeric_bytes_client_errors():
631+
attempts = {"count": 0}
632+
633+
def operation() -> str:
634+
attempts["count"] += 1
635+
raise HyperbrowserError(
636+
"client failure",
637+
status_code=b"400", # type: ignore[arg-type]
638+
)
639+
640+
with pytest.raises(HyperbrowserError, match="client failure"):
641+
retry_operation(
642+
operation_name="sync retry numeric-bytes client error",
643+
operation=operation,
644+
max_attempts=5,
645+
retry_delay_seconds=0.0001,
646+
)
647+
648+
assert attempts["count"] == 1
649+
650+
605651
def test_retry_operation_does_not_retry_stop_iteration_errors():
606652
attempts = {"count": 0}
607653

@@ -951,6 +997,34 @@ async def get_status() -> str:
951997
asyncio.run(run())
952998

953999

1000+
def test_poll_until_terminal_status_async_retries_overlong_numeric_status_codes():
1001+
async def run() -> None:
1002+
attempts = {"count": 0}
1003+
1004+
async def get_status() -> str:
1005+
attempts["count"] += 1
1006+
if attempts["count"] < 3:
1007+
raise HyperbrowserError(
1008+
"oversized status metadata",
1009+
status_code="4000000000000", # type: ignore[arg-type]
1010+
)
1011+
return "completed"
1012+
1013+
status = await poll_until_terminal_status_async(
1014+
operation_name="async poll oversized numeric status retries",
1015+
get_status=get_status,
1016+
is_terminal_status=lambda value: value == "completed",
1017+
poll_interval_seconds=0.0001,
1018+
max_wait_seconds=1.0,
1019+
max_status_failures=5,
1020+
)
1021+
1022+
assert status == "completed"
1023+
assert attempts["count"] == 3
1024+
1025+
asyncio.run(run())
1026+
1027+
9541028
def test_poll_until_terminal_status_async_does_not_retry_stop_async_iteration_errors():
9551029
async def run() -> None:
9561030
attempts = {"count": 0}
@@ -1181,6 +1255,32 @@ async def operation() -> str:
11811255
asyncio.run(run())
11821256

11831257

1258+
def test_retry_operation_async_retries_numeric_bytes_rate_limit_errors():
1259+
async def run() -> None:
1260+
attempts = {"count": 0}
1261+
1262+
async def operation() -> str:
1263+
attempts["count"] += 1
1264+
if attempts["count"] < 3:
1265+
raise HyperbrowserError(
1266+
"rate limited",
1267+
status_code=b"429", # type: ignore[arg-type]
1268+
)
1269+
return "ok"
1270+
1271+
result = await retry_operation_async(
1272+
operation_name="async retry numeric-bytes rate limit",
1273+
operation=operation,
1274+
max_attempts=5,
1275+
retry_delay_seconds=0.0001,
1276+
)
1277+
1278+
assert result == "ok"
1279+
assert attempts["count"] == 3
1280+
1281+
asyncio.run(run())
1282+
1283+
11841284
def test_retry_operation_async_does_not_retry_stop_async_iteration_errors():
11851285
async def run() -> None:
11861286
attempts = {"count": 0}

0 commit comments

Comments
 (0)