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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: solari-browser
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for Solari Browser — managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP.
5
5
  Project-URL: Homepage, https://getsolari.com
6
6
  Author: Solari
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "solari-browser"
7
- version = "0.2.0"
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"
@@ -37,7 +37,7 @@ from .types import (
37
37
  Session,
38
38
  )
39
39
 
40
- __version__ = "0.1.2"
40
+ __version__ = "0.2.2"
41
41
 
42
42
  __all__ = [
43
43
  "Solari",
@@ -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 (gzip-encoded per `content_encoding`)."""
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