scrapely-python 0.1.10__py3-none-any.whl → 0.1.12__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.10'
135
+ __version__ = '0.1.12'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
scrapely/actor.py CHANGED
@@ -24,7 +24,12 @@ from crawlee.configuration import Configuration as CrawleeConfiguration
24
24
  from crawlee.storage_clients._base import StorageClient
25
25
  from crawlee.storages import Dataset, KeyValueStore, RequestQueue
26
26
 
27
- from .charging import ChargingManager, is_default_dataset_item_event, push_data_and_charge
27
+ from .charging import (
28
+ ChargingManager,
29
+ is_default_dataset_item_event,
30
+ push_data_and_charge,
31
+ set_active_charging_manager,
32
+ )
28
33
  from .configuration import Configuration
29
34
  from .crypto import decrypt_secret_inputs
30
35
  from .proxy_configuration import with_proxy_key_aliases
@@ -486,7 +491,7 @@ class Actor(metaclass=_ActorMeta):
486
491
  if flush is not None:
487
492
  deadline_ms = min(max(1, timeout_secs) * 1000, 30_000)
488
493
  # The flush's own deadline ends its retries (charge POSTs back off
489
- # for up to ~45 s) a little before wait_for would cancel it.
494
+ # for up to ~2 min) a little before wait_for would cancel it.
490
495
  await asyncio.wait_for(
491
496
  flush(max_intents=64, deadline_ms=max(500, deadline_ms - 2_000)),
492
497
  timeout=max(1.0, deadline_ms / 1000.0),
@@ -515,6 +520,7 @@ class Actor(metaclass=_ActorMeta):
515
520
  # Clear charging + patched client so a second Actor lifecycle cannot
516
521
  # post charges to the previous run_id / credentials.
517
522
  cls._charging_manager = None
523
+ set_active_charging_manager(None)
518
524
  cls._apify_client_instance = None
519
525
 
520
526
  cls._default_dataset = None
@@ -1061,12 +1067,15 @@ class Actor(metaclass=_ActorMeta):
1061
1067
  return None
1062
1068
 
1063
1069
  client = cls.new_client(token=token, base_url=api_url)
1070
+ # Setting the message is idempotent: a 429 (run API rate limit), 5xx or
1071
+ # dropped connection is retried with backoff instead of failing the run.
1064
1072
  response = await client.put(
1065
1073
  f'/v2/actor-runs/{run_id}',
1066
1074
  json_data={
1067
1075
  'statusMessage': message,
1068
1076
  'isStatusMessageTerminal': is_terminal,
1069
1077
  },
1078
+ idempotent=True,
1070
1079
  )
1071
1080
  body: Any = {}
1072
1081
  if response.content:
@@ -1222,6 +1231,8 @@ class Actor(metaclass=_ActorMeta):
1222
1231
  # Prefer the storage factory client (same URL/token/run as API storage).
1223
1232
  client = cls._client if cls._client is not None else cls.new_client()
1224
1233
  cls._charging_manager = ChargingManager(config, client)
1234
+ # Storage clients refuse default-dataset writes past this run's limit.
1235
+ set_active_charging_manager(cls._charging_manager)
1225
1236
  return cls._charging_manager
1226
1237
 
1227
1238
 
scrapely/charging.py CHANGED
@@ -9,7 +9,9 @@ never treated as retries of a previous operation.
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import contextvars
12
13
  import json
14
+ import os
13
15
  import logging
14
16
  import time
15
17
  import uuid
@@ -47,22 +49,76 @@ CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
47
49
  # Soft cap on open unresolved intents. Refuse NEW billable charges when full.
48
50
  _PENDING_MAX_ENTRIES = 1024
49
51
 
50
- # Same-key POST attempts before leaving an intent pending / raising. The API
51
- # client itself retries 5xx answers and refused or reset connections of these
52
- # POSTs with its long backoff (0.5 s doubling to 8 s, about 45 s): re-sending
52
+ # Same-key POST attempts before leaving an intent pending / re-queueing a void.
53
+ # The API client itself retries 429 and 5xx answers and refused or reset
54
+ # connections of these POSTs with exponential backoff (honouring Retry-After)
55
+ # for its retry window (2 minutes, SCRAPELY_API_RETRY_WINDOW_SECS): re-sending
53
56
  # is safe because every attempt carries the same Idempotency-Key. These outer
54
57
  # attempts cover what the client gives up on quickly (timeouts, other
55
- # transport errors), with the same backoff, within _RETRY_BUDGET_SECS.
58
+ # transport errors), within _RETRY_BUDGET_SECS. Then the charge stays pending
59
+ # (the void is re-queued) and is retried before the next push and at exit,
60
+ # the same as the JS SDK.
56
61
  _POST_ATTEMPTS = 8
57
62
 
58
- # Outer retries stop once this much time has passed since the first attempt.
59
- _RETRY_BUDGET_SECS = 60.0
63
+ # Outer retries stop once this much time has passed since the first attempt
64
+ # (the API retry window; JS SDK parity).
65
+ _RETRY_BUDGET_SECS = 120.0
66
+
67
+
68
+ def _is_transient_status(status: Optional[int]) -> bool:
69
+ """408/429 and 5xx: the platform could not take the request now; try again later."""
70
+ return status is not None and (status in (408, 429) or status >= 500)
71
+
60
72
 
61
73
  # Local-only dataset collecting one entry per logical charge operation
62
74
  # (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
63
75
  LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log'
64
76
 
65
77
 
78
+ # Charge-limit safety net for the default dataset.
79
+ #
80
+ # Once a pay-per-event run has used up its spending limit (maxTotalChargeUsd),
81
+ # rows written to the run's default dataset would go out unbilled. The SDK then
82
+ # refuses such writes: nothing is stored, one warning is logged and
83
+ # ``ChargingManager.is_charge_limit_reached()`` turns True. Pushes made by
84
+ # ``push_data_and_charge`` (``Actor.push_data``) are decided there and marked
85
+ # with this context variable, so the storage client does not check them twice.
86
+ _charge_gated_push: contextvars.ContextVar[bool] = contextvars.ContextVar(
87
+ 'scrapely_charge_gated_push', default=False
88
+ )
89
+
90
+ # The charging manager of the running Actor (set by ``Actor``); storage clients
91
+ # consult it before writing to the default dataset.
92
+ _active_charging_manager: Optional['ChargingManager'] = None
93
+
94
+
95
+ def set_active_charging_manager(manager: Optional['ChargingManager']) -> None:
96
+ """Register the charging manager whose limit guards default-dataset writes."""
97
+ global _active_charging_manager
98
+ _active_charging_manager = manager
99
+
100
+
101
+ def default_dataset_id_from_env() -> Optional[str]:
102
+ """The run's default dataset ID, as the platform passes it."""
103
+ return os.environ.get('ACTOR_DEFAULT_DATASET_ID') or os.environ.get('APIFY_DEFAULT_DATASET_ID')
104
+
105
+
106
+ def refuse_default_dataset_push(item_count: int) -> bool:
107
+ """True when a write of ``item_count`` rows to the default dataset must be dropped.
108
+
109
+ Called by storage clients for writes that did not go through
110
+ ``push_data_and_charge`` (``Actor.open_dataset().push_data()``, crawler
111
+ ``context.push_data``, ...). Non-PPE runs and runs below their limit are
112
+ never refused.
113
+ """
114
+ if _charge_gated_push.get():
115
+ return False
116
+ manager = _active_charging_manager
117
+ if manager is None:
118
+ return False
119
+ return manager._refuse_default_dataset_push(item_count)
120
+
121
+
66
122
  def is_default_dataset_item_event(event_name: str) -> bool:
67
123
  """Check if event name is a default dataset item event."""
68
124
  return event_name in (APIFY_DEFAULT_DATASET_ITEM_EVENT, SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
@@ -209,10 +265,69 @@ class ChargingManager:
209
265
  self._last_accepted_operation_id: Optional[str] = None
210
266
  # Voids not yet confirmed by the platform; retried before the next push and at exit.
211
267
  self._pending_voids: List[tuple[str, str, int, bool]] = []
268
+ # Sticky: the platform refused or capped a charge (HTTP 402 / fewer
269
+ # events accepted) or the local budget gate trimmed one.
270
+ self._charge_limit_hit = False
271
+ # Default-dataset rows refused after the limit (one warning, then a count).
272
+ self._refused_default_dataset_items = 0
212
273
 
213
274
  def close(self) -> None:
214
275
  """Mark the manager closed; subsequent charge/push operations are rejected."""
215
276
  self._closed = True
277
+ if self._refused_default_dataset_items:
278
+ logger.warning(
279
+ '%s result(s) pushed to the default dataset after the spending limit was '
280
+ 'reached were not saved',
281
+ self._refused_default_dataset_items,
282
+ )
283
+
284
+ def is_charge_limit_reached(self) -> bool:
285
+ """Whether this pay-per-event run has used up its spending limit.
286
+
287
+ True once a charge was refused or capped by the limit (the platform's
288
+ HTTP 402 or a partial acceptance, or the local budget gate), or once the
289
+ remaining budget cannot pay for even one event of the cheapest priced
290
+ type. From then on, pushes to the default dataset that are not paid
291
+ for are refused. Always False for actors without pay-per-event
292
+ pricing, without a limit, or with only free events.
293
+ """
294
+ if self._pricing_info is None:
295
+ return False
296
+ if self._charge_limit_hit:
297
+ return True
298
+ if self._max_total_charge_usd is None:
299
+ return False
300
+ prices = [price for price in self._per_event_prices.values() if price > 0]
301
+ if not prices:
302
+ return False
303
+ return self._max_total_charge_usd - self._total_charged_usd < min(prices)
304
+
305
+ def _mark_charge_limit_hit(self) -> None:
306
+ if self._pricing_info is not None:
307
+ self._charge_limit_hit = True
308
+
309
+ def _note_refused_default_dataset_items(self, count: int) -> None:
310
+ """Count rows dropped because of the limit; warn on the first one only."""
311
+ if count <= 0:
312
+ return
313
+ if not self._refused_default_dataset_items:
314
+ limit = (
315
+ f'${self._max_total_charge_usd}' if self._max_total_charge_usd is not None else 'the limit'
316
+ )
317
+ logger.warning(
318
+ 'The spending limit of this run (maxTotalChargeUsd = %s) has been reached: '
319
+ 'results pushed to the default dataset are no longer saved. The Actor should '
320
+ 'stop now (check ChargeResult.event_charge_limit_reached or '
321
+ 'Actor.get_charging_manager().is_charge_limit_reached()).',
322
+ limit,
323
+ )
324
+ self._refused_default_dataset_items += count
325
+
326
+ def _refuse_default_dataset_push(self, item_count: int) -> bool:
327
+ if item_count <= 0 or not self.is_charge_limit_reached():
328
+ return False
329
+ self._note_refused_default_dataset_items(item_count)
330
+ return True
216
331
 
217
332
  @property
218
333
  def charge_lock(self) -> RLock:
@@ -458,7 +573,12 @@ class ChargingManager:
458
573
  return await self._void_charge(event_name, operation_id, reserved, not_found_means_voided=True)
459
574
 
460
575
  async def _void_charge(
461
- self, event_name: str, operation_id: str, reserved: int, not_found_means_voided: bool = False
576
+ self,
577
+ event_name: str,
578
+ operation_id: str,
579
+ reserved: int,
580
+ not_found_means_voided: bool = False,
581
+ deadline: Optional[float] = None,
462
582
  ) -> bool:
463
583
  """Reverse an accepted charge whose items were never stored, then unwind locally.
464
584
 
@@ -468,6 +588,8 @@ class ChargingManager:
468
588
  if self._client is None or not self._is_at_home or not self._actor_run_id:
469
589
  return False
470
590
  started = time.monotonic()
591
+ budget_end = started + self._RETRY_BUDGET_SECS
592
+ deadline = budget_end if deadline is None else min(deadline, budget_end)
471
593
  for attempt in range(self._POST_ATTEMPTS):
472
594
  try:
473
595
  await self._client.post(
@@ -475,6 +597,7 @@ class ChargingManager:
475
597
  json_data={},
476
598
  headers={'Idempotency-Key': operation_id},
477
599
  idempotent=True,
600
+ retry_deadline=deadline,
478
601
  )
479
602
  self._unwind_reservation(event_name, reserved)
480
603
  return True
@@ -487,24 +610,37 @@ class ChargingManager:
487
610
  self._unwind_reservation(event_name, reserved)
488
611
  return True
489
612
  # 404: nothing to void; 409: already settled; 4xx auth: permanent.
490
- if status is not None and 400 <= status < 500:
613
+ # 408/429 (rate limited) and 5xx are not answers: re-queue below.
614
+ if status is not None and 400 <= status < 500 and not _is_transient_status(status):
491
615
  return False
492
- if not await self._backoff_before_retry(attempt, started, err):
616
+ if not await self._backoff_before_retry(attempt, started, err, deadline):
493
617
  break
494
618
  # Outcome unknown: keep the reservation and retry before the next push / at exit.
495
619
  self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
496
620
  logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
497
621
  return False
498
622
 
499
- async def _retry_pending_voids(self) -> bool:
623
+ async def _retry_pending_voids(self, deadline: Optional[float] = None) -> bool:
500
624
  """Retry unconfirmed voids (caller holds the operation lock).
501
625
 
502
- Returns False while any earlier void is still unconfirmed.
626
+ With ``deadline`` (``time.monotonic()``, the exit flush) keeps retrying
627
+ with backoff while time is left. Returns False while any earlier void
628
+ is still unconfirmed.
503
629
  """
504
- pending, self._pending_voids = self._pending_voids, []
505
- for event_name, operation_id, reserved, not_found_means_voided in pending:
506
- await self._void_charge(event_name, operation_id, reserved, not_found_means_voided)
507
- return not self._pending_voids
630
+ from .storage.api_client import server_retry_delay
631
+
632
+ round_ = 0
633
+ while True:
634
+ pending, self._pending_voids = self._pending_voids, []
635
+ for event_name, operation_id, reserved, not_found_means_voided in pending:
636
+ await self._void_charge(event_name, operation_id, reserved, not_found_means_voided, deadline)
637
+ if not self._pending_voids:
638
+ return True
639
+ left = 0.0 if deadline is None else deadline - time.monotonic()
640
+ if left <= 0:
641
+ return False
642
+ await asyncio.sleep(min(server_retry_delay(round_, None), left))
643
+ round_ += 1
508
644
 
509
645
  def _quarantine_stale_pending(self) -> None:
510
646
  """Remove intents older than the safe replay window (fail closed, no replay).
@@ -551,7 +687,7 @@ class ChargingManager:
551
687
  },
552
688
  headers={'Idempotency-Key': idempotency_key},
553
689
  idempotent=True,
554
- **({'retry_deadline': deadline} if deadline is not None else {}),
690
+ retry_deadline=deadline,
555
691
  )
556
692
  if isinstance(response, dict):
557
693
  return response
@@ -569,17 +705,17 @@ class ChargingManager:
569
705
  ) -> bool:
570
706
  """Sleep before the next same-key attempt; False when no attempt is left.
571
707
 
572
- A 5xx answer reaching this point was already retried by the API client
573
- with its long backoff, so it ends the attempts. Timeouts and other
574
- errors without an answer back off the same way (0.5 s doubling to 8 s)
575
- while attempts and the retry budget last.
708
+ A 429/5xx answer reaching this point was already retried by the API
709
+ client for the whole budget, so it ends the attempts. Timeouts, 408 and
710
+ other errors back off the same way while attempts and the retry budget
711
+ last.
576
712
  """
577
713
  from .storage.api_client import server_retry_delay
578
714
 
579
715
  if attempt + 1 >= self._POST_ATTEMPTS:
580
716
  return False
581
717
  status = _http_status_of(err)
582
- if status is not None and status >= 500:
718
+ if status is not None and (status == 429 or status >= 500):
583
719
  return False
584
720
  delay = server_retry_delay(attempt, None)
585
721
  if time.monotonic() - started + delay > self._RETRY_BUDGET_SECS:
@@ -609,6 +745,9 @@ class ChargingManager:
609
745
  """
610
746
  last_error: Optional[BaseException] = None
611
747
  started = time.monotonic()
748
+ # The client's own retries count against the same budget.
749
+ budget_end = started + self._RETRY_BUDGET_SECS
750
+ deadline = budget_end if deadline is None else min(deadline, budget_end)
612
751
  for attempt in range(self._POST_ATTEMPTS):
613
752
  try:
614
753
  body = await self._post_charge_once(
@@ -657,11 +796,10 @@ class ChargingManager:
657
796
  """Caller must hold _operation_lock."""
658
797
  if not self._is_at_home or self._client is None or not self._actor_run_id:
659
798
  return 0
660
- await self._retry_pending_voids()
661
-
662
- import time
663
-
664
799
  deadline = None if deadline_ms is None else (time.monotonic() + max(0.0, deadline_ms) / 1000.0)
800
+ # Voids first: an unconfirmed one means the user may be billed for items
801
+ # that were never stored. At exit (with a deadline) keep retrying them.
802
+ await self._retry_pending_voids(deadline)
665
803
  confirmed_total = 0
666
804
  async with self._flush_lock:
667
805
  while True:
@@ -688,6 +826,8 @@ class ChargingManager:
688
826
  deadline=deadline,
689
827
  )
690
828
  except ChargeRejectedError as err:
829
+ if _is_charge_limit_rejection(err):
830
+ self._mark_charge_limit_hit()
691
831
  self._unwind_reservation(event_name, count)
692
832
  self._drop_pending(idempotency_key)
693
833
  logger.warning(
@@ -699,6 +839,7 @@ class ChargingManager:
699
839
  continue
700
840
  if accepted is not None:
701
841
  if accepted < count:
842
+ self._mark_charge_limit_hit()
702
843
  self._unwind_reservation(event_name, count - accepted)
703
844
  self._drop_pending(idempotency_key)
704
845
  confirmed += 1
@@ -784,6 +925,9 @@ class ChargingManager:
784
925
  else 0
785
926
  )
786
927
 
928
+ if chargeable_count < count:
929
+ self._mark_charge_limit_hit()
930
+
787
931
  if chargeable_count <= 0:
788
932
  return ChargeResult(
789
933
  chargeable_within_limit=self._compute_chargeable(),
@@ -835,7 +979,9 @@ class ChargingManager:
835
979
  count=chargeable_count,
836
980
  idempotency_key=idempotency_key,
837
981
  )
838
- except ChargeRejectedError:
982
+ except ChargeRejectedError as rejected:
983
+ if _is_charge_limit_rejection(rejected):
984
+ self._mark_charge_limit_hit()
839
985
  # Server definitively rejected: unwind local reservation.
840
986
  self._drop_pending(idempotency_key)
841
987
  self._unwind_reservation(event_name, chargeable_count)
@@ -849,6 +995,8 @@ class ChargingManager:
849
995
  if accepted > 0:
850
996
  self._last_accepted_operation_id = idempotency_key
851
997
  if accepted < chargeable_count:
998
+ # The platform capped the charge at the run's limit.
999
+ self._mark_charge_limit_hit()
852
1000
  self._unwind_reservation(event_name, chargeable_count - accepted)
853
1001
  chargeable_count = accepted
854
1002
  result = ChargeResult(
@@ -1066,6 +1214,15 @@ class ChargingManager:
1066
1214
 
1067
1215
 
1068
1216
 
1217
+ def _is_charge_limit_rejection(err: ChargeRejectedError) -> bool:
1218
+ """Whether a rejected charge was refused because the run's limit is used up."""
1219
+ return (
1220
+ err.status_code == 402
1221
+ or err.reason == 'charge-limit-exceeded'
1222
+ or 'charge-limit-exceeded' in str(err).lower()
1223
+ )
1224
+
1225
+
1069
1226
  def _read_accepted_count(body: Any, fallback: int) -> int:
1070
1227
  if not isinstance(body, dict):
1071
1228
  return fallback
@@ -1166,6 +1323,24 @@ async def push_data_and_charge(
1166
1323
  if fallback_event:
1167
1324
  resolved_event = fallback_event
1168
1325
 
1326
+ # Charge-limit safety net: once the run's spending limit is used up, a
1327
+ # default-dataset push that does not pay per row (no event, or only
1328
+ # free events) stores nothing. Paid pushes go on below, where the
1329
+ # budget cap stores only the rows that are actually charged.
1330
+ if (
1331
+ is_ppe
1332
+ and is_default_dataset
1333
+ and items
1334
+ and charging_manager.is_charge_limit_reached()
1335
+ and not _push_is_paid_per_item(charging_manager, event_name, resolved_event)
1336
+ ):
1337
+ charging_manager._note_refused_default_dataset_items(len(items))
1338
+ return ChargeResult(
1339
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1340
+ charged_count=0,
1341
+ event_charge_limit_reached=True,
1342
+ )
1343
+
1169
1344
  limited_count = len(items)
1170
1345
  if is_ppe and resolved_event:
1171
1346
  # Keep the explicit event separate from the implicit default-dataset
@@ -1177,6 +1352,10 @@ async def push_data_and_charge(
1177
1352
  )
1178
1353
 
1179
1354
  limited_items = items[:limited_count] if limited_count < len(items) else items
1355
+ if is_ppe and is_default_dataset and limited_count < len(items):
1356
+ # Rows beyond what the budget can pay for are dropped.
1357
+ charging_manager._mark_charge_limit_hit()
1358
+ charging_manager._note_refused_default_dataset_items(len(items) - limited_count)
1180
1359
 
1181
1360
  if not limited_items:
1182
1361
  return ChargeResult(
@@ -1302,9 +1481,13 @@ async def push_data_and_charge(
1302
1481
  await charging_manager._cancel_uncertain_charge(name, op_id, reserved)
1303
1482
  else:
1304
1483
  await charging_manager._void_charge(name, op_id, reserved)
1484
+ if is_default_dataset and accepted_for_items < len(limited_items):
1485
+ charging_manager._note_refused_default_dataset_items(
1486
+ len(limited_items) - accepted_for_items
1487
+ )
1305
1488
  if accepted_for_items > 0:
1306
1489
  try:
1307
- await push_fn(limited_items[:accepted_for_items])
1490
+ await _gated_push(push_fn, limited_items[:accepted_for_items])
1308
1491
  except Exception as push_err:
1309
1492
  # A result is billed only if it was saved: void this
1310
1493
  # operation's platform charges and unwind local
@@ -1344,7 +1527,7 @@ async def push_data_and_charge(
1344
1527
 
1345
1528
 
1346
1529
  # No billable events — still persist the budget-limited items.
1347
- await push_fn(limited_items)
1530
+ await _gated_push(push_fn, limited_items)
1348
1531
  return ChargeResult(
1349
1532
  chargeable_within_limit=charging_manager.compute_chargeable(),
1350
1533
  charged_count=0,
@@ -1352,6 +1535,23 @@ async def push_data_and_charge(
1352
1535
  )
1353
1536
 
1354
1537
 
1538
+ def _push_is_paid_per_item(
1539
+ charging_manager: ChargingManager, event_name: Optional[str], resolved_event: Optional[str]
1540
+ ) -> bool:
1541
+ """Whether every row of this push is charged a positive price."""
1542
+ names = {name for name in (event_name, resolved_event) if name}
1543
+ return any((charging_manager._calculate_event_price(name) or Decimal('0')) > 0 for name in names)
1544
+
1545
+
1546
+ async def _gated_push(push_fn: Any, items: List[Dict[str, Any]]) -> Any:
1547
+ """Run a push already decided by push_data_and_charge (storage skips its own check)."""
1548
+ token = _charge_gated_push.set(True)
1549
+ try:
1550
+ return await push_fn(items)
1551
+ finally:
1552
+ _charge_gated_push.reset(token)
1553
+
1554
+
1355
1555
  def _http_status_of(err: BaseException) -> Optional[int]:
1356
1556
  status = getattr(err, 'status_code', None)
1357
1557
  if status is None:
@@ -43,7 +43,11 @@ class _ChargingManagerHooks:
43
43
  from .charging import has_default_dataset_item_event_in_pricing
44
44
 
45
45
  pricing_info = self._charging_manager.get_pricing_info()
46
- return has_default_dataset_item_event_in_pricing(pricing_info.per_event_prices or {})
46
+ # Past the spending limit, route default-dataset pushes through
47
+ # push_data_and_charge too, which refuses rows nobody pays for.
48
+ return has_default_dataset_item_event_in_pricing(
49
+ pricing_info.per_event_prices or {}
50
+ ) or bool(getattr(self._charging_manager, 'is_charge_limit_reached', lambda: False)())
47
51
 
48
52
  async def push_and_charge(
49
53
  self,
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import asyncio
8
8
  import logging
9
+ import os
9
10
  import random
10
11
  import time
11
12
  from typing import Any, Dict, Optional, Union
@@ -36,13 +37,16 @@ def is_retryable_response(
36
37
  ) -> bool:
37
38
  """Whether a non-2xx answer is retried.
38
39
 
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.
40
+ 429 always: the run API limiter and dataset backpressure refuse it before
41
+ anything is written. Idempotent methods: also 500/502/503/504. Others
42
+ (dataset push, KV PUT) only on a 503 that certainly was not processed: the
43
+ rate limiter refused it ("rate-limit-unavailable", before the handler), or
44
+ HAProxy had no backend (its own page, nothing forwarded). Any other 503 may
45
+ mean "outcome unknown" (a direct dataset write whose commit was not
46
+ confirmed), and re-sending could store the items twice.
45
47
  """
48
+ if status == 429:
49
+ return True
46
50
  if idempotent:
47
51
  return status in (500, 502, 503, 504)
48
52
  if status != 503:
@@ -70,22 +74,60 @@ def _error_type(response: httpx.Response) -> Optional[str]:
70
74
  return None
71
75
 
72
76
 
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
+ # Default time budget for retrying one API call (429, 503, network errors).
78
+ DEFAULT_API_RETRY_WINDOW_SECS = 120.0
79
+ _MAX_BACKOFF_SECS = 30.0
80
+
81
+
82
+ def api_retry_window_secs() -> float:
83
+ """How long one API call keeps retrying before it gives up.
84
+
85
+ ``SCRAPELY_API_RETRY_WINDOW_SECS`` when set, else ``ApiClient.retry_window_secs``
86
+ (2 minutes by default).
87
+ """
88
+ raw = os.environ.get('SCRAPELY_API_RETRY_WINDOW_SECS')
89
+ if raw:
77
90
  try:
78
- secs = float(retry_after)
91
+ value = float(raw)
92
+ if value >= 0:
93
+ return value
79
94
  except ValueError:
80
- try:
81
- from email.utils import parsedate_to_datetime
95
+ pass
96
+ return ApiClient.retry_window_secs
97
+
98
+
99
+ def _parse_retry_after(retry_after: Optional[str], now: Optional[float]) -> Optional[float]:
100
+ if retry_after is None or not retry_after.strip():
101
+ return None
102
+ secs: Optional[float] = None
103
+ try:
104
+ secs = float(retry_after)
105
+ except ValueError:
106
+ try:
107
+ from email.utils import parsedate_to_datetime
82
108
 
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
109
+ secs = max(0.0, parsedate_to_datetime(retry_after).timestamp() - (time.time() if now is None else now))
110
+ except Exception:
111
+ secs = None
112
+ if secs is None or secs < 0 or secs != secs:
113
+ return None
114
+ # Not capped: a Retry-After past the retry window ends the retries instead.
115
+ return secs
116
+
117
+
118
+ def server_retry_delay(attempt: int, retry_after: Optional[str], now: Optional[float] = None) -> float:
119
+ """Delay in seconds before retry number ``attempt + 1``.
120
+
121
+ Exponential backoff (0.5 s doubling, capped at 30 s) with equal jitter, and
122
+ never earlier than the server's Retry-After (seconds or HTTP date) plus a little jitter so throttled clients don't all come back in
123
+ the same instant.
124
+ """
125
+ exp: float = min(_MAX_BACKOFF_SECS, 0.5 * float(2 ** min(attempt, 16)))
126
+ backoff = exp / 2 + random.random() * (exp / 2)
127
+ ra = _parse_retry_after(retry_after, now)
128
+ if ra is None:
129
+ return backoff
130
+ return max(ra + random.random() * min(1.0, ra * 0.2 + 0.1), backoff)
89
131
 
90
132
 
91
133
  class AdaptiveRateLimiter:
@@ -139,25 +181,8 @@ class AdaptiveRateLimiter:
139
181
 
140
182
  self.last_request_time = time.time() * 1000
141
183
 
142
- async def handle_rate_limited(self, response: httpx.Response) -> None:
184
+ def record_rate_limited(self) -> None:
143
185
  self._rate_limit_errors[429] = self._rate_limit_errors.get(429, 0) + 1
144
- retry_after = response.headers.get('Retry-After')
145
- wait_ms = 1000.0
146
- if retry_after:
147
- try:
148
- wait_ms = float(retry_after) * 1000
149
- except ValueError:
150
- try:
151
- from email.utils import parsedate_to_datetime
152
-
153
- retry_date = parsedate_to_datetime(retry_after).timestamp() * 1000
154
- wait_ms = max(0.0, retry_date - time.time() * 1000)
155
- except Exception:
156
- wait_ms = 1000.0
157
-
158
- wait_ms = wait_ms + random.random() * 100
159
- logger.warning('[RateLimiter] Rate limited, waiting %.0fms before retry', wait_ms)
160
- await asyncio.sleep(wait_ms / 1000)
161
186
 
162
187
  def get_rate_limit_errors(self) -> Dict[int, int]:
163
188
  return dict(self._rate_limit_errors)
@@ -170,11 +195,10 @@ class ApiClient:
170
195
  """
171
196
 
172
197
  _rate_limiter = AdaptiveRateLimiter()
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
198
+ # How long one call keeps retrying 429/503/network errors before it gives
199
+ # up (SCRAPELY_API_RETRY_WINDOW_SECS overrides it). Long enough that a
200
+ # throttled run slows down instead of failing, and covers a main redeploy.
201
+ retry_window_secs = DEFAULT_API_RETRY_WINDOW_SECS
178
202
 
179
203
  def __init__(
180
204
  self,
@@ -260,12 +284,25 @@ class ApiClient:
260
284
  idempotent: Optional[bool] = None,
261
285
  retry_deadline: Optional[float] = None,
262
286
  ) -> httpx.Response:
263
- """Make an HTTP request with adaptive rate limiting and 429 retries.
287
+ """Make an HTTP request with the API retry policy.
288
+
289
+ Retries, with exponential backoff and jitter that honours Retry-After,
290
+ until the retry window (2 minutes by default) runs out:
291
+
292
+ - 429 (run API rate limit, dataset backpressure): always; nothing was applied.
293
+ - 503 from the limiter or HAProxy: always; nothing was forwarded.
294
+ - 500/502/503/504, read/write timeouts and dropped connections:
295
+ idempotent calls only.
296
+ - Refused connections and connect/pool timeouts: always; the request
297
+ never left.
298
+
299
+ A write (dataset push, KV PUT) whose request may have reached the API is
300
+ never re-sent: there is no idempotency key for it, so a re-send could
301
+ store the same rows twice. Such failures surface to the caller instead.
264
302
 
265
- ``retry_deadline`` (a ``time.monotonic()`` value) stops the 5xx /
266
- connection retries once the next backoff would end after it, so a
267
- caller with a time budget (e.g. the exit flush) gets an answer instead
268
- of being cancelled mid-retry.
303
+ ``retry_deadline`` (a ``time.monotonic()`` value) ends the retries
304
+ earlier, so a caller with a time budget (e.g. the exit flush) gets an
305
+ answer instead of being cancelled mid-retry.
269
306
  """
270
307
  client = await self._ensure_client()
271
308
  url = self._url(path)
@@ -277,10 +314,30 @@ class ApiClient:
277
314
 
278
315
  if idempotent is None:
279
316
  idempotent = method.upper() in IDEMPOTENT_METHODS
280
- attempt = 0 # timeouts and 429s
281
- server_attempt = 0 # 503/5xx and connection failures (API briefly unavailable)
317
+ deadline = time.monotonic() + max(0.0, api_retry_window_secs())
318
+ if retry_deadline is not None:
319
+ deadline = min(deadline, retry_deadline)
320
+ attempt = 0
321
+
322
+ async def backoff(why: str, retry_after: Optional[str]) -> bool:
323
+ """Sleep before the next attempt; False once the retry window is used up."""
324
+ nonlocal attempt
325
+ # Retrying sooner than asked would only be refused again: give up
326
+ # when the next try would land past the window / deadline.
327
+ wait = server_retry_delay(attempt, retry_after)
328
+ if time.monotonic() + wait > deadline:
329
+ return False
330
+ attempt += 1
331
+ logger.warning('%s %s %s; retry %d in %.1fs', method, path, why, attempt, wait)
332
+ await asyncio.sleep(wait)
333
+ return True
334
+
282
335
  while True:
283
336
  await self._rate_limiter.wait_for_slot()
337
+ request_kwargs: Dict[str, Any] = {}
338
+ if retry_deadline is not None:
339
+ # A caller with a time budget: no attempt may outlive it.
340
+ request_kwargs['timeout'] = max(0.5, min(self.timeout, retry_deadline - time.monotonic()))
284
341
  try:
285
342
  response = await client.request(
286
343
  method,
@@ -289,63 +346,38 @@ class ApiClient:
289
346
  json=json_data,
290
347
  content=content,
291
348
  headers=request_headers or None,
349
+ **request_kwargs,
292
350
  )
293
- except httpx.TimeoutException:
294
- if do_not_retry_timeouts or attempt >= self._max_retries:
351
+ except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ConnectError) as exc:
352
+ # Never reached the API: safe for every method.
353
+ if isinstance(exc, (httpx.ConnectTimeout, httpx.PoolTimeout)) and do_not_retry_timeouts:
295
354
  raise
296
- attempt += 1
297
- await asyncio.sleep(0.25 * attempt)
298
- continue
355
+ if await backoff(f'failed ({type(exc).__name__})', None):
356
+ continue
357
+ raise
358
+ except httpx.TimeoutException as exc:
359
+ # Read/write timeout: the request may have been applied.
360
+ if idempotent and not do_not_retry_timeouts and await backoff(f'timed out ({type(exc).__name__})', None):
361
+ continue
362
+ raise
299
363
  except httpx.TransportError as exc:
300
- # No response. A refused/unresolvable connection never reached
301
- # the API, so it is safe for every method; anything else (reset
302
- # mid-request) only for idempotent ones.
303
- wait = server_retry_delay(server_attempt, None)
304
- if (
305
- (idempotent or isinstance(exc, httpx.ConnectError))
306
- and server_attempt < self._max_server_retries
307
- and (retry_deadline is None or time.monotonic() + wait <= retry_deadline)
308
- ):
309
- server_attempt += 1
310
- logger.warning(
311
- '%s %s failed (%s); retry %d/%d in %.1fs',
312
- method, path, type(exc).__name__, server_attempt, self._max_server_retries, wait,
313
- )
314
- await asyncio.sleep(wait)
364
+ # Reset mid-request: only idempotent calls are re-sent.
365
+ if idempotent and await backoff(f'failed ({type(exc).__name__})', None):
315
366
  continue
316
367
  raise
317
368
 
318
369
  self._rate_limiter.update_from_headers(response.headers)
319
-
320
- if response.status_code == 429 and attempt < self._max_retries:
321
- attempt += 1
322
- await self._rate_limiter.handle_rate_limited(response)
323
- continue
324
-
325
- # API briefly unavailable (main redeploy, limiter store blip); see
326
- # is_retryable_response for which answers are safe to re-send.
327
- if (
328
- not response.is_success
329
- and server_attempt < self._max_server_retries
330
- and (
331
- retry_deadline is None
332
- or time.monotonic() + server_retry_delay(server_attempt, response.headers.get('Retry-After')) <= retry_deadline
333
- )
334
- and is_retryable_response(
335
- response.status_code,
336
- idempotent,
337
- _error_type(response) if response.status_code == 503 else None,
338
- response.text if response.status_code == 503 else None,
339
- )
370
+ if response.status_code == 429:
371
+ self._rate_limiter.record_rate_limited()
372
+
373
+ if not response.is_success and is_retryable_response(
374
+ response.status_code,
375
+ idempotent,
376
+ _error_type(response) if response.status_code == 503 else None,
377
+ response.text if response.status_code == 503 else None,
340
378
  ):
341
- wait = server_retry_delay(server_attempt, response.headers.get('Retry-After'))
342
- server_attempt += 1
343
- logger.warning(
344
- '%s %s answered %d; retry %d/%d in %.1fs',
345
- method, path, response.status_code, server_attempt, self._max_server_retries, wait,
346
- )
347
- await asyncio.sleep(wait)
348
- continue
379
+ if await backoff(f'answered {response.status_code}', response.headers.get('Retry-After')):
380
+ continue
349
381
 
350
382
  if raise_for_status:
351
383
  await self._raise_for_status(response)
@@ -402,6 +434,7 @@ class ApiClient:
402
434
  params: Optional[Dict[str, Any]] = None,
403
435
  *,
404
436
  raise_for_status: bool = True,
437
+ idempotent: Optional[bool] = None,
405
438
  ) -> httpx.Response:
406
439
  return await self.request(
407
440
  'PUT',
@@ -411,6 +444,7 @@ class ApiClient:
411
444
  content=content,
412
445
  headers=headers,
413
446
  raise_for_status=raise_for_status,
447
+ idempotent=idempotent,
414
448
  )
415
449
 
416
450
  async def delete(
@@ -99,6 +99,12 @@ class ScrapelyDatasetClient(DatasetClient):
99
99
  items = [data]
100
100
  else:
101
101
  items = list(data)
102
+ # Charge-limit safety net: a pay-per-event run past its spending limit
103
+ # must not store unbilled rows in its default dataset.
104
+ from ..charging import default_dataset_id_from_env, refuse_default_dataset_push
105
+
106
+ if self._metadata.id == default_dataset_id_from_env() and refuse_default_dataset_push(len(items)):
107
+ return
102
108
  await self._api.dataset_push_items(self._metadata.id, items)
103
109
 
104
110
  @override
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.10
3
+ Version: 0.1.12
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 AND Apache-2.0
@@ -1,10 +1,10 @@
1
- scrapely/__init__.py,sha256=n-Ei0So7Ex3bRjqTBs1ROCE9uAjI4fjJ7tAFC5YYA-U,5746
1
+ scrapely/__init__.py,sha256=R7A3fQmfkS8LtiN04-32fWvjo6kjNrBSlaeFJCLNgjc,5746
2
2
  scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
3
- scrapely/actor.py,sha256=cQl9RM1MUZWWR8uNF7q6Pjd8vpB1MCoDp6KM5ITJq5I,77479
3
+ scrapely/actor.py,sha256=xo25KfueM_quEFrD9buWqIjja69LSt-koCVIdg3arqg,77912
4
4
  scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
5
5
  scrapely/cdp_retry.py,sha256=hdTJEAIKhApM7IOk1DrQEl8NoflSJSmqd4RbNJ2IsgI,8540
6
- scrapely/charging.py,sha256=L3759neFv63-BvYOg5mSB2GDgauaP6nEJuT5wznJGmM,59263
7
- scrapely/charging_client.py,sha256=tDtlPwLSVFvoRa2NBgNjyfHkAUM77SSIyYT2ACgpDm8,3710
6
+ scrapely/charging.py,sha256=_GHEKEgQ7lRYTVPacBiBuvlaqGTNcsF65K8xhiJ0dNM,68526
7
+ scrapely/charging_client.py,sha256=-4q-Ww7gu9EZXpM9QIn1Vd4vwAXmgL_WEKqaQIx3ViA,3961
8
8
  scrapely/configuration.py,sha256=GjMr3YsxM16HYjAOTUzWZKG2E8FuMzAoFXmkn5dbDPM,16396
9
9
  scrapely/errors.py,sha256=DCAlVvE3jToZuPEG2HVDL7i8D8hLboomQ5JpSOcsXoQ,4107
10
10
  scrapely/log.py,sha256=iVKoQdMLQXEWz1STzbSXrNUxsS_FNwiIdqoEg7CzeqU,1440
@@ -23,10 +23,10 @@ scrapely/request_loaders/__init__.py,sha256=aNgr4pRt3MziUbU6UQ_bjTT3moMNvvyUsPeC
23
23
  scrapely/request_loaders/_scrapely_request_list.py,sha256=vguj-EvBjvrGY5_vf2fGXnJBfnhlPeRjdkjPFaxUA7g,7628
24
24
  scrapely/storage/__init__.py,sha256=diAhxEvOXjc3YVhZvzkO3MttJUXQWJ5ogxstiApsh7U,1405
25
25
  scrapely/storage/_metadata.py,sha256=WWawBnVN0b1iK2K-SlxZXUc6czb5wXWf8iC_0tzkgAQ,4764
26
- scrapely/storage/api_client.py,sha256=jqfBOOHo97DeTzJQlh0bBTn0EjjK184VtnxFIs20rbg,16649
26
+ scrapely/storage/api_client.py,sha256=aWuMwMlHiOkKDCxcN3KDH8Fgf-W2SCSCtphlm80d8nw,17935
27
27
  scrapely/storage/client.py,sha256=Ln_NA8nJz62nXldkcj54PFZopX5P3Lp8HGot_8friNM,22015
28
28
  scrapely/storage/patch_storage.py,sha256=o3Fv0BByeZ1T9HhkzhY6bjolOFsIQcnqBoHq1EdG8Fw,8758
29
- scrapely/storage/scrapely_dataset_client.py,sha256=gRvlCDw9c0yO5GE5LQOa1xAyXc9pnb-E37Eq7fHqHCM,10338
29
+ scrapely/storage/scrapely_dataset_client.py,sha256=u5dnXxe1BAsq7rh-haR5W1ChE7UZ_k7B9ShR8YtwFkg,10695
30
30
  scrapely/storage/scrapely_kvs_client.py,sha256=ItQhe2rcSl20NkSmegfqHiRyKncH4Qg9BjQVpF6jHfk,7403
31
31
  scrapely/storage/scrapely_rq_client.py,sha256=gA4nMDuWyrh6QfbnMv1y7ZgcwFGWIPXGE5Y4OsWA1qI,18920
32
32
  scrapely/storage/scrapely_storage_client.py,sha256=N-p2ISICuKQ8RNzmN-JiXcj_lTttULLOGl9lpLl62qI,6567
@@ -38,10 +38,10 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=4Ipgbh4qEqAkwQJ9
38
38
  scrapely/storage/_file_system/_storage_client.py,sha256=aaGJ4P0QAtIgEMl6yHpuobeWFD5M4rMC5HwhOOvuGuo,1794
39
39
  scrapely/storage_clients/__init__.py,sha256=cs1YX7JV2_GKBXeiiJgkQFali08b5_y_jH6Yw7iROAA,656
40
40
  scrapely/storages/__init__.py,sha256=4PAELhJNZ3exJ7Uouicn-bCQ58NafKjhLBixQ8ATQc4,173
41
- scrapely_python-0.1.10.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
42
- scrapely_python-0.1.10.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
43
- scrapely_python-0.1.10.dist-info/licenses/NOTICE,sha256=DEsNrw6UdSIqdYYqMWVFgJnyt6jUivTD7iFU6YM_e1A,1122
44
- scrapely_python-0.1.10.dist-info/METADATA,sha256=iM8rTb87n890stjpZaERpSRig28sKSLhnmSPROFVoZc,3487
45
- scrapely_python-0.1.10.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
46
- scrapely_python-0.1.10.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
47
- scrapely_python-0.1.10.dist-info/RECORD,,
41
+ scrapely_python-0.1.12.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
42
+ scrapely_python-0.1.12.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
43
+ scrapely_python-0.1.12.dist-info/licenses/NOTICE,sha256=DEsNrw6UdSIqdYYqMWVFgJnyt6jUivTD7iFU6YM_e1A,1122
44
+ scrapely_python-0.1.12.dist-info/METADATA,sha256=zVfjqlKUO2cXYOX8JTl6b8d8mjpS6qXocCrY8PDK9qI,3487
45
+ scrapely_python-0.1.12.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
46
+ scrapely_python-0.1.12.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
47
+ scrapely_python-0.1.12.dist-info/RECORD,,