Skip to content

Commit 6718e76

Browse files
authored
Retry transient GET requests (#119)
1 parent 7e89ae3 commit 6718e76

9 files changed

Lines changed: 506 additions & 59 deletions

File tree

hyperbrowser/client/managers/async_manager/sandbox.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@
5757
from ....sandbox_common import (
5858
RuntimeConnection,
5959
ensure_response_ok,
60+
get_retry_delay_seconds,
6061
normalize_network_error,
62+
request_context,
6163
parse_json_response,
64+
should_retry_get,
6265
)
6366
from ..sandboxes.shared import (
6467
_build_sandbox_exposed_url,
@@ -942,19 +945,31 @@ async def _request(
942945
params: Optional[Dict[str, object]] = None,
943946
data: Optional[Dict[str, object]] = None,
944947
):
945-
try:
946-
response = await self._client.transport.client.request(
947-
method,
948-
self._client._build_url(path),
949-
params={k: v for k, v in (params or {}).items() if v is not None},
950-
json=data,
951-
)
952-
except BaseException as error:
953-
raise normalize_network_error(
954-
error,
955-
"control",
956-
"Unknown error occurred",
957-
)
948+
failed_attempt = 1
949+
while True:
950+
try:
951+
response = await self._client.transport.client.request(
952+
method,
953+
self._client._build_url(path),
954+
params={
955+
key: value
956+
for key, value in (params or {}).items()
957+
if value is not None
958+
},
959+
json=data,
960+
)
961+
ensure_response_ok(response, "control")
962+
except BaseException as cause:
963+
error = normalize_network_error(
964+
cause,
965+
"control",
966+
"Unknown error occurred",
967+
request_context(method, path),
968+
)
969+
if not should_retry_get(method, error, failed_attempt):
970+
raise error
971+
await asyncio.sleep(get_retry_delay_seconds(failed_attempt))
972+
failed_attempt += 1
973+
continue
958974

959-
ensure_response_ok(response, "control")
960-
return parse_json_response(response, "control")
975+
return parse_json_response(response, "control")

hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ensure_response_ok,
1010
normalize_network_error,
1111
parse_json_response,
12+
request_context,
1213
resolve_runtime_transport_target,
1314
)
1415
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
@@ -275,6 +276,7 @@ async def _send(
275276
error,
276277
"runtime",
277278
"Unknown runtime request error",
279+
request_context(method, path),
278280
)
279281

280282
await response.aread()
@@ -309,6 +311,7 @@ async def _send_binary_stream(
309311
error,
310312
"runtime",
311313
"Unknown runtime request error",
314+
request_context(method, path),
312315
)
313316

314317
async def _send_stream(
@@ -341,4 +344,5 @@ async def _send_stream(
341344
error,
342345
"runtime",
343346
"Unknown runtime request error",
347+
request_context("GET", path),
344348
)

hyperbrowser/client/managers/sync_manager/sandbox.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@
5555
from ....sandbox_common import (
5656
RuntimeConnection,
5757
ensure_response_ok,
58+
get_retry_delay_seconds,
5859
normalize_network_error,
60+
request_context,
5961
parse_json_response,
62+
should_retry_get,
6063
)
6164
from ..sandboxes.shared import (
6265
_build_sandbox_exposed_url,
@@ -925,19 +928,31 @@ def _request(
925928
params: Optional[Dict[str, object]] = None,
926929
data: Optional[Dict[str, object]] = None,
927930
):
928-
try:
929-
response = self._client.transport.client.request(
930-
method,
931-
self._client._build_url(path),
932-
params={k: v for k, v in (params or {}).items() if v is not None},
933-
json=data,
934-
)
935-
except BaseException as error:
936-
raise normalize_network_error(
937-
error,
938-
"control",
939-
"Unknown error occurred",
940-
)
931+
failed_attempt = 1
932+
while True:
933+
try:
934+
response = self._client.transport.client.request(
935+
method,
936+
self._client._build_url(path),
937+
params={
938+
key: value
939+
for key, value in (params or {}).items()
940+
if value is not None
941+
},
942+
json=data,
943+
)
944+
ensure_response_ok(response, "control")
945+
except BaseException as cause:
946+
error = normalize_network_error(
947+
cause,
948+
"control",
949+
"Unknown error occurred",
950+
request_context(method, path),
951+
)
952+
if not should_retry_get(method, error, failed_attempt):
953+
raise error
954+
time.sleep(get_retry_delay_seconds(failed_attempt))
955+
failed_attempt += 1
956+
continue
941957

942-
ensure_response_ok(response, "control")
943-
return parse_json_response(response, "control")
958+
return parse_json_response(response, "control")

hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ensure_response_ok,
1010
normalize_network_error,
1111
parse_json_response,
12+
request_context,
1213
resolve_runtime_transport_target,
1314
)
1415
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
@@ -273,6 +274,7 @@ def _send(
273274
error,
274275
"runtime",
275276
"Unknown runtime request error",
277+
request_context(method, path),
276278
)
277279

278280
response.read()
@@ -307,6 +309,7 @@ def _send_binary_stream(
307309
error,
308310
"runtime",
309311
"Unknown runtime request error",
312+
request_context(method, path),
310313
)
311314

312315
def _send_stream(
@@ -339,4 +342,5 @@ def _send_stream(
339342
error,
340343
"runtime",
341344
"Unknown runtime request error",
345+
request_context("GET", path),
342346
)

hyperbrowser/sandbox_common.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import random
23
from dataclasses import dataclass
34
from typing import Any, Dict, Optional, Tuple
45
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -8,6 +9,9 @@
89
from .exceptions import HyperbrowserError, HyperbrowserService
910

1011
RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
12+
GET_RETRY_MAX_ATTEMPTS = 3
13+
GET_RETRY_INITIAL_DELAY_SECONDS = 0.25
14+
GET_RETRY_MAX_DELAY_SECONDS = 1.0
1115
RUNTIME_SESSION_REFRESH_BUFFER_MS = 60_000
1216

1317

@@ -45,6 +49,26 @@ def is_retryable_network_error(error: BaseException) -> bool:
4549
)
4650

4751

52+
def should_retry_get(
53+
method: str,
54+
error: HyperbrowserError,
55+
failed_attempt: int,
56+
) -> bool:
57+
return (
58+
method.upper() == "GET"
59+
and error.retryable
60+
and failed_attempt < GET_RETRY_MAX_ATTEMPTS
61+
)
62+
63+
64+
def get_retry_delay_seconds(failed_attempt: int) -> float:
65+
maximum_delay = min(
66+
GET_RETRY_INITIAL_DELAY_SECONDS * (2 ** (failed_attempt - 1)),
67+
GET_RETRY_MAX_DELAY_SECONDS,
68+
)
69+
return random.uniform(maximum_delay / 2, maximum_delay)
70+
71+
4872
def parse_error_payload(
4973
raw_text: str, fallback_message: str
5074
) -> Tuple[str, Optional[str], Any]:
@@ -230,16 +254,55 @@ def to_websocket_transport_target(
230254
)
231255

232256

257+
def request_context(method: Optional[str], path_or_url: Optional[str]) -> str:
258+
"""Render "[POST /sandbox]" for error messages.
259+
260+
Accepts either a bare path or a full URL. The query string is dropped so
261+
request parameters never reach error text or logs.
262+
"""
263+
normalized_method = (method or "").strip().upper()
264+
raw_target = (path_or_url or "").strip()
265+
normalized_path = urlsplit(raw_target).path or raw_target.split("?", 1)[0]
266+
if normalized_method and normalized_path:
267+
return f"[{normalized_method} {normalized_path}]"
268+
if normalized_path:
269+
return f"[{normalized_path}]"
270+
return f"[{normalized_method}]" if normalized_method else ""
271+
272+
273+
def describe_network_error(
274+
error: BaseException,
275+
default_message: str,
276+
context: str = "",
277+
) -> str:
278+
suffix = f" {context}" if context else ""
279+
detail = str(error).strip()
280+
if detail:
281+
return f"{detail}{suffix}"
282+
# Several httpx transport exceptions are raised with no arguments, so str()
283+
# is empty and the caller's fallback alone would not say which one failed.
284+
name = type(error).__name__
285+
base = f"{default_message} ({name})" if default_message else name
286+
return f"{base}{suffix}"
287+
288+
233289
def normalize_network_error(
234290
error: BaseException,
235291
service: HyperbrowserService,
236292
default_message: str,
293+
context: str = "",
237294
) -> HyperbrowserError:
238295
if isinstance(error, HyperbrowserError):
239296
return error
297+
if not isinstance(error, Exception):
298+
# CancelledError, KeyboardInterrupt and SystemExit are control flow, not
299+
# transport failures. Callers catch BaseException around their requests,
300+
# so wrapping these would strand the cancellation and report a request
301+
# the caller itself abandoned as a network error.
302+
raise error
240303

241304
return HyperbrowserError(
242-
str(error) if str(error) else default_message,
305+
describe_network_error(error, default_message, context),
243306
retryable=is_retryable_network_error(error),
244307
service=service,
245308
cause=error,

hyperbrowser/transport/async_transport.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
from typing import Optional
44

55
from hyperbrowser.exceptions import HyperbrowserError
6+
from hyperbrowser.sandbox_common import (
7+
RETRYABLE_STATUS_CODES,
8+
get_request_id,
9+
get_retry_delay_seconds,
10+
is_retryable_network_error,
11+
normalize_network_error,
12+
request_context,
13+
should_retry_get,
14+
)
615
from .base import TransportStrategy, APIResponse
716

817

@@ -49,6 +58,9 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse:
4958
status_code=response.status_code,
5059
response=response,
5160
original_error=e,
61+
request_id=get_request_id(response),
62+
retryable=response.status_code in RETRYABLE_STATUS_CODES,
63+
service="control",
5264
)
5365
return APIResponse.from_status(response.status_code)
5466
except httpx.HTTPStatusError as e:
@@ -62,9 +74,17 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse:
6274
status_code=response.status_code,
6375
response=response,
6476
original_error=e,
77+
request_id=get_request_id(response),
78+
retryable=response.status_code in RETRYABLE_STATUS_CODES,
79+
service="control",
6580
)
6681
except httpx.RequestError as e:
67-
raise HyperbrowserError("Request failed", original_error=e)
82+
raise HyperbrowserError(
83+
"Request failed",
84+
original_error=e,
85+
retryable=is_retryable_network_error(e),
86+
service="control",
87+
)
6888

6989
async def post(
7090
self,
@@ -82,25 +102,34 @@ async def post(
82102
else:
83103
response = await self.client.post(url, json=data, **kwargs)
84104
return await self._handle_response(response)
85-
except HyperbrowserError:
86-
raise
87-
except Exception as e:
88-
raise HyperbrowserError("Post request failed", original_error=e)
105+
except BaseException as e:
106+
raise normalize_network_error(
107+
e, "control", "Post request failed", request_context("POST", url)
108+
)
89109

90110
async def get(
91111
self, url: str, params: Optional[dict] = None, follow_redirects: bool = False
92112
) -> APIResponse:
93113
if params:
94114
params = {k: v for k, v in params.items() if v is not None}
95-
try:
96-
response = await self.client.get(
97-
url, params=params, follow_redirects=follow_redirects
98-
)
99-
return await self._handle_response(response)
100-
except HyperbrowserError:
101-
raise
102-
except Exception as e:
103-
raise HyperbrowserError("Get request failed", original_error=e)
115+
failed_attempt = 1
116+
while True:
117+
try:
118+
response = await self.client.get(
119+
url, params=params, follow_redirects=follow_redirects
120+
)
121+
return await self._handle_response(response)
122+
except BaseException as cause:
123+
error = normalize_network_error(
124+
cause,
125+
"control",
126+
"Get request failed",
127+
request_context("GET", url),
128+
)
129+
if not should_retry_get("GET", error, failed_attempt):
130+
raise error
131+
await asyncio.sleep(get_retry_delay_seconds(failed_attempt))
132+
failed_attempt += 1
104133

105134
async def put(self, url: str, data: Optional[dict] = None) -> APIResponse:
106135
try:

0 commit comments

Comments
 (0)