hyperbrowser-lite 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ /.pypirc
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperbrowser-lite
3
+ Version: 1.0.0
4
+ Summary: Minimal cloud browser SDK for Hyperbrowser — session lifecycle management via CDP.
5
+ Project-URL: Homepage, https://github.com/ayanami-browser-pilot/hyperbrowser-lite
6
+ Project-URL: Repository, https://github.com/ayanami-browser-pilot/hyperbrowser-lite
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic<3,>=2.5.2
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
12
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: respx>=0.21; extra == 'dev'
@@ -0,0 +1,234 @@
1
+ # hyperbrowser-lite
2
+
3
+ Minimal Python SDK for [Hyperbrowser](https://www.hyperbrowser.ai/) cloud browser sessions. Only does one thing: **create a cloud browser, return a CDP URL, clean up when done**.
4
+
5
+ Part of the [cloud-browser-sdk-spec](https://github.com/ayanami-browser-pilot) unified interface for cloud browser providers.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install hyperbrowser-lite
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from hyperbrowser_lite import HyperbrowserCloud
17
+
18
+ client = HyperbrowserCloud(api_key="hb-...") # or set HYPERBROWSER_API_KEY env var
19
+
20
+ # Create a cloud browser session
21
+ session = client.sessions.create()
22
+ print(session.cdp_url) # wss://connect-us-west-2.hyperbrowser.ai/?token=...
23
+ print(session.session_id) # UUID
24
+
25
+ # Connect with Playwright
26
+ # Hyperbrowser CDP uses token-based URLs — no auth headers needed
27
+ from playwright.sync_api import sync_playwright
28
+
29
+ with sync_playwright() as pw:
30
+ browser = pw.chromium.connect_over_cdp(session.cdp_url)
31
+ page = browser.contexts[0].new_page()
32
+ page.goto("https://example.com")
33
+ print(page.title())
34
+
35
+ # Clean up
36
+ client.sessions.delete(session.session_id)
37
+ ```
38
+
39
+ ### Context Manager (auto-cleanup)
40
+
41
+ ```python
42
+ with client.sessions.create() as session:
43
+ # use session.cdp_url ...
44
+ pass
45
+ # session automatically deleted on exit
46
+ ```
47
+
48
+ ### Async
49
+
50
+ ```python
51
+ from hyperbrowser_lite import AsyncHyperbrowserCloud
52
+
53
+ async with AsyncHyperbrowserCloud(api_key="hb-...") as client:
54
+ session = await client.sessions.create()
55
+ print(session.cdp_url)
56
+ await client.sessions.delete(session.session_id)
57
+ ```
58
+
59
+ ## API
60
+
61
+ ### Client
62
+
63
+ ```python
64
+ HyperbrowserCloud(api_key=None, *, base_url=None, timeout=60.0, max_retries=2)
65
+ AsyncHyperbrowserCloud(api_key=None, *, base_url=None, timeout=60.0, max_retries=2)
66
+ ```
67
+
68
+ - `api_key` — defaults to `HYPERBROWSER_API_KEY` env var
69
+ - `base_url` — defaults to `https://api.hyperbrowser.ai`
70
+
71
+ ### Sessions (`client.sessions`)
72
+
73
+ | Method | Description |
74
+ |--------|-------------|
75
+ | `create(**kwargs) -> SessionInfo` | Create cloud browser, returns CDP URL |
76
+ | `get(session_id) -> SessionInfo` | Get session status |
77
+ | `list(**filters) -> list[SessionInfo]` | List sessions |
78
+ | `delete(session_id) -> None` | Stop session (idempotent) |
79
+
80
+ ### `create()` Parameters
81
+
82
+ ```python
83
+ session = client.sessions.create(
84
+ # Browser mode — controls anti-detection
85
+ browser_mode="stealth", # or "ultra_stealth" (enterprise), default "normal"
86
+
87
+ # Proxy — managed or custom
88
+ proxy=ManagedProxyConfig(country="US"),
89
+ # proxy=ProxyConfig(server="http://proxy:8080", username="u", password="p"),
90
+
91
+ # Recording — ON by default, explicitly enable/disable
92
+ recording=RecordingConfig(enabled=True),
93
+
94
+ # Fingerprint — locale and viewport
95
+ fingerprint=FingerprintConfig(locale="en-US", viewport=ViewportConfig(1920, 1080)),
96
+
97
+ # Vendor params
98
+ solve_captchas=True, # paid plan required
99
+ adblock=True, # block ads, faster page loads
100
+ trackers=True, # block tracking scripts
101
+ annoyances=True, # block cookie banners, newsletter popups
102
+ timeout_minutes=30,
103
+ extension_ids=["ext-123"],
104
+ region="us-central",
105
+ )
106
+ ```
107
+
108
+ ### SessionInfo Fields
109
+
110
+ | Field | Type | Description |
111
+ |-------|------|-------------|
112
+ | `session_id` | `str` | Unique identifier (UUID) |
113
+ | `cdp_url` | `str \| None` | CDP WebSocket URL (`wss://connect-{region}.hyperbrowser.ai/?token=...`) |
114
+ | `status` | `str` | `"active"`, `"closed"`, or `"error"` |
115
+ | `created_at` | `datetime \| None` | Creation timestamp |
116
+ | `inspect_url` | `str \| None` | Live view URL — open in browser to watch session in real-time |
117
+ | `metadata` | `dict` | Vendor-specific data (see below) |
118
+
119
+ #### Metadata Contents
120
+
121
+ The `metadata` dict contains Hyperbrowser-specific fields from the API response:
122
+
123
+ | Key | Description |
124
+ |-----|-------------|
125
+ | `teamId` | Team identifier |
126
+ | `token` | JWT auth token (embedded in `cdp_url`) |
127
+ | `sessionUrl` | Dashboard URL for this session |
128
+ | `launchState` | Full launch configuration (see Feature Details below) |
129
+ | `proxyDataConsumed` | Proxy bandwidth consumed |
130
+ | `liveDomain` | WebSocket server domain |
131
+ | `webdriverEndpoint` | WebDriver protocol endpoint |
132
+ | `computerActionEndpoint` | Computer action API endpoint |
133
+
134
+ ### CDP WebSocket Authentication
135
+
136
+ Hyperbrowser's CDP WebSocket endpoint uses **token-based URLs** — the `wsEndpoint` contains an embedded JWT token. **No additional headers are needed**:
137
+
138
+ ```python
139
+ # Playwright — just connect directly
140
+ browser = pw.chromium.connect_over_cdp(session.cdp_url)
141
+
142
+ # Works with any CDP client (Playwright, Puppeteer, Selenium, raw websockets)
143
+ ```
144
+
145
+ This is different from providers like Skyvern which require `x-api-key` headers on the WebSocket handshake.
146
+
147
+ ## Feature Details
148
+
149
+ All features are verified against the live Hyperbrowser API. The `launchState` in `metadata` confirms which features are active.
150
+
151
+ ### Stealth Mode (`browser_mode="stealth"`)
152
+
153
+ Hides browser automation fingerprints so anti-bot systems cannot easily detect the session:
154
+ - `navigator.webdriver` returns `false`
155
+ - Chrome DevTools Protocol detection signatures are masked
156
+ - Other automation hints are suppressed
157
+
158
+ **Verified**: `launchState.useStealth` changes from `false` to `true`. Available on free plan.
159
+
160
+ ### Ultra Stealth Mode (`browser_mode="ultra_stealth"`)
161
+
162
+ Enhanced stealth with additional anti-detection measures beyond standard stealth.
163
+
164
+ **Requires enterprise plan** — returns HTTP 402 on free plan.
165
+
166
+ ### Recording (`RecordingConfig`)
167
+
168
+ Records DOM mutations during the session (similar to [rrweb](https://github.com/rrweb-io/rrweb)). After the session ends, the recording can be retrieved via the Hyperbrowser dashboard or API for playback.
169
+
170
+ **Important**: Recording is **enabled by default** (`enableWebRecording: true`). Passing `RecordingConfig(enabled=True)` is redundant unless you previously disabled it. Video recording (`enableVideoWebRecording`) is a separate feature and is off by default.
171
+
172
+ ### Ad Blocking (`adblock=True`)
173
+
174
+ Built-in ad blocker that removes ads (banners, popups, video ads) from pages:
175
+ - Faster page loads — fewer network requests
176
+ - Cleaner pages — no ad popups interfering with automation
177
+ - Less bandwidth — reduces `proxyDataConsumed`
178
+
179
+ Related options: `trackers=True` (blocks tracking scripts), `annoyances=True` (blocks cookie banners, newsletter popups).
180
+
181
+ **Verified**: `launchState.adblock` changes to `true`. Available on free plan.
182
+
183
+ ### Captcha Solving (`solve_captchas=True`)
184
+
185
+ Automatically solves CAPTCHAs encountered during browsing.
186
+
187
+ **Requires paid plan** — returns HTTP 402 on free plan.
188
+
189
+ ### Feature Support Matrix
190
+
191
+ | Feature | Free Plan | Paid Plan | Enterprise | Usage |
192
+ |---------|-----------|-----------|------------|-------|
193
+ | Custom proxy server | ? | ? | ? | `ProxyConfig(server=...)` |
194
+ | Managed proxy (200+ countries) | ? | ? | ? | `ManagedProxyConfig(country="US")` |
195
+ | Stealth mode | Yes | Yes | Yes | `browser_mode="stealth"` |
196
+ | Ultra stealth mode | No | No | Yes | `browser_mode="ultra_stealth"` |
197
+ | Browser fingerprint | Yes | Yes | Yes | `FingerprintConfig(locale=..., viewport=...)` |
198
+ | Session recording (DOM) | Yes (default ON) | Yes | Yes | `RecordingConfig(enabled=True)` |
199
+ | Video recording | ? | ? | ? | Vendor param: `enable_video_web_recording=True` |
200
+ | Captcha solving | No | Yes | Yes | `solve_captchas=True` |
201
+ | Ad blocking | Yes | Yes | Yes | `adblock=True` |
202
+ | Tracker blocking | Yes | Yes | Yes | `trackers=True` |
203
+ | Annoyance blocking | Yes | Yes | Yes | `annoyances=True` |
204
+ | Extensions | Yes | Yes | Yes | `extension_ids=[...]` |
205
+ | Region selection | Yes | Yes | Yes | `region="us-central"` |
206
+ | Browser profiles | Yes | Yes | Yes | `profile={"id": "..."}` |
207
+ | Context persistence | N/A | N/A | N/A | Use profiles instead |
208
+
209
+ > `?` = not tested (requires proxy configuration / paid features)
210
+
211
+ ### Exceptions
212
+
213
+ ```
214
+ CloudBrowserError
215
+ ├── AuthenticationError # 401/403 — invalid or expired API key
216
+ ├── QuotaExceededError # 429 — rate limit (has .retry_after)
217
+ ├── SessionNotFoundError # 404 — session not found
218
+ ├── ProviderError # 5xx — server error (has .status_code, .request_id)
219
+ ├── TimeoutError # Operation timeout
220
+ └── NetworkError # Connection failure
221
+ ```
222
+
223
+ Note: Plan-restricted features return **HTTP 402** (mapped to `CloudBrowserError`), not 403.
224
+
225
+ ### Backward Compatibility
226
+
227
+ ```python
228
+ from hyperbrowser_lite import Hyperbrowser # alias for HyperbrowserCloud
229
+ from hyperbrowser_lite import AsyncHyperbrowser # alias for AsyncHyperbrowserCloud
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hyperbrowser-lite"
7
+ version = "1.0.0"
8
+ description = "Minimal cloud browser SDK for Hyperbrowser — session lifecycle management via CDP."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "httpx>=0.27.0",
12
+ "pydantic>=2.5.2,<3",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.24",
19
+ "pytest-cov>=5.0",
20
+ "respx>=0.21",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/ayanami-browser-pilot/hyperbrowser-lite"
25
+ Repository = "https://github.com/ayanami-browser-pilot/hyperbrowser-lite"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/hyperbrowser_lite"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ testpaths = ["tests"]
@@ -0,0 +1,182 @@
1
+ """Hyperbrowser Cloud Browser SDK — minimal interface for browser session lifecycle.
2
+
3
+ Quick Start
4
+ -----------
5
+ ::
6
+
7
+ from hyperbrowser_lite import HyperbrowserCloud
8
+
9
+ client = HyperbrowserCloud(api_key="hb-...") # or set HYPERBROWSER_API_KEY env var
10
+
11
+ # Create a cloud browser session
12
+ session = client.sessions.create()
13
+ print(session.cdp_url) # wss://...
14
+ print(session.session_id)
15
+
16
+ # Use with Playwright (or any CDP client)
17
+ # NOTE: Hyperbrowser CDP uses token-based wsEndpoint URLs — no auth headers needed.
18
+ from playwright.sync_api import sync_playwright
19
+ with sync_playwright() as pw:
20
+ browser = pw.chromium.connect_over_cdp(session.cdp_url)
21
+ page = browser.contexts[0].new_page()
22
+ page.goto("https://example.com")
23
+
24
+ # Cleanup
25
+ client.sessions.delete(session.session_id)
26
+
27
+ # Or use context manager for auto-cleanup:
28
+ with client.sessions.create() as session:
29
+ ... # session auto-deleted on exit
30
+
31
+ API Reference
32
+ -------------
33
+
34
+ Client Classes
35
+ ~~~~~~~~~~~~~~
36
+ - ``HyperbrowserCloud(api_key, *, base_url, timeout, max_retries)`` — Sync client
37
+ - ``AsyncHyperbrowserCloud(api_key, *, base_url, timeout, max_retries)`` — Async client
38
+ - ``Hyperbrowser`` / ``AsyncHyperbrowser`` — Backward-compatible aliases
39
+
40
+ Client Properties
41
+ ~~~~~~~~~~~~~~~~~
42
+ - ``client.sessions`` — SessionsResource for CRUD operations
43
+ - ``client.contexts`` — Always None (use profiles for persistence)
44
+ - ``client.capabilities`` — Returns ``["proxy", "fingerprint", "recording"]``
45
+
46
+ Session CRUD (``client.sessions``)
47
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
48
+ - ``create(*, browser_mode, proxy, recording, fingerprint, context, **vendor_params) -> SessionInfo``
49
+ - ``get(session_id) -> SessionInfo``
50
+ - ``list(**filters) -> list[SessionInfo]``
51
+ - ``delete(session_id) -> None`` (idempotent, safe to call multiple times)
52
+
53
+ Vendor Parameters (pass via ``**vendor_params`` in ``create()``)
54
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
55
+ - ``timeout_minutes: int`` — Session timeout in minutes
56
+ - ``solve_captchas: bool`` — Enable captcha solving
57
+ - ``adblock: bool`` — Enable ad blocking
58
+ - ``trackers: bool`` — Block trackers
59
+ - ``annoyances: bool`` — Block annoyances
60
+ - ``profile: dict`` — Session profile (``{"id": "..."}`` for persistence)
61
+ - ``extension_ids: list[str]`` — Browser extension IDs
62
+ - ``region: str`` — Deploy region (e.g. ``"us-central"``)
63
+ - ``accept_cookies: bool`` — Auto-accept cookie banners
64
+ - ``browser_args: list[str]`` — Extra browser CLI arguments
65
+ - ``url_blocklist: list[str]`` — URLs to block
66
+ - ``save_downloads: bool`` — Persist downloaded files
67
+
68
+ Example with features::
69
+
70
+ session = client.sessions.create(
71
+ browser_mode="stealth",
72
+ proxy=ManagedProxyConfig(country="US"),
73
+ recording=RecordingConfig(enabled=True),
74
+ fingerprint=FingerprintConfig(locale="en-US"),
75
+ solve_captchas=True,
76
+ adblock=True,
77
+ timeout_minutes=30,
78
+ )
79
+
80
+ Feature Support Matrix
81
+ ~~~~~~~~~~~~~~~~~~~~~~
82
+ ========================= ========= ==================================================
83
+ Feature Supported Notes
84
+ ========================= ========= ==================================================
85
+ Custom proxy server Yes ``ProxyConfig(server, username, password)``
86
+ Managed proxy (200+ ctry) Yes ``ManagedProxyConfig(country="US")``
87
+ Stealth mode Yes ``browser_mode="stealth"``
88
+ Ultra stealth mode Yes ``browser_mode="ultra_stealth"``
89
+ Browser fingerprint Yes ``FingerprintConfig(locale=..., viewport=...)``
90
+ Session recording Yes ``RecordingConfig(enabled=True)``
91
+ Captcha solving Yes ``solve_captchas=True``
92
+ Ad blocking Yes ``adblock=True``
93
+ Extensions Yes ``extension_ids=[...]``
94
+ Region selection Yes ``region="us-central"``
95
+ Browser profiles Yes ``profile={"id": "..."}`` (vendor param)
96
+ Context persistence No Use profiles instead
97
+ ========================= ========= ==================================================
98
+
99
+ SessionInfo Fields
100
+ ~~~~~~~~~~~~~~~~~~
101
+ - ``session_id: str`` — Unique session identifier
102
+ - ``cdp_url: str | None`` — CDP WebSocket URL (wss://)
103
+ - ``status: str`` — "active", "closed", or "error"
104
+ - ``created_at: datetime | None``
105
+ - ``inspect_url: str | None`` — Live view URL for debugging
106
+ - ``metadata: dict`` — Vendor-specific data (teamId, token, proxyDataConsumed, etc.)
107
+
108
+ Proxy Configuration
109
+ ~~~~~~~~~~~~~~~~~~~
110
+ - ``ManagedProxyConfig(country="US")`` — Managed proxy with country
111
+ - ``ManagedProxyConfig(country="US", city="New York")`` — Managed proxy with city
112
+ - ``ProxyConfig(server="http://proxy:8080")`` — Custom proxy server
113
+ - ``ProxyConfig(server, username="u", password="p")`` — Custom proxy with auth
114
+
115
+ CDP Authentication
116
+ ~~~~~~~~~~~~~~~~~~
117
+ Hyperbrowser's CDP WebSocket endpoint uses token-based URLs (the ``wsEndpoint``
118
+ contains an embedded auth token). **No additional headers are needed** for the
119
+ WebSocket connection::
120
+
121
+ browser = pw.chromium.connect_over_cdp(session.cdp_url) # No headers needed
122
+
123
+ Exception Hierarchy
124
+ ~~~~~~~~~~~~~~~~~~~
125
+ ::
126
+
127
+ CloudBrowserError # Base exception
128
+ ├── AuthenticationError # 401/403 — invalid or expired API key
129
+ ├── QuotaExceededError # 429 — rate limit (has .retry_after attribute)
130
+ ├── SessionNotFoundError # 404 — session doesn't exist
131
+ ├── ProviderError # 5xx — server error (has .status_code, .request_id)
132
+ ├── TimeoutError # Operation timed out
133
+ └── NetworkError # Connection failure
134
+ """
135
+
136
+ from .client import AsyncHyperbrowserCloud, HyperbrowserCloud
137
+ from .exceptions import (
138
+ AuthenticationError,
139
+ CloudBrowserError,
140
+ NetworkError,
141
+ ProviderError,
142
+ QuotaExceededError,
143
+ SessionNotFoundError,
144
+ TimeoutError,
145
+ )
146
+ from .models import (
147
+ ContextAttach,
148
+ FingerprintConfig,
149
+ ManagedProxyConfig,
150
+ ProxyConfig,
151
+ RecordingConfig,
152
+ SessionInfo,
153
+ ViewportConfig,
154
+ )
155
+
156
+ # Backward compatibility aliases
157
+ Hyperbrowser = HyperbrowserCloud
158
+ AsyncHyperbrowser = AsyncHyperbrowserCloud
159
+
160
+ __all__ = [
161
+ # Clients
162
+ "HyperbrowserCloud",
163
+ "AsyncHyperbrowserCloud",
164
+ "Hyperbrowser",
165
+ "AsyncHyperbrowser",
166
+ # Models
167
+ "SessionInfo",
168
+ "ContextAttach",
169
+ "FingerprintConfig",
170
+ "ViewportConfig",
171
+ "ProxyConfig",
172
+ "ManagedProxyConfig",
173
+ "RecordingConfig",
174
+ # Exceptions
175
+ "CloudBrowserError",
176
+ "AuthenticationError",
177
+ "QuotaExceededError",
178
+ "SessionNotFoundError",
179
+ "ProviderError",
180
+ "TimeoutError",
181
+ "NetworkError",
182
+ ]
@@ -0,0 +1,209 @@
1
+ """Internal HTTP client wrappers with retry and exception mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .exceptions import (
11
+ AuthenticationError,
12
+ CloudBrowserError,
13
+ NetworkError,
14
+ ProviderError,
15
+ QuotaExceededError,
16
+ SessionNotFoundError,
17
+ TimeoutError,
18
+ )
19
+
20
+ _RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
21
+ _DEFAULT_BACKOFF_BASE = 0.5
22
+ _DEFAULT_BACKOFF_MAX = 3.0
23
+
24
+
25
+ def _parse_retry_after(response: httpx.Response) -> float | None:
26
+ """Parse Retry-After header value in seconds."""
27
+ value = response.headers.get("retry-after")
28
+ if value is None:
29
+ return None
30
+ try:
31
+ return float(value)
32
+ except (ValueError, TypeError):
33
+ return None
34
+
35
+
36
+ def _raise_for_status(response: httpx.Response) -> None:
37
+ """Map HTTP status codes to SDK exceptions."""
38
+ status = response.status_code
39
+ if 200 <= status < 300:
40
+ return
41
+
42
+ try:
43
+ body = response.json()
44
+ except Exception:
45
+ body = {}
46
+
47
+ message = body.get("detail") or body.get("message") or response.text or f"HTTP {status}"
48
+ request_id = response.headers.get("x-request-id")
49
+
50
+ if status in (401, 403):
51
+ raise AuthenticationError(str(message))
52
+ if status == 404:
53
+ raise SessionNotFoundError(str(message))
54
+ if status == 429:
55
+ retry_after_val = _parse_retry_after(response)
56
+ retry_after_int = int(retry_after_val) if retry_after_val is not None else None
57
+ raise QuotaExceededError(str(message), retry_after=retry_after_int)
58
+ if status >= 500:
59
+ raise ProviderError(
60
+ str(message),
61
+ status_code=status,
62
+ request_id=request_id,
63
+ )
64
+ raise CloudBrowserError(f"HTTP {status}: {message}")
65
+
66
+
67
+ class SyncHttpClient:
68
+ """Synchronous HTTP client with retry logic."""
69
+
70
+ def __init__(
71
+ self,
72
+ base_url: str,
73
+ api_key: str,
74
+ timeout: float = 60.0,
75
+ max_retries: int = 2,
76
+ ):
77
+ self._client = httpx.Client(
78
+ base_url=base_url,
79
+ headers={"x-api-key": api_key},
80
+ timeout=timeout,
81
+ )
82
+ self._max_retries = max_retries
83
+
84
+ def request(
85
+ self,
86
+ method: str,
87
+ path: str,
88
+ *,
89
+ json: Any = None,
90
+ params: dict[str, Any] | None = None,
91
+ ) -> dict[str, Any]:
92
+ """Send an HTTP request with retry on transient errors."""
93
+ last_exc: Exception | None = None
94
+
95
+ for attempt in range(self._max_retries + 1):
96
+ try:
97
+ response = self._client.request(
98
+ method, path, json=json, params=params
99
+ )
100
+ except httpx.TimeoutException as exc:
101
+ last_exc = TimeoutError(str(exc))
102
+ if attempt < self._max_retries:
103
+ self._backoff(attempt)
104
+ continue
105
+ raise last_exc from exc
106
+ except httpx.ConnectError as exc:
107
+ last_exc = NetworkError(str(exc))
108
+ if attempt < self._max_retries:
109
+ self._backoff(attempt)
110
+ continue
111
+ raise last_exc from exc
112
+
113
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self._max_retries:
114
+ wait = _parse_retry_after(response) or self._backoff_delay(attempt)
115
+ time.sleep(wait)
116
+ continue
117
+
118
+ _raise_for_status(response)
119
+
120
+ if response.status_code == 204 or not response.content:
121
+ return {}
122
+ return response.json() # type: ignore[no-any-return]
123
+
124
+ # Should not reach here, but just in case
125
+ if last_exc is not None:
126
+ raise last_exc
127
+ raise CloudBrowserError("Request failed after retries")
128
+
129
+ def close(self) -> None:
130
+ self._client.close()
131
+
132
+ @staticmethod
133
+ def _backoff_delay(attempt: int) -> float:
134
+ return min(_DEFAULT_BACKOFF_BASE * (2**attempt), _DEFAULT_BACKOFF_MAX)
135
+
136
+ @staticmethod
137
+ def _backoff(attempt: int) -> None:
138
+ time.sleep(min(_DEFAULT_BACKOFF_BASE * (2**attempt), _DEFAULT_BACKOFF_MAX))
139
+
140
+
141
+ class AsyncHttpClient:
142
+ """Asynchronous HTTP client with retry logic."""
143
+
144
+ def __init__(
145
+ self,
146
+ base_url: str,
147
+ api_key: str,
148
+ timeout: float = 60.0,
149
+ max_retries: int = 2,
150
+ ):
151
+ self._client = httpx.AsyncClient(
152
+ base_url=base_url,
153
+ headers={"x-api-key": api_key},
154
+ timeout=timeout,
155
+ )
156
+ self._max_retries = max_retries
157
+
158
+ async def request(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ json: Any = None,
164
+ params: dict[str, Any] | None = None,
165
+ ) -> dict[str, Any]:
166
+ """Send an async HTTP request with retry on transient errors."""
167
+ import asyncio
168
+
169
+ last_exc: Exception | None = None
170
+
171
+ for attempt in range(self._max_retries + 1):
172
+ try:
173
+ response = await self._client.request(
174
+ method, path, json=json, params=params
175
+ )
176
+ except httpx.TimeoutException as exc:
177
+ last_exc = TimeoutError(str(exc))
178
+ if attempt < self._max_retries:
179
+ await asyncio.sleep(self._backoff_delay(attempt))
180
+ continue
181
+ raise last_exc from exc
182
+ except httpx.ConnectError as exc:
183
+ last_exc = NetworkError(str(exc))
184
+ if attempt < self._max_retries:
185
+ await asyncio.sleep(self._backoff_delay(attempt))
186
+ continue
187
+ raise last_exc from exc
188
+
189
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self._max_retries:
190
+ wait = _parse_retry_after(response) or self._backoff_delay(attempt)
191
+ await asyncio.sleep(wait)
192
+ continue
193
+
194
+ _raise_for_status(response)
195
+
196
+ if response.status_code == 204 or not response.content:
197
+ return {}
198
+ return response.json() # type: ignore[no-any-return]
199
+
200
+ if last_exc is not None:
201
+ raise last_exc
202
+ raise CloudBrowserError("Request failed after retries")
203
+
204
+ async def close(self) -> None:
205
+ await self._client.aclose()
206
+
207
+ @staticmethod
208
+ def _backoff_delay(attempt: int) -> float:
209
+ return min(_DEFAULT_BACKOFF_BASE * (2**attempt), _DEFAULT_BACKOFF_MAX)