solari-browser 0.2.1__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.1
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.1"
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.2.1"
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|"
@@ -471,7 +486,7 @@ class Solari:
471
486
  res = await self._http.request(
472
487
  method, url, headers=headers, content=payload
473
488
  )
474
- 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 (
475
490
  _is_safe_to_replay(method, idempotency_key)
476
491
  and _says_retryable(res)
477
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