solari-browser 0.2.2__tar.gz → 0.2.3__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.2
3
+ Version: 0.2.3
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.2"
7
+ version = "0.2.3"
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.2"
40
+ __version__ = "0.2.3"
41
41
 
42
42
  __all__ = [
43
43
  "Solari",
@@ -81,19 +81,23 @@ 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.
84
+ # Some 5xx are answers, not outages: retrying cannot change them, and swallowing the body would hide the
85
+ # code from the caller. The gateway says so itself with `retryable: false` (502 `PoolResponseUnparseable`:
86
+ # the session may already exist, so a retry creates a second one; 503 `EgressBlockPeekUnsupportedOnFlavor`).
87
+ # `ProxyTierUnavailable` (explicitly requested proxy tier not servable) is also terminal, whatever its flag.
86
88
  _TERMINAL_503_CODES = frozenset({"ProxyTierUnavailable"})
87
89
 
88
90
 
89
- def _is_terminal_503(res: httpx.Response) -> bool:
90
- if res.status_code != 503:
91
- return False
91
+ def _is_terminal(res: httpx.Response) -> bool:
92
92
  try:
93
93
  body = res.json()
94
94
  except Exception: # noqa: BLE001 - non-JSON body is an ordinary outage
95
95
  return False
96
- return isinstance(body, dict) and body.get("code") in _TERMINAL_503_CODES
96
+ if not isinstance(body, dict):
97
+ return False
98
+ if body.get("retryable") is False:
99
+ return True
100
+ return res.status_code == 503 and body.get("code") in _TERMINAL_503_CODES
97
101
 
98
102
 
99
103
  # Mirrors `isLikelyTransientConnect`: which connect failures are worth relaunching.
@@ -486,11 +490,13 @@ class Solari:
486
490
  res = await self._http.request(
487
491
  method, url, headers=headers, content=payload
488
492
  )
489
- retry_this = (res.status_code in _RETRYABLE_STATUS and not _is_terminal_503(res)) or (
493
+ retry_this = (res.status_code in _RETRYABLE_STATUS and not _is_terminal(res)) or (
490
494
  _is_safe_to_replay(method, idempotency_key)
491
495
  and _says_retryable(res)
492
496
  )
493
- if res.status_code < 400 or not retry_this:
497
+ # Out of attempts: hand the last response back so the caller maps its
498
+ # status and `code` like any other error, not a status-less "exhausted".
499
+ if res.status_code < 400 or not retry_this or attempt == self._max_attempts:
494
500
  return res
495
501
  last_err = SolariError(f"Solari {method} {path}: {res.status_code}", res.status_code)
496
502
  except Exception as err: # noqa: BLE001 - all transport errors are retryable
@@ -286,16 +286,26 @@ async def test_does_not_retry_400(gw):
286
286
 
287
287
 
288
288
  @sync
289
- async def test_exhausting_attempts_raises(gw):
289
+ async def test_exhausting_attempts_on_5xx_keeps_the_status(gw):
290
290
  gw.route("POST", "/sessions", 503, {"error": "no capacity"})
291
291
  c = client(gw, backoff_ms=1, max_attempts=2)
292
292
  with pytest.raises(SolariError) as ei:
293
293
  await c.sessions.create()
294
294
  await c.close()
295
- assert "exhausted 2 attempts" in str(ei.value)
295
+ assert ei.value.status == 503
296
296
  assert len(gw.seen) == 2
297
297
 
298
298
 
299
+ @sync
300
+ async def test_exhausting_attempts_on_transport_error_raises_exhausted():
301
+ c = Solari(api_key="slr_live_test", base_url="http://127.0.0.1:1", backoff_ms=1, max_attempts=2)
302
+ with pytest.raises(SolariError) as ei:
303
+ await c.sessions.create()
304
+ await c.close()
305
+ assert "exhausted 2 attempts" in str(ei.value)
306
+ assert ei.value.status is None
307
+
308
+
299
309
  # ---- release / replay ---------------------------------------------------------
300
310
 
301
311
 
@@ -115,3 +115,52 @@ def test_ordinary_503_is_still_retried(gw): # noqa: F811
115
115
 
116
116
  create()
117
117
  assert len(gw.seen) == 2
118
+
119
+
120
+ def test_pool_response_unparseable_502_is_not_retried_and_keeps_its_code(gw): # noqa: F811
121
+ # The pool may already have created the session, so a retry would leave a second one behind.
122
+ gw.route("POST", "/sessions", 502,
123
+ {"error": "Pool returned an invalid response", "code": "PoolResponseUnparseable", "retryable": False})
124
+ gw.route("POST", "/sessions", 201, {"sessionId": "s-1", "wsEndpoint": "ws://127.0.0.1:1/ws"})
125
+
126
+ c = client(gw, backoff_ms=1)
127
+
128
+ @sync
129
+ async def create():
130
+ return await c.sessions.create()
131
+
132
+ with pytest.raises(SolariError) as info:
133
+ create()
134
+ assert info.value.code == "PoolResponseUnparseable"
135
+ assert info.value.status == 502
136
+ assert len(gw.seen) == 1, "retryable:false must not be retried"
137
+
138
+
139
+ def test_bare_502_is_still_retried(gw): # noqa: F811
140
+ gw.route("POST", "/sessions", 502, {"error": "bad gateway"})
141
+ gw.route("POST", "/sessions", 201, {"sessionId": "s-1", "wsEndpoint": "ws://127.0.0.1:1/ws"})
142
+
143
+ c = client(gw, backoff_ms=1)
144
+
145
+ @sync
146
+ async def create():
147
+ return await c.sessions.create()
148
+
149
+ create()
150
+ assert len(gw.seen) == 2
151
+
152
+
153
+ def test_final_5xx_keeps_its_status_and_code(gw): # noqa: F811
154
+ gw.route("POST", "/sessions", 503, {"error": "browser unhealthy", "code": "BrowserUnhealthy"})
155
+
156
+ c = client(gw, backoff_ms=1)
157
+
158
+ @sync
159
+ async def create():
160
+ return await c.sessions.create()
161
+
162
+ with pytest.raises(SolariError) as info:
163
+ create()
164
+ assert info.value.status == 503
165
+ assert info.value.code == "BrowserUnhealthy"
166
+ assert len(gw.seen) == 2, "both attempts are still made"
File without changes