scrapely-python 0.1.4__tar.gz → 0.1.5__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.
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/PKG-INFO +1 -1
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/pyproject.toml +1 -1
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/api_client.py +109 -10
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
- scrapely_python-0.1.5/tests/test_api_client_retries.py +170 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_storage.py +5 -1
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/README.md +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/setup.cfg +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/charging.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.4 → scrapely_python-0.1.5}/tests/test_secrets.py +0 -0
|
@@ -24,6 +24,32 @@ class ApiClientError(Exception):
|
|
|
24
24
|
self.body = body
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
# PUT is left out: a KV record PUT is counted (and billed) as a write each time
|
|
28
|
+
# it is applied, so a lost response must not be re-sent blindly.
|
|
29
|
+
IDEMPOTENT_METHODS = frozenset({'GET', 'HEAD', 'DELETE', 'OPTIONS'})
|
|
30
|
+
# HAProxy's own 503 page: no backend was available, so nothing was forwarded.
|
|
31
|
+
NO_BACKEND_MARKER = 'No server is available to handle this request'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_retryable_response(
|
|
35
|
+
status: int, idempotent: bool, error_type: Optional[str] = None, body_text: Optional[str] = None
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Whether a non-2xx answer is retried.
|
|
38
|
+
|
|
39
|
+
Idempotent methods: 500/502/503/504. Others (dataset push, KV PUT) only on
|
|
40
|
+
a 503 that certainly was not processed: the rate limiter refused it
|
|
41
|
+
("rate-limit-unavailable", before the handler), or HAProxy had no backend
|
|
42
|
+
(its own page, nothing forwarded). Any other 503 may mean "outcome unknown"
|
|
43
|
+
(a direct dataset write whose commit was not confirmed), and re-sending
|
|
44
|
+
could store the items twice.
|
|
45
|
+
"""
|
|
46
|
+
if idempotent:
|
|
47
|
+
return status in (500, 502, 503, 504)
|
|
48
|
+
if status != 503:
|
|
49
|
+
return False
|
|
50
|
+
return error_type == 'rate-limit-unavailable' or NO_BACKEND_MARKER in (body_text or '')
|
|
51
|
+
|
|
52
|
+
|
|
27
53
|
def unwrap_data(value: Any) -> Any:
|
|
28
54
|
"""Unwrap Apify-style v2 response envelopes ({ data: ... })."""
|
|
29
55
|
if isinstance(value, list):
|
|
@@ -33,6 +59,35 @@ def unwrap_data(value: Any) -> Any:
|
|
|
33
59
|
return value
|
|
34
60
|
|
|
35
61
|
|
|
62
|
+
def _error_type(response: httpx.Response) -> Optional[str]:
|
|
63
|
+
try:
|
|
64
|
+
body = response.json()
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
if isinstance(body, dict) and isinstance(body.get('error'), dict):
|
|
68
|
+
t = body['error'].get('type')
|
|
69
|
+
return t if isinstance(t, str) else None
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def server_retry_delay(server_attempt: int, retry_after: Optional[str], now: Optional[float] = None) -> float:
|
|
74
|
+
"""Exponential backoff with jitter, in seconds; honours a Retry-After (seconds or HTTP date) of up to 30 s."""
|
|
75
|
+
if retry_after is not None and retry_after.strip():
|
|
76
|
+
secs: Optional[float] = None
|
|
77
|
+
try:
|
|
78
|
+
secs = float(retry_after)
|
|
79
|
+
except ValueError:
|
|
80
|
+
try:
|
|
81
|
+
from email.utils import parsedate_to_datetime
|
|
82
|
+
|
|
83
|
+
secs = max(0.0, parsedate_to_datetime(retry_after).timestamp() - (time.time() if now is None else now))
|
|
84
|
+
except Exception:
|
|
85
|
+
secs = None
|
|
86
|
+
if secs is not None and secs >= 0:
|
|
87
|
+
return min(secs, 30.0) + random.random() * 0.1
|
|
88
|
+
return min(8.0, 0.5 * 2 ** server_attempt) + random.random() * 0.25
|
|
89
|
+
|
|
90
|
+
|
|
36
91
|
class AdaptiveRateLimiter:
|
|
37
92
|
"""Adaptive throttling based on X-RateLimit-* headers (TS AdaptiveRateLimiter)."""
|
|
38
93
|
|
|
@@ -116,6 +171,10 @@ class ApiClient:
|
|
|
116
171
|
|
|
117
172
|
_rate_limiter = AdaptiveRateLimiter()
|
|
118
173
|
_max_retries = 3
|
|
174
|
+
# Retries while the API is briefly unavailable (503/5xx, connection
|
|
175
|
+
# refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
|
|
176
|
+
# covers a main redeploy.
|
|
177
|
+
_max_server_retries = 8
|
|
119
178
|
|
|
120
179
|
def __init__(
|
|
121
180
|
self,
|
|
@@ -198,6 +257,7 @@ class ApiClient:
|
|
|
198
257
|
headers: Optional[Dict[str, str]] = None,
|
|
199
258
|
raise_for_status: bool = True,
|
|
200
259
|
do_not_retry_timeouts: bool = False,
|
|
260
|
+
idempotent: Optional[bool] = None,
|
|
201
261
|
) -> httpx.Response:
|
|
202
262
|
"""Make an HTTP request with adaptive rate limiting and 429 retries."""
|
|
203
263
|
client = await self._ensure_client()
|
|
@@ -208,8 +268,11 @@ class ApiClient:
|
|
|
208
268
|
if json_data is not None and 'Content-Type' not in request_headers:
|
|
209
269
|
request_headers['Content-Type'] = 'application/json'
|
|
210
270
|
|
|
211
|
-
|
|
212
|
-
|
|
271
|
+
if idempotent is None:
|
|
272
|
+
idempotent = method.upper() in IDEMPOTENT_METHODS
|
|
273
|
+
attempt = 0 # timeouts and 429s
|
|
274
|
+
server_attempt = 0 # 503/5xx and connection failures (API briefly unavailable)
|
|
275
|
+
while True:
|
|
213
276
|
await self._rate_limiter.wait_for_slot()
|
|
214
277
|
try:
|
|
215
278
|
response = await client.request(
|
|
@@ -220,27 +283,59 @@ class ApiClient:
|
|
|
220
283
|
content=content,
|
|
221
284
|
headers=request_headers or None,
|
|
222
285
|
)
|
|
223
|
-
except httpx.TimeoutException
|
|
224
|
-
last_error = exc
|
|
286
|
+
except httpx.TimeoutException:
|
|
225
287
|
if do_not_retry_timeouts or attempt >= self._max_retries:
|
|
226
288
|
raise
|
|
227
|
-
|
|
289
|
+
attempt += 1
|
|
290
|
+
await asyncio.sleep(0.25 * attempt)
|
|
228
291
|
continue
|
|
292
|
+
except httpx.TransportError as exc:
|
|
293
|
+
# No response. A refused/unresolvable connection never reached
|
|
294
|
+
# the API, so it is safe for every method; anything else (reset
|
|
295
|
+
# mid-request) only for idempotent ones.
|
|
296
|
+
if (idempotent or isinstance(exc, httpx.ConnectError)) and server_attempt < self._max_server_retries:
|
|
297
|
+
wait = server_retry_delay(server_attempt, None)
|
|
298
|
+
server_attempt += 1
|
|
299
|
+
logger.warning(
|
|
300
|
+
'%s %s failed (%s); retry %d/%d in %.1fs',
|
|
301
|
+
method, path, type(exc).__name__, server_attempt, self._max_server_retries, wait,
|
|
302
|
+
)
|
|
303
|
+
await asyncio.sleep(wait)
|
|
304
|
+
continue
|
|
305
|
+
raise
|
|
229
306
|
|
|
230
307
|
self._rate_limiter.update_from_headers(response.headers)
|
|
231
308
|
|
|
232
309
|
if response.status_code == 429 and attempt < self._max_retries:
|
|
310
|
+
attempt += 1
|
|
233
311
|
await self._rate_limiter.handle_rate_limited(response)
|
|
234
312
|
continue
|
|
235
313
|
|
|
314
|
+
# API briefly unavailable (main redeploy, limiter store blip); see
|
|
315
|
+
# is_retryable_response for which answers are safe to re-send.
|
|
316
|
+
if (
|
|
317
|
+
not response.is_success
|
|
318
|
+
and server_attempt < self._max_server_retries
|
|
319
|
+
and is_retryable_response(
|
|
320
|
+
response.status_code,
|
|
321
|
+
idempotent,
|
|
322
|
+
_error_type(response) if response.status_code == 503 else None,
|
|
323
|
+
response.text if response.status_code == 503 else None,
|
|
324
|
+
)
|
|
325
|
+
):
|
|
326
|
+
wait = server_retry_delay(server_attempt, response.headers.get('Retry-After'))
|
|
327
|
+
server_attempt += 1
|
|
328
|
+
logger.warning(
|
|
329
|
+
'%s %s answered %d; retry %d/%d in %.1fs',
|
|
330
|
+
method, path, response.status_code, server_attempt, self._max_server_retries, wait,
|
|
331
|
+
)
|
|
332
|
+
await asyncio.sleep(wait)
|
|
333
|
+
continue
|
|
334
|
+
|
|
236
335
|
if raise_for_status:
|
|
237
336
|
await self._raise_for_status(response)
|
|
238
337
|
return response
|
|
239
338
|
|
|
240
|
-
if last_error is not None:
|
|
241
|
-
raise last_error
|
|
242
|
-
raise ApiClientError('Request failed after retries', 429)
|
|
243
|
-
|
|
244
339
|
async def get(
|
|
245
340
|
self,
|
|
246
341
|
path: str,
|
|
@@ -268,6 +363,7 @@ class ApiClient:
|
|
|
268
363
|
params: Optional[Dict[str, Any]] = None,
|
|
269
364
|
*,
|
|
270
365
|
raise_for_status: bool = True,
|
|
366
|
+
idempotent: Optional[bool] = None,
|
|
271
367
|
) -> httpx.Response:
|
|
272
368
|
return await self.request(
|
|
273
369
|
'POST',
|
|
@@ -277,6 +373,7 @@ class ApiClient:
|
|
|
277
373
|
content=content,
|
|
278
374
|
headers=headers,
|
|
279
375
|
raise_for_status=raise_for_status,
|
|
376
|
+
idempotent=idempotent,
|
|
280
377
|
)
|
|
281
378
|
|
|
282
379
|
async def put(
|
|
@@ -325,8 +422,10 @@ class ApiClient:
|
|
|
325
422
|
data: Any = None,
|
|
326
423
|
params: Optional[Dict[str, Any]] = None,
|
|
327
424
|
headers: Optional[Dict[str, str]] = None,
|
|
425
|
+
*,
|
|
426
|
+
idempotent: Optional[bool] = None,
|
|
328
427
|
) -> Any:
|
|
329
|
-
response = await self.post(path, json_data=data, params=params, headers=headers)
|
|
428
|
+
response = await self.post(path, json_data=data, params=params, headers=headers, idempotent=idempotent)
|
|
330
429
|
if not response.content:
|
|
331
430
|
return {}
|
|
332
431
|
try:
|
|
@@ -44,6 +44,7 @@ src/scrapely_python.egg-info/SOURCES.txt
|
|
|
44
44
|
src/scrapely_python.egg-info/dependency_links.txt
|
|
45
45
|
src/scrapely_python.egg-info/requires.txt
|
|
46
46
|
src/scrapely_python.egg-info/top_level.txt
|
|
47
|
+
tests/test_api_client_retries.py
|
|
47
48
|
tests/test_apify_parity.py
|
|
48
49
|
tests/test_browser_configuration.py
|
|
49
50
|
tests/test_charging_parity.py
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Storage calls ride out a briefly unavailable API.
|
|
2
|
+
|
|
3
|
+
Staging 2026-10-15: while main redeployed, storage calls got 503 "Rate limiter
|
|
4
|
+
is temporarily unavailable" (and refused connections) and runs failed, because
|
|
5
|
+
the client retried only 429s. 503 and refused connections now retry with
|
|
6
|
+
backoff for every method; 500/502/504 and mid-request transport errors only for
|
|
7
|
+
idempotent methods.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from scrapely.storage import api_client as mod
|
|
18
|
+
from scrapely.storage.api_client import ApiClient, ApiClientError, is_retryable_response, server_retry_delay
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
|
|
23
|
+
slept: List[float] = []
|
|
24
|
+
|
|
25
|
+
async def fake_sleep(secs: float) -> None:
|
|
26
|
+
slept.append(secs)
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(mod.asyncio, 'sleep', fake_sleep)
|
|
29
|
+
return slept
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def client_for(responses: list) -> tuple[ApiClient, list]:
|
|
33
|
+
calls: list = []
|
|
34
|
+
|
|
35
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
36
|
+
calls.append(request.method)
|
|
37
|
+
r = responses.pop(0) if len(responses) > 1 else responses[0]
|
|
38
|
+
if isinstance(r, Exception):
|
|
39
|
+
raise r
|
|
40
|
+
return r
|
|
41
|
+
|
|
42
|
+
return ApiClient('http://api.test', 'st_x', 'run1', transport=httpx.MockTransport(handler)), calls
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def unavailable() -> httpx.Response:
|
|
46
|
+
return httpx.Response(503, json={'error': {'type': 'rate-limit-unavailable', 'message': 'Rate limiter is temporarily unavailable'}})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def outcome_unknown() -> httpx.Response:
|
|
50
|
+
return httpx.Response(503, json={'error': {'type': 'service-unavailable', 'message': 'Dataset storage is temporarily unavailable, try again'}})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_post_not_resent_after_outcome_unknown_503() -> None:
|
|
54
|
+
c, calls = client_for([outcome_unknown(), httpx.Response(201, json={})])
|
|
55
|
+
with pytest.raises(ApiClientError) as e:
|
|
56
|
+
await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
|
|
57
|
+
assert e.value.status_code == 503
|
|
58
|
+
assert calls == ['POST'], 'the items may already be stored'
|
|
59
|
+
|
|
60
|
+
c, calls = client_for([outcome_unknown(), httpx.Response(200, json={})])
|
|
61
|
+
assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
|
|
62
|
+
assert len(calls) == 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_post_resent_after_proxy_503() -> None:
|
|
66
|
+
proxy = httpx.Response(503, text='<html>No server is available to handle this request.</html>', headers={'content-type': 'text/html'})
|
|
67
|
+
other = httpx.Response(503, text='<html>Service Unavailable</html>', headers={'content-type': 'text/html'})
|
|
68
|
+
c, calls = client_for([other, httpx.Response(201, json={})])
|
|
69
|
+
with pytest.raises(ApiClientError):
|
|
70
|
+
await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
|
|
71
|
+
assert len(calls) == 1, 'a non-JSON 503 without the no-backend marker may have been forwarded'
|
|
72
|
+
c, calls = client_for([proxy, httpx.Response(201, json={})])
|
|
73
|
+
assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{}])).status_code == 201
|
|
74
|
+
assert len(calls) == 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_record_read_with_raise_for_status_false_still_retries() -> None:
|
|
78
|
+
c, calls = client_for([unavailable(), httpx.Response(404, json={'error': {'message': 'Record not found'}})])
|
|
79
|
+
r = await c.request('GET', '/v2/key-value-stores/k/records/STATE', raise_for_status=False)
|
|
80
|
+
assert r.status_code == 404
|
|
81
|
+
assert len(calls) == 2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_post_retries_503_until_success(no_sleep: List[float]) -> None:
|
|
85
|
+
c, calls = client_for([unavailable(), unavailable(), httpx.Response(201, json={'data': {}})])
|
|
86
|
+
r = await c.request('POST', '/v2/datasets/d/items', json_data=[{'a': 1}])
|
|
87
|
+
assert r.status_code == 201
|
|
88
|
+
assert calls == ['POST'] * 3
|
|
89
|
+
backoff = [x for x in no_sleep if x >= 0.4] # the rate limiter's own pacing sleeps are shorter
|
|
90
|
+
assert len(backoff) == 2 and 0.5 <= backoff[0] < 0.8 and 1.0 <= backoff[1] < 1.3 # exponential
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_retry_after_is_honoured(no_sleep: List[float]) -> None:
|
|
94
|
+
c, _ = client_for([httpx.Response(503, headers={'Retry-After': '3'}), httpx.Response(200, json={})])
|
|
95
|
+
await c.request('GET', '/v2/key-value-stores/k/records/x')
|
|
96
|
+
assert any(3.0 <= x < 3.2 for x in no_sleep)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def test_gives_up_after_budget() -> None:
|
|
100
|
+
c, calls = client_for([unavailable()])
|
|
101
|
+
with pytest.raises(ApiClientError) as e:
|
|
102
|
+
await c.request('GET', '/v2/datasets/d')
|
|
103
|
+
assert e.value.status_code == 503
|
|
104
|
+
assert len(calls) == ApiClient._max_server_retries + 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_502_retried_for_get_not_for_post() -> None:
|
|
108
|
+
c, calls = client_for([httpx.Response(502), httpx.Response(200, json={})])
|
|
109
|
+
assert (await c.request('GET', '/v2/datasets/d')).status_code == 200
|
|
110
|
+
assert len(calls) == 2
|
|
111
|
+
|
|
112
|
+
c, calls = client_for([httpx.Response(502), httpx.Response(201, json={})])
|
|
113
|
+
with pytest.raises(ApiClientError) as e:
|
|
114
|
+
await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
|
|
115
|
+
assert e.value.status_code == 502
|
|
116
|
+
assert calls == ['POST'], 'a POST that may have been processed must not be re-sent'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_connection_refused_retried_for_every_method() -> None:
|
|
120
|
+
c, calls = client_for([httpx.ConnectError('refused'), httpx.Response(201, json={})])
|
|
121
|
+
assert (await c.request('POST', '/v2/datasets/d/items', json_data=[{}])).status_code == 201
|
|
122
|
+
assert len(calls) == 2
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_mid_request_reset_only_retried_when_idempotent() -> None:
|
|
126
|
+
c, calls = client_for([httpx.ReadError('reset'), httpx.Response(200, json={})])
|
|
127
|
+
assert (await c.request('GET', '/v2/key-value-stores/k/records/x')).status_code == 200
|
|
128
|
+
assert len(calls) == 2
|
|
129
|
+
|
|
130
|
+
# PUT is a billed KV write: not re-sent after a reset or a 502 (may have been applied) ...
|
|
131
|
+
c, calls = client_for([httpx.ReadError('reset'), httpx.Response(200, json={})])
|
|
132
|
+
with pytest.raises(httpx.ReadError):
|
|
133
|
+
await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')
|
|
134
|
+
assert len(calls) == 1
|
|
135
|
+
c, calls = client_for([httpx.Response(502), httpx.Response(200, json={})])
|
|
136
|
+
with pytest.raises(ApiClientError):
|
|
137
|
+
await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')
|
|
138
|
+
assert len(calls) == 1
|
|
139
|
+
# ... but is after the limiter refused it.
|
|
140
|
+
c, calls = client_for([unavailable(), httpx.Response(200, json={})])
|
|
141
|
+
assert (await c.request('PUT', '/v2/key-value-stores/k/records/x', content=b'x')).status_code == 200
|
|
142
|
+
assert len(calls) == 2
|
|
143
|
+
|
|
144
|
+
c, calls = client_for([httpx.ReadError('reset'), httpx.Response(201, json={})])
|
|
145
|
+
with pytest.raises(httpx.ReadError):
|
|
146
|
+
await c.request('POST', '/v2/datasets/d/items', json_data=[{}])
|
|
147
|
+
assert len(calls) == 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_client_errors_are_not_retried() -> None:
|
|
151
|
+
c, calls = client_for([httpx.Response(404, json={'error': {'message': 'nope'}})])
|
|
152
|
+
with pytest.raises(ApiClientError):
|
|
153
|
+
await c.request('GET', '/v2/datasets/missing')
|
|
154
|
+
assert len(calls) == 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_helpers() -> None:
|
|
158
|
+
assert is_retryable_response(503, True, 'service-unavailable')
|
|
159
|
+
assert not is_retryable_response(503, False, 'service-unavailable')
|
|
160
|
+
assert is_retryable_response(503, False, 'rate-limit-unavailable')
|
|
161
|
+
assert is_retryable_response(503, False, None, '<h1>503</h1> No server is available to handle this request.')
|
|
162
|
+
assert not is_retryable_response(503, False, None, '<html>Service Unavailable</html>')
|
|
163
|
+
assert is_retryable_response(504, True) and not is_retryable_response(504, False)
|
|
164
|
+
assert not is_retryable_response(400, True) and not is_retryable_response(501, True)
|
|
165
|
+
from email.utils import parsedate_to_datetime
|
|
166
|
+
now = parsedate_to_datetime('Thu, 15 Oct 2026 00:00:00 GMT').timestamp()
|
|
167
|
+
assert 5.0 <= server_retry_delay(0, 'Thu, 15 Oct 2026 00:00:05 GMT', now=now) < 5.2
|
|
168
|
+
assert 4.0 <= server_retry_delay(3, 'garbage') < 4.3
|
|
169
|
+
assert server_retry_delay(10, None) < 8.3
|
|
170
|
+
assert 30.0 <= server_retry_delay(0, '120') < 30.2
|
|
@@ -256,7 +256,11 @@ async def test_rq_reclaim_after_unclaimed_lock_expiry_persists_retry_count(trans
|
|
|
256
256
|
|
|
257
257
|
|
|
258
258
|
@pytest.mark.asyncio
|
|
259
|
-
async def test_rq_reclaim_still_raises_other_errors(transport, mock_api):
|
|
259
|
+
async def test_rq_reclaim_still_raises_other_errors(transport, mock_api, monkeypatch):
|
|
260
|
+
# A persistent 500 is raised once the server-error retries are spent (none here, to keep the test fast).
|
|
261
|
+
from scrapely.storage.api_client import ApiClient
|
|
262
|
+
|
|
263
|
+
monkeypatch.setattr(ApiClient, '_max_server_retries', 0)
|
|
260
264
|
client = ScrapelyStorageClient(
|
|
261
265
|
api_url='https://api.test.scrape.ly',
|
|
262
266
|
token='t',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/_file_system/_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely/storage/scrapely_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.4 → scrapely_python-0.1.5}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|