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.
- {solari_browser-0.2.2 → solari_browser-0.2.3}/PKG-INFO +1 -1
- {solari_browser-0.2.2 → solari_browser-0.2.3}/pyproject.toml +1 -1
- {solari_browser-0.2.2 → solari_browser-0.2.3}/solari_browser/__init__.py +1 -1
- {solari_browser-0.2.2 → solari_browser-0.2.3}/solari_browser/client.py +14 -8
- {solari_browser-0.2.2 → solari_browser-0.2.3}/tests/test_client.py +12 -2
- {solari_browser-0.2.2 → solari_browser-0.2.3}/tests/test_retry_hint.py +49 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/.gitignore +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/README.md +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/solari_browser/browser_session.py +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/solari_browser/errors.py +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/solari_browser/types.py +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/tests/conftest.py +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/tests/test_launch_seed.py +0 -0
- {solari_browser-0.2.2 → solari_browser-0.2.3}/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.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"
|
|
@@ -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
|
-
#
|
|
85
|
-
# code from the caller.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|