solari-browser 0.2.0__tar.gz → 0.2.2__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.
- {solari_browser-0.2.0 → solari_browser-0.2.2}/PKG-INFO +1 -1
- {solari_browser-0.2.0 → solari_browser-0.2.2}/pyproject.toml +1 -1
- {solari_browser-0.2.0 → solari_browser-0.2.2}/solari_browser/__init__.py +1 -1
- {solari_browser-0.2.0 → solari_browser-0.2.2}/solari_browser/client.py +20 -2
- {solari_browser-0.2.0 → solari_browser-0.2.2}/solari_browser/errors.py +3 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/tests/test_retry_hint.py +32 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/.gitignore +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/README.md +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/solari_browser/browser_session.py +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/solari_browser/types.py +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/tests/conftest.py +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/tests/test_client.py +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/tests/test_launch_seed.py +0 -0
- {solari_browser-0.2.0 → solari_browser-0.2.2}/tests/test_resolved_proxy.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "solari-browser"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Python SDK for Solari Browser — managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -81,6 +81,21 @@ def _says_retryable(res: httpx.Response) -> bool:
|
|
|
81
81
|
return False
|
|
82
82
|
return isinstance(body, dict) and body.get("retryable") is True
|
|
83
83
|
|
|
84
|
+
# A few 503s are answers, not outages: retrying cannot change them, and swallowing the body would hide the
|
|
85
|
+
# code from the caller. `ProxyTierUnavailable` (explicitly requested proxy tier not servable) is one.
|
|
86
|
+
_TERMINAL_503_CODES = frozenset({"ProxyTierUnavailable"})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_terminal_503(res: httpx.Response) -> bool:
|
|
90
|
+
if res.status_code != 503:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
body = res.json()
|
|
94
|
+
except Exception: # noqa: BLE001 - non-JSON body is an ordinary outage
|
|
95
|
+
return False
|
|
96
|
+
return isinstance(body, dict) and body.get("code") in _TERMINAL_503_CODES
|
|
97
|
+
|
|
98
|
+
|
|
84
99
|
# Mirrors `isLikelyTransientConnect`: which connect failures are worth relaunching.
|
|
85
100
|
_TRANSIENT_CONNECT = re.compile(
|
|
86
101
|
r"ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|WebSocket|connect|"
|
|
@@ -284,7 +299,10 @@ class _Sessions:
|
|
|
284
299
|
)
|
|
285
300
|
|
|
286
301
|
async def download_replay(self, session_id: str) -> bytes:
|
|
287
|
-
"""Download the replay as NDJSON bytes
|
|
302
|
+
"""Download the replay as NDJSON bytes. May or may not actually be
|
|
303
|
+
gzip despite what was uploaded — GCS decompresses transparently on
|
|
304
|
+
an ordinary GET, so check `content_encoding` (now provider-accurate)
|
|
305
|
+
before attempting to decompress."""
|
|
288
306
|
replay = await self.get_replay_url(session_id)
|
|
289
307
|
async with httpx.AsyncClient(timeout=self._client._timeout_s) as http:
|
|
290
308
|
res = await http.get(replay.url)
|
|
@@ -468,7 +486,7 @@ class Solari:
|
|
|
468
486
|
res = await self._http.request(
|
|
469
487
|
method, url, headers=headers, content=payload
|
|
470
488
|
)
|
|
471
|
-
retry_this = res.status_code in _RETRYABLE_STATUS or (
|
|
489
|
+
retry_this = (res.status_code in _RETRYABLE_STATUS and not _is_terminal_503(res)) or (
|
|
472
490
|
_is_safe_to_replay(method, idempotency_key)
|
|
473
491
|
and _says_retryable(res)
|
|
474
492
|
)
|
|
@@ -17,6 +17,9 @@ BROWSER_UNHEALTHY = "BrowserUnhealthy"
|
|
|
17
17
|
# `_release_rejection` in client.py for why a 404 is not blanket-success on
|
|
18
18
|
# DELETE /sessions/:id.
|
|
19
19
|
INVALID_SESSION_ID = "InvalidSessionId"
|
|
20
|
+
# PROXY_TIER_UNAVAILABLE — POST /sessions 503: a proxy tier you explicitly requested cannot be served right now.
|
|
21
|
+
# No session was created. Not retried by the client (the answer cannot change within a retry).
|
|
22
|
+
PROXY_TIER_UNAVAILABLE = "ProxyTierUnavailable"
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
class SolariError(Exception):
|
|
@@ -83,3 +83,35 @@ def test_507_is_retryable_even_though_this_gateway_never_emits_one():
|
|
|
83
83
|
# Negative control: permanent statuses must stay out.
|
|
84
84
|
assert 500 not in _RETRYABLE_STATUS
|
|
85
85
|
assert 501 not in _RETRYABLE_STATUS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_proxy_tier_unavailable_503_is_not_retried_and_keeps_its_code(gw): # noqa: F811
|
|
89
|
+
gw.route("POST", "/sessions", 503,
|
|
90
|
+
{"error": "Requested proxy tier 'residential' is unavailable", "code": "ProxyTierUnavailable", "tier": "residential"})
|
|
91
|
+
gw.route("POST", "/sessions", 201, {"sessionId": "s-1", "wsEndpoint": "ws://127.0.0.1:1/ws"})
|
|
92
|
+
|
|
93
|
+
c = client(gw, backoff_ms=1)
|
|
94
|
+
|
|
95
|
+
@sync
|
|
96
|
+
async def create():
|
|
97
|
+
return await c.sessions.create(proxy={"tier": "residential", "country": "us"})
|
|
98
|
+
|
|
99
|
+
with pytest.raises(SolariError) as info:
|
|
100
|
+
create()
|
|
101
|
+
assert info.value.code == "ProxyTierUnavailable"
|
|
102
|
+
assert info.value.status == 503
|
|
103
|
+
assert len(gw.seen) == 1, "a terminal 503 must not be retried"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_ordinary_503_is_still_retried(gw): # noqa: F811
|
|
107
|
+
gw.route("POST", "/sessions", 503, {"error": "No stealth pool available", "kind": "stealth"})
|
|
108
|
+
gw.route("POST", "/sessions", 201, {"sessionId": "s-1", "wsEndpoint": "ws://127.0.0.1:1/ws"})
|
|
109
|
+
|
|
110
|
+
c = client(gw, backoff_ms=1)
|
|
111
|
+
|
|
112
|
+
@sync
|
|
113
|
+
async def create():
|
|
114
|
+
return await c.sessions.create()
|
|
115
|
+
|
|
116
|
+
create()
|
|
117
|
+
assert len(gw.seen) == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|