scrapely-python 0.1.3__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.3'
135
+ __version__ = '0.1.5'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
scrapely/charging.py CHANGED
@@ -199,7 +199,7 @@ class ChargingManager:
199
199
  # (push_data_and_charge voids it when the push then fails).
200
200
  self._last_accepted_operation_id: Optional[str] = None
201
201
  # Voids not yet confirmed by the platform; retried before the next push and at exit.
202
- self._pending_voids: List[tuple[str, str, int]] = []
202
+ self._pending_voids: List[tuple[str, str, int, bool]] = []
203
203
 
204
204
  def close(self) -> None:
205
205
  """Mark the manager closed; subsequent charge/push operations are rejected."""
@@ -439,7 +439,18 @@ class ChargingManager:
439
439
  self._last_accepted_operation_id = None
440
440
  return op_id
441
441
 
442
- async def _void_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
442
+ async def _cancel_uncertain_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
443
+ """Cancel an unconfirmed charge for items that were then not stored.
444
+
445
+ Drops the pending intent (so the exit flush never posts it) and voids it in
446
+ case the original request did apply; a 404 means it never applied.
447
+ """
448
+ self._drop_pending(operation_id)
449
+ return await self._void_charge(event_name, operation_id, reserved, not_found_means_voided=True)
450
+
451
+ async def _void_charge(
452
+ self, event_name: str, operation_id: str, reserved: int, not_found_means_voided: bool = False
453
+ ) -> bool:
443
454
  """Reverse an accepted charge whose items were never stored, then unwind locally.
444
455
 
445
456
  POST /v2/actor-runs/{run}/charge/void with the charge's Idempotency-Key.
@@ -458,13 +469,19 @@ class ChargingManager:
458
469
  return True
459
470
  except Exception as err: # noqa: BLE001
460
471
  status = _http_status_of(err)
472
+ # Only the void route's own answer proves it never applied; a server
473
+ # without the route (older deployment) also answers 404.
474
+ if status == 404 and not_found_means_voided and getattr(err, 'type', None) == 'charge-not-found':
475
+ # The unconfirmed charge never reached the ledger: nothing billed.
476
+ self._unwind_reservation(event_name, reserved)
477
+ return True
461
478
  # 404: nothing to void; 409: already settled; 4xx auth: permanent.
462
479
  if status is not None and 400 <= status < 500:
463
480
  return False
464
481
  if attempt + 1 < self._POST_ATTEMPTS:
465
482
  await asyncio.sleep(0.25 * (attempt + 1))
466
483
  # Outcome unknown: keep the reservation and retry before the next push / at exit.
467
- self._pending_voids.append((event_name, operation_id, reserved))
484
+ self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
468
485
  logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
469
486
  return False
470
487
 
@@ -474,8 +491,8 @@ class ChargingManager:
474
491
  Returns False while any earlier void is still unconfirmed.
475
492
  """
476
493
  pending, self._pending_voids = self._pending_voids, []
477
- for event_name, operation_id, reserved in pending:
478
- await self._void_charge(event_name, operation_id, reserved)
494
+ for event_name, operation_id, reserved, not_found_means_voided in pending:
495
+ await self._void_charge(event_name, operation_id, reserved, not_found_means_voided)
479
496
  return not self._pending_voids
480
497
 
481
498
  def _quarantine_stale_pending(self) -> None:
@@ -1158,19 +1175,39 @@ async def push_data_and_charge(
1158
1175
  # Synthetic storage event first (local-only); then explicit billable event.
1159
1176
  reserved_in_op: list[tuple[str, int]] = []
1160
1177
  operation_ids: list[Optional[str]] = []
1178
+ uncertain_ops: set[str] = set()
1179
+
1180
+ async def charge_one(name: str) -> None:
1181
+ charging_manager._take_last_accepted_operation_id()
1182
+ try:
1183
+ r = await charging_manager._charge_exclusive(name, len(limited_items))
1184
+ op_id = charging_manager._take_last_accepted_operation_id()
1185
+ except ChargeUncertainError as unconfirmed:
1186
+ # The platform did not confirm the charge (5xx / timeouts after
1187
+ # retries). Never drop the items for that: the intent stays
1188
+ # pending with its idempotency key and is re-posted by the exit
1189
+ # flush, so the items are stored now and billed once.
1190
+ logger.warning(
1191
+ 'Charge for %r not confirmed yet (%s); storing the items and retrying the charge later',
1192
+ name,
1193
+ unconfirmed.operation_id,
1194
+ )
1195
+ r = ChargeResult(
1196
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1197
+ charged_count=unconfirmed.count,
1198
+ event_charge_limit_reached=False,
1199
+ )
1200
+ op_id = unconfirmed.operation_id
1201
+ uncertain_ops.add(op_id)
1202
+ results.append(r)
1203
+ reserved_in_op.append((name, r.charged_count))
1204
+ operation_ids.append(op_id)
1205
+
1161
1206
  try:
1162
1207
  if default_event:
1163
- charging_manager._take_last_accepted_operation_id()
1164
- r = await charging_manager._charge_exclusive(default_event, len(limited_items))
1165
- results.append(r)
1166
- reserved_in_op.append((default_event, r.charged_count))
1167
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1208
+ await charge_one(default_event)
1168
1209
  if event_name and event_name != default_event:
1169
- charging_manager._take_last_accepted_operation_id()
1170
- r = await charging_manager._charge_exclusive(event_name, len(limited_items))
1171
- results.append(r)
1172
- reserved_in_op.append((event_name, r.charged_count))
1173
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1210
+ await charge_one(event_name)
1174
1211
  except Exception:
1175
1212
  for name, reserved in reserved_in_op:
1176
1213
  if reserved > 0:
@@ -1192,11 +1229,31 @@ async def push_data_and_charge(
1192
1229
  )
1193
1230
  # Unwind any event that reserved more than we will persist
1194
1231
  # (synthetic default-dataset is local-only and otherwise sticks).
1232
+ platform_op_names = {
1233
+ name for (name, _), op_id in zip(reserved_in_op, operation_ids) if op_id
1234
+ }
1195
1235
  for name, result in charged_events:
1196
1236
  if result.charged_count > accepted_for_items:
1237
+ # Nothing stored and a platform charge was made: the void
1238
+ # below releases its local count once the platform confirms.
1239
+ if accepted_for_items <= 0 and name in platform_op_names:
1240
+ continue
1197
1241
  charging_manager._unwind_reservation(
1198
1242
  name, result.charged_count - accepted_for_items
1199
1243
  )
1244
+ if accepted_for_items <= 0:
1245
+ # Nothing will be stored (another event of this push was capped
1246
+ # to zero): undo every platform charge this operation made, so
1247
+ # no result is billed for items that are never saved. Local
1248
+ # counts were unwound just above.
1249
+ for (name, reserved), op_id in zip(reserved_in_op, operation_ids):
1250
+ if reserved <= 0 or not op_id:
1251
+ continue
1252
+ # Original count: released only when the platform confirms.
1253
+ if op_id in uncertain_ops:
1254
+ await charging_manager._cancel_uncertain_charge(name, op_id, reserved)
1255
+ else:
1256
+ await charging_manager._void_charge(name, op_id, reserved)
1200
1257
  if accepted_for_items > 0:
1201
1258
  try:
1202
1259
  await push_fn(limited_items[:accepted_for_items])
@@ -1218,7 +1275,10 @@ async def push_data_and_charge(
1218
1275
  kept = min(accepted_for_items, next(r.charged_count for n, r in charged_events if n == name))
1219
1276
  if kept <= 0:
1220
1277
  continue
1221
- if op_id:
1278
+ if op_id and op_id in uncertain_ops:
1279
+ # Never re-post a charge for items that were not stored.
1280
+ await charging_manager._cancel_uncertain_charge(name, op_id, kept)
1281
+ elif op_id:
1222
1282
  await charging_manager._void_charge(name, op_id, kept)
1223
1283
  elif is_default_dataset_item_event(name):
1224
1284
  charging_manager._unwind_reservation(name, kept)
@@ -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.3
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,8 +1,8 @@
1
- scrapely/__init__.py,sha256=vJx9b5JOO4ehkpvB03Xjoj7dCIzw4lgLggG-0jWrkM0,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
5
- scrapely/charging.py,sha256=eoOWnbtUPcfuh96Ss6XZmOveLInprs6EjSAtrGrCd9A,53364
5
+ scrapely/charging.py,sha256=MFz_Z4qJ-N3qLffleQb2BxjomygM3UMvHSBw1HabG80,56857
6
6
  scrapely/charging_client.py,sha256=tDtlPwLSVFvoRa2NBgNjyfHkAUM77SSIyYT2ACgpDm8,3710
7
7
  scrapely/configuration.py,sha256=YUnbft9JeWlVTgWalr-CgJCiyJ_e_rgoQItVPvjeWRs,15406
8
8
  scrapely/errors.py,sha256=DCAlVvE3jToZuPEG2HVDL7i8D8hLboomQ5JpSOcsXoQ,4107
@@ -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.3.dist-info/METADATA,sha256=pp3DbPQObzUEt1NXKP6prb8mCreU6crymE1QG2-EFSM,3104
41
- scrapely_python-0.1.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
42
- scrapely_python-0.1.3.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
43
- scrapely_python-0.1.3.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,,