Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions hyperbrowser/client/managers/async_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@
from ....sandbox_common import (
RuntimeConnection,
ensure_response_ok,
get_retry_delay_seconds,
normalize_network_error,
request_context,
parse_json_response,
should_retry_get,
)
from ..sandboxes.shared import (
_build_sandbox_exposed_url,
Expand Down Expand Up @@ -942,19 +945,31 @@ async def _request(
params: Optional[Dict[str, object]] = None,
data: Optional[Dict[str, object]] = None,
):
try:
response = await self._client.transport.client.request(
method,
self._client._build_url(path),
params={k: v for k, v in (params or {}).items() if v is not None},
json=data,
)
except BaseException as error:
raise normalize_network_error(
error,
"control",
"Unknown error occurred",
)
failed_attempt = 1
while True:
try:
response = await self._client.transport.client.request(
method,
self._client._build_url(path),
params={
key: value
for key, value in (params or {}).items()
if value is not None
},
json=data,
)
ensure_response_ok(response, "control")
except BaseException as cause:
error = normalize_network_error(
cause,
"control",
"Unknown error occurred",
request_context(method, path),
)
if not should_retry_get(method, error, failed_attempt):
raise error
await asyncio.sleep(get_retry_delay_seconds(failed_attempt))
failed_attempt += 1
continue

ensure_response_ok(response, "control")
return parse_json_response(response, "control")
return parse_json_response(response, "control")
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ensure_response_ok,
normalize_network_error,
parse_json_response,
request_context,
resolve_runtime_transport_target,
)
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
Expand Down Expand Up @@ -275,6 +276,7 @@ async def _send(
error,
"runtime",
"Unknown runtime request error",
request_context(method, path),
)

await response.aread()
Expand Down Expand Up @@ -309,6 +311,7 @@ async def _send_binary_stream(
error,
"runtime",
"Unknown runtime request error",
request_context(method, path),
)

async def _send_stream(
Expand Down Expand Up @@ -341,4 +344,5 @@ async def _send_stream(
error,
"runtime",
"Unknown runtime request error",
request_context("GET", path),
)
45 changes: 30 additions & 15 deletions hyperbrowser/client/managers/sync_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@
from ....sandbox_common import (
RuntimeConnection,
ensure_response_ok,
get_retry_delay_seconds,
normalize_network_error,
request_context,
parse_json_response,
should_retry_get,
)
from ..sandboxes.shared import (
_build_sandbox_exposed_url,
Expand Down Expand Up @@ -925,19 +928,31 @@ def _request(
params: Optional[Dict[str, object]] = None,
data: Optional[Dict[str, object]] = None,
):
try:
response = self._client.transport.client.request(
method,
self._client._build_url(path),
params={k: v for k, v in (params or {}).items() if v is not None},
json=data,
)
except BaseException as error:
raise normalize_network_error(
error,
"control",
"Unknown error occurred",
)
failed_attempt = 1
while True:
try:
response = self._client.transport.client.request(
method,
self._client._build_url(path),
params={
key: value
for key, value in (params or {}).items()
if value is not None
},
json=data,
)
ensure_response_ok(response, "control")
except BaseException as cause:
error = normalize_network_error(
cause,
"control",
"Unknown error occurred",
request_context(method, path),
)
if not should_retry_get(method, error, failed_attempt):
raise error
time.sleep(get_retry_delay_seconds(failed_attempt))
failed_attempt += 1
continue

ensure_response_ok(response, "control")
return parse_json_response(response, "control")
return parse_json_response(response, "control")
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ensure_response_ok,
normalize_network_error,
parse_json_response,
request_context,
resolve_runtime_transport_target,
)
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
Expand Down Expand Up @@ -273,6 +274,7 @@ def _send(
error,
"runtime",
"Unknown runtime request error",
request_context(method, path),
)

response.read()
Expand Down Expand Up @@ -307,6 +309,7 @@ def _send_binary_stream(
error,
"runtime",
"Unknown runtime request error",
request_context(method, path),
)

def _send_stream(
Expand Down Expand Up @@ -339,4 +342,5 @@ def _send_stream(
error,
"runtime",
"Unknown runtime request error",
request_context("GET", path),
)
65 changes: 64 additions & 1 deletion hyperbrowser/sandbox_common.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import random
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urljoin, urlsplit, urlunsplit
Expand All @@ -8,6 +9,9 @@
from .exceptions import HyperbrowserError, HyperbrowserService

RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
GET_RETRY_MAX_ATTEMPTS = 3
GET_RETRY_INITIAL_DELAY_SECONDS = 0.25
GET_RETRY_MAX_DELAY_SECONDS = 1.0
RUNTIME_SESSION_REFRESH_BUFFER_MS = 60_000


Expand Down Expand Up @@ -45,6 +49,26 @@ def is_retryable_network_error(error: BaseException) -> bool:
)


def should_retry_get(
method: str,
error: HyperbrowserError,
failed_attempt: int,
) -> bool:
return (
method.upper() == "GET"
and error.retryable
and failed_attempt < GET_RETRY_MAX_ATTEMPTS
)


def get_retry_delay_seconds(failed_attempt: int) -> float:
maximum_delay = min(
GET_RETRY_INITIAL_DELAY_SECONDS * (2 ** (failed_attempt - 1)),
GET_RETRY_MAX_DELAY_SECONDS,
)
return random.uniform(maximum_delay / 2, maximum_delay)


def parse_error_payload(
raw_text: str, fallback_message: str
) -> Tuple[str, Optional[str], Any]:
Expand Down Expand Up @@ -230,16 +254,55 @@ def to_websocket_transport_target(
)


def request_context(method: Optional[str], path_or_url: Optional[str]) -> str:
"""Render "[POST /sandbox]" for error messages.

Accepts either a bare path or a full URL. The query string is dropped so
request parameters never reach error text or logs.
"""
normalized_method = (method or "").strip().upper()
raw_target = (path_or_url or "").strip()
normalized_path = urlsplit(raw_target).path or raw_target.split("?", 1)[0]
if normalized_method and normalized_path:
return f"[{normalized_method} {normalized_path}]"
if normalized_path:
return f"[{normalized_path}]"
return f"[{normalized_method}]" if normalized_method else ""


def describe_network_error(
error: BaseException,
default_message: str,
context: str = "",
) -> str:
suffix = f" {context}" if context else ""
detail = str(error).strip()
if detail:
return f"{detail}{suffix}"
# Several httpx transport exceptions are raised with no arguments, so str()
# is empty and the caller's fallback alone would not say which one failed.
name = type(error).__name__
base = f"{default_message} ({name})" if default_message else name
return f"{base}{suffix}"


def normalize_network_error(
error: BaseException,
service: HyperbrowserService,
default_message: str,
context: str = "",
) -> HyperbrowserError:
if isinstance(error, HyperbrowserError):
return error
if not isinstance(error, Exception):
# CancelledError, KeyboardInterrupt and SystemExit are control flow, not
# transport failures. Callers catch BaseException around their requests,
# so wrapping these would strand the cancellation and report a request
# the caller itself abandoned as a network error.
raise error
Comment thread
Dingway98 marked this conversation as resolved.

return HyperbrowserError(
str(error) if str(error) else default_message,
describe_network_error(error, default_message, context),
retryable=is_retryable_network_error(error),
service=service,
cause=error,
Expand Down
57 changes: 43 additions & 14 deletions hyperbrowser/transport/async_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
from typing import Optional

from hyperbrowser.exceptions import HyperbrowserError
from hyperbrowser.sandbox_common import (
RETRYABLE_STATUS_CODES,
get_request_id,
get_retry_delay_seconds,
is_retryable_network_error,
normalize_network_error,
request_context,
should_retry_get,
)
from .base import TransportStrategy, APIResponse


Expand Down Expand Up @@ -49,6 +58,9 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse:
status_code=response.status_code,
response=response,
original_error=e,
request_id=get_request_id(response),
retryable=response.status_code in RETRYABLE_STATUS_CODES,
service="control",
)
return APIResponse.from_status(response.status_code)
except httpx.HTTPStatusError as e:
Expand All @@ -62,9 +74,17 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse:
status_code=response.status_code,
response=response,
original_error=e,
request_id=get_request_id(response),
retryable=response.status_code in RETRYABLE_STATUS_CODES,
service="control",
)
except httpx.RequestError as e:
raise HyperbrowserError("Request failed", original_error=e)
raise HyperbrowserError(
"Request failed",
original_error=e,
retryable=is_retryable_network_error(e),
service="control",
)

async def post(
self,
Expand All @@ -82,25 +102,34 @@ async def post(
else:
response = await self.client.post(url, json=data, **kwargs)
return await self._handle_response(response)
except HyperbrowserError:
raise
except Exception as e:
raise HyperbrowserError("Post request failed", original_error=e)
except BaseException as e:
raise normalize_network_error(
e, "control", "Post request failed", request_context("POST", url)
)

async def get(
self, url: str, params: Optional[dict] = None, follow_redirects: bool = False
) -> APIResponse:
if params:
params = {k: v for k, v in params.items() if v is not None}
try:
response = await self.client.get(
url, params=params, follow_redirects=follow_redirects
)
return await self._handle_response(response)
except HyperbrowserError:
raise
except Exception as e:
raise HyperbrowserError("Get request failed", original_error=e)
failed_attempt = 1
while True:
try:
response = await self.client.get(
url, params=params, follow_redirects=follow_redirects
)
return await self._handle_response(response)
except BaseException as cause:
error = normalize_network_error(
cause,
"control",
"Get request failed",
request_context("GET", url),
)
if not should_retry_get("GET", error, failed_attempt):
raise error
await asyncio.sleep(get_retry_delay_seconds(failed_attempt))
failed_attempt += 1

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