Skip to content

Commit 68fc9eb

Browse files
Normalize string status codes in retry classification
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 9e349cd commit 68fc9eb

File tree

2 files changed

+119
-5
lines changed

2 files changed

+119
-5
lines changed

hyperbrowser/client/polling.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ def _is_executor_shutdown_runtime_error(exc: Exception) -> bool:
172172
)
173173

174174

175+
def _normalize_status_code_for_retry(status_code: object) -> Optional[int]:
176+
if isinstance(status_code, bool):
177+
return None
178+
if isinstance(status_code, int):
179+
return status_code
180+
if isinstance(status_code, str):
181+
normalized_status = status_code.strip()
182+
if not normalized_status:
183+
return None
184+
try:
185+
return int(normalized_status, 10)
186+
except ValueError:
187+
return None
188+
return None
189+
190+
175191
def _is_retryable_exception(exc: Exception) -> bool:
176192
if isinstance(exc, ConcurrentBrokenExecutor):
177193
return False
@@ -194,11 +210,14 @@ def _is_retryable_exception(exc: Exception) -> bool:
194210
if isinstance(exc, (HyperbrowserTimeoutError, HyperbrowserPollingError)):
195211
return False
196212
if isinstance(exc, HyperbrowserError) and exc.status_code is not None:
197-
if isinstance(exc.status_code, bool) or not isinstance(exc.status_code, int):
213+
normalized_status_code = _normalize_status_code_for_retry(exc.status_code)
214+
if normalized_status_code is None:
198215
return True
199216
if (
200-
_CLIENT_ERROR_STATUS_MIN <= exc.status_code < _CLIENT_ERROR_STATUS_MAX
201-
and exc.status_code not in _RETRYABLE_CLIENT_ERROR_STATUS_CODES
217+
_CLIENT_ERROR_STATUS_MIN
218+
<= normalized_status_code
219+
< _CLIENT_ERROR_STATUS_MAX
220+
and normalized_status_code not in _RETRYABLE_CLIENT_ERROR_STATUS_CODES
202221
):
203222
return False
204223
return True

tests/test_polling.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,26 @@ def get_status() -> str:
124124
assert attempts["count"] == 1
125125

126126

127+
def test_poll_until_terminal_status_does_not_retry_numeric_string_client_errors():
128+
attempts = {"count": 0}
129+
130+
def get_status() -> str:
131+
attempts["count"] += 1
132+
raise HyperbrowserError("client failure", status_code="400") # type: ignore[arg-type]
133+
134+
with pytest.raises(HyperbrowserError, match="client failure"):
135+
poll_until_terminal_status(
136+
operation_name="sync poll numeric-string client error",
137+
get_status=get_status,
138+
is_terminal_status=lambda value: value == "completed",
139+
poll_interval_seconds=0.0001,
140+
max_wait_seconds=1.0,
141+
max_status_failures=5,
142+
)
143+
144+
assert attempts["count"] == 1
145+
146+
127147
def test_poll_until_terminal_status_does_not_retry_stop_iteration_errors():
128148
attempts = {"count": 0}
129149

@@ -367,7 +387,7 @@ async def get_status() -> str:
367387
if attempts["count"] < 3:
368388
raise HyperbrowserError(
369389
"malformed status code",
370-
status_code="400", # type: ignore[arg-type]
390+
status_code="invalid-status", # type: ignore[arg-type]
371391
)
372392
return "completed"
373393

@@ -559,6 +579,29 @@ def operation() -> str:
559579
assert attempts["count"] == 1
560580

561581

582+
def test_retry_operation_retries_numeric_string_rate_limit_errors():
583+
attempts = {"count": 0}
584+
585+
def operation() -> str:
586+
attempts["count"] += 1
587+
if attempts["count"] < 3:
588+
raise HyperbrowserError(
589+
"rate limited",
590+
status_code=" 429 ", # type: ignore[arg-type]
591+
)
592+
return "ok"
593+
594+
result = retry_operation(
595+
operation_name="sync retry numeric-string rate limit",
596+
operation=operation,
597+
max_attempts=5,
598+
retry_delay_seconds=0.0001,
599+
)
600+
601+
assert result == "ok"
602+
assert attempts["count"] == 3
603+
604+
562605
def test_retry_operation_does_not_retry_stop_iteration_errors():
563606
attempts = {"count": 0}
564607

@@ -733,7 +776,7 @@ def operation() -> str:
733776
if attempts["count"] < 3:
734777
raise HyperbrowserError(
735778
"malformed status code",
736-
status_code="400", # type: ignore[arg-type]
779+
status_code="invalid-status", # type: ignore[arg-type]
737780
)
738781
return "ok"
739782

@@ -882,6 +925,32 @@ async def get_status() -> str:
882925
asyncio.run(run())
883926

884927

928+
def test_poll_until_terminal_status_async_does_not_retry_numeric_string_client_errors():
929+
async def run() -> None:
930+
attempts = {"count": 0}
931+
932+
async def get_status() -> str:
933+
attempts["count"] += 1
934+
raise HyperbrowserError(
935+
"client failure",
936+
status_code="404", # type: ignore[arg-type]
937+
)
938+
939+
with pytest.raises(HyperbrowserError, match="client failure"):
940+
await poll_until_terminal_status_async(
941+
operation_name="async poll numeric-string client error",
942+
get_status=get_status,
943+
is_terminal_status=lambda value: value == "completed",
944+
poll_interval_seconds=0.0001,
945+
max_wait_seconds=1.0,
946+
max_status_failures=5,
947+
)
948+
949+
assert attempts["count"] == 1
950+
951+
asyncio.run(run())
952+
953+
885954
def test_poll_until_terminal_status_async_does_not_retry_stop_async_iteration_errors():
886955
async def run() -> None:
887956
attempts = {"count": 0}
@@ -1086,6 +1155,32 @@ async def operation() -> str:
10861155
asyncio.run(run())
10871156

10881157

1158+
def test_retry_operation_async_retries_numeric_string_rate_limit_errors():
1159+
async def run() -> None:
1160+
attempts = {"count": 0}
1161+
1162+
async def operation() -> str:
1163+
attempts["count"] += 1
1164+
if attempts["count"] < 3:
1165+
raise HyperbrowserError(
1166+
"rate limited",
1167+
status_code="429", # type: ignore[arg-type]
1168+
)
1169+
return "ok"
1170+
1171+
result = await retry_operation_async(
1172+
operation_name="async retry numeric-string rate limit",
1173+
operation=operation,
1174+
max_attempts=5,
1175+
retry_delay_seconds=0.0001,
1176+
)
1177+
1178+
assert result == "ok"
1179+
assert attempts["count"] == 3
1180+
1181+
asyncio.run(run())
1182+
1183+
10891184
def test_retry_operation_async_does_not_retry_stop_async_iteration_errors():
10901185
async def run() -> None:
10911186
attempts = {"count": 0}

0 commit comments

Comments
 (0)