|
1 | 1 | import json |
| 2 | +import random |
2 | 3 | from dataclasses import dataclass |
3 | 4 | from typing import Any, Dict, Optional, Tuple |
4 | 5 | from urllib.parse import urljoin, urlsplit, urlunsplit |
|
8 | 9 | from .exceptions import HyperbrowserError, HyperbrowserService |
9 | 10 |
|
10 | 11 | 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 |
11 | 15 | RUNTIME_SESSION_REFRESH_BUFFER_MS = 60_000 |
12 | 16 |
|
13 | 17 |
|
@@ -45,6 +49,26 @@ def is_retryable_network_error(error: BaseException) -> bool: |
45 | 49 | ) |
46 | 50 |
|
47 | 51 |
|
| 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 | + |
48 | 72 | def parse_error_payload( |
49 | 73 | raw_text: str, fallback_message: str |
50 | 74 | ) -> Tuple[str, Optional[str], Any]: |
@@ -230,16 +254,55 @@ def to_websocket_transport_target( |
230 | 254 | ) |
231 | 255 |
|
232 | 256 |
|
| 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 | + |
233 | 289 | def normalize_network_error( |
234 | 290 | error: BaseException, |
235 | 291 | service: HyperbrowserService, |
236 | 292 | default_message: str, |
| 293 | + context: str = "", |
237 | 294 | ) -> HyperbrowserError: |
238 | 295 | if isinstance(error, HyperbrowserError): |
239 | 296 | 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 |
240 | 303 |
|
241 | 304 | return HyperbrowserError( |
242 | | - str(error) if str(error) else default_message, |
| 305 | + describe_network_error(error, default_message, context), |
243 | 306 | retryable=is_retryable_network_error(error), |
244 | 307 | service=service, |
245 | 308 | cause=error, |
|
0 commit comments