scrapely-python 0.1.4__py3-none-any.whl → 0.1.5__py3-none-any.whl
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/__init__.py +1 -1
- scrapely/storage/api_client.py +109 -10
- {scrapely_python-0.1.4.dist-info → scrapely_python-0.1.5.dist-info}/METADATA +1 -1
- {scrapely_python-0.1.4.dist-info → scrapely_python-0.1.5.dist-info}/RECORD +6 -6
- {scrapely_python-0.1.4.dist-info → scrapely_python-0.1.5.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.4.dist-info → scrapely_python-0.1.5.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
scrapely/storage/api_client.py
CHANGED
|
@@ -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:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
scrapely/__init__.py,sha256=
|
|
1
|
+
scrapely/__init__.py,sha256=m8qsFMznobEHdjjlLu5KCP8Y1yKA3EM2pSoJJn-En_4,5745
|
|
2
2
|
scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
|
|
3
3
|
scrapely/actor.py,sha256=QczHOj2GVQnMbQZ_76vjjJNHxPrinWB_WCdZ6_41l00,75126
|
|
4
4
|
scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
|
|
@@ -22,7 +22,7 @@ scrapely/request_loaders/__init__.py,sha256=aNgr4pRt3MziUbU6UQ_bjTT3moMNvvyUsPeC
|
|
|
22
22
|
scrapely/request_loaders/_scrapely_request_list.py,sha256=cq7xx7Ai4qOhcqGV2TLqPR3o53FPCPKpVxWEMw5qKaM,7367
|
|
23
23
|
scrapely/storage/__init__.py,sha256=diAhxEvOXjc3YVhZvzkO3MttJUXQWJ5ogxstiApsh7U,1405
|
|
24
24
|
scrapely/storage/_metadata.py,sha256=WWawBnVN0b1iK2K-SlxZXUc6czb5wXWf8iC_0tzkgAQ,4764
|
|
25
|
-
scrapely/storage/api_client.py,sha256=
|
|
25
|
+
scrapely/storage/api_client.py,sha256=q48maCfgEnyWGD_gIhehiBvA30AlbqKkNjouNu6BIa4,15870
|
|
26
26
|
scrapely/storage/client.py,sha256=M3o_a_9azHNn6eL_UyEeVz-VQJvnT_th1HJPuCA4PkE,21746
|
|
27
27
|
scrapely/storage/patch_storage.py,sha256=o3Fv0BByeZ1T9HhkzhY6bjolOFsIQcnqBoHq1EdG8Fw,8758
|
|
28
28
|
scrapely/storage/scrapely_dataset_client.py,sha256=gRvlCDw9c0yO5GE5LQOa1xAyXc9pnb-E37Eq7fHqHCM,10338
|
|
@@ -37,7 +37,7 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=4Ipgbh4qEqAkwQJ9
|
|
|
37
37
|
scrapely/storage/_file_system/_storage_client.py,sha256=aaGJ4P0QAtIgEMl6yHpuobeWFD5M4rMC5HwhOOvuGuo,1794
|
|
38
38
|
scrapely/storage_clients/__init__.py,sha256=cs1YX7JV2_GKBXeiiJgkQFali08b5_y_jH6Yw7iROAA,656
|
|
39
39
|
scrapely/storages/__init__.py,sha256=4PAELhJNZ3exJ7Uouicn-bCQ58NafKjhLBixQ8ATQc4,173
|
|
40
|
-
scrapely_python-0.1.
|
|
41
|
-
scrapely_python-0.1.
|
|
42
|
-
scrapely_python-0.1.
|
|
43
|
-
scrapely_python-0.1.
|
|
40
|
+
scrapely_python-0.1.5.dist-info/METADATA,sha256=9Jl9D1ijxCxthjVQY0YB3ryI6jipYzXONbjyOjA8YEs,3104
|
|
41
|
+
scrapely_python-0.1.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
42
|
+
scrapely_python-0.1.5.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
|
|
43
|
+
scrapely_python-0.1.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|