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 CHANGED
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.4'
135
+ __version__ = '0.1.5'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -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
- last_error: Optional[Exception] = None
212
- for attempt in range(self._max_retries + 1):
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 as exc:
224
- last_error = exc
286
+ except httpx.TimeoutException:
225
287
  if do_not_retry_timeouts or attempt >= self._max_retries:
226
288
  raise
227
- await asyncio.sleep(0.25 * (attempt + 1))
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- scrapely/__init__.py,sha256=nVwtGU9e9kfzoiX6MdfbK4U-p88J7eMDUkqNkpKY1Ug,5745
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=wrRxQ8h4jKdWY50HI0gXS-XHaFpzWPbVyLQ1nOYnsnA,11305
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.4.dist-info/METADATA,sha256=ACM2A5RD1zJ8JV3cEY0jZigW3dpvsjQyiwJBFvKlLv4,3104
41
- scrapely_python-0.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
42
- scrapely_python-0.1.4.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
43
- scrapely_python-0.1.4.dist-info/RECORD,,
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,,