scrapely-python 0.1.0__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 +246 -0
- scrapely/_browser_locations.py +5470 -0
- scrapely/actor.py +1836 -0
- scrapely/browser_configuration.py +146 -0
- scrapely/charging.py +1143 -0
- scrapely/charging_client.py +107 -0
- scrapely/configuration.py +375 -0
- scrapely/crypto/__init__.py +21 -0
- scrapely/crypto/_secrets.py +210 -0
- scrapely/errors.py +153 -0
- scrapely/events/__init__.py +57 -0
- scrapely/events/_types.py +194 -0
- scrapely/events/event_manager.py +292 -0
- scrapely/log.py +39 -0
- scrapely/patch_browser_pool.py +861 -0
- scrapely/pricing_types.py +172 -0
- scrapely/proxy_configuration.py +741 -0
- scrapely/py.typed +2 -0
- scrapely/request_loaders/__init__.py +5 -0
- scrapely/request_loaders/_scrapely_request_list.py +169 -0
- scrapely/storage/__init__.py +48 -0
- scrapely/storage/_file_system/__init__.py +9 -0
- scrapely/storage/_file_system/_key_value_store_client.py +155 -0
- scrapely/storage/_file_system/_storage_client.py +50 -0
- scrapely/storage/_metadata.py +138 -0
- scrapely/storage/api_client.py +350 -0
- scrapely/storage/client.py +618 -0
- scrapely/storage/patch_storage.py +257 -0
- scrapely/storage/scrapely_dataset_client.py +305 -0
- scrapely/storage/scrapely_kvs_client.py +220 -0
- scrapely/storage/scrapely_rq_client.py +496 -0
- scrapely/storage/scrapely_storage_client.py +192 -0
- scrapely/storage/smart_scrapely_storage_client.py +109 -0
- scrapely/storage/types.py +601 -0
- scrapely/storage/utils.py +38 -0
- scrapely/storage_clients/__init__.py +25 -0
- scrapely/storages/__init__.py +5 -0
- scrapely/types.py +269 -0
- scrapely/webhook.py +82 -0
- scrapely_python-0.1.0.dist-info/METADATA +83 -0
- scrapely_python-0.1.0.dist-info/RECORD +43 -0
- scrapely_python-0.1.0.dist-info/WHEEL +5 -0
- scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
scrapely/charging.py
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Charging manager for pay-per-event pricing.
|
|
3
|
+
|
|
4
|
+
Each charge() call is one logical operation with a unique Idempotency-Key.
|
|
5
|
+
Failed POSTs keep a pending intent for flush/retry; later charge() calls are
|
|
6
|
+
never treated as retries of a previous operation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from threading import RLock
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
from .pricing_types import (
|
|
22
|
+
ActorPricingInfoResult,
|
|
23
|
+
ChargeResult,
|
|
24
|
+
PayPerEventActorPricingInfo,
|
|
25
|
+
PricingModel,
|
|
26
|
+
PricingPerEvent,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .configuration import Configuration
|
|
31
|
+
from .storage.api_client import ApiClient
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Apify-compatible synthetic event for default-dataset items in pay-per-event actors.
|
|
36
|
+
APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item'
|
|
37
|
+
|
|
38
|
+
# Scrapely-native synthetic event for default-dataset items.
|
|
39
|
+
SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item'
|
|
40
|
+
|
|
41
|
+
# Apify-compatible alias — imported actors expect this constant name.
|
|
42
|
+
DEFAULT_DATASET_ITEM_EVENT = APIFY_DEFAULT_DATASET_ITEM_EVENT
|
|
43
|
+
|
|
44
|
+
# Must stay below server CHARGE_IDEMPOTENCY_TTL (35d). Do not replay past this age.
|
|
45
|
+
CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
|
|
46
|
+
|
|
47
|
+
# Soft cap on open unresolved intents. Refuse NEW billable charges when full.
|
|
48
|
+
_PENDING_MAX_ENTRIES = 1024
|
|
49
|
+
|
|
50
|
+
# Immediate same-key POST attempts before leaving intent pending / raising.
|
|
51
|
+
_POST_ATTEMPTS = 3
|
|
52
|
+
|
|
53
|
+
# Local-only dataset collecting one entry per logical charge operation
|
|
54
|
+
# (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
|
|
55
|
+
LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_default_dataset_item_event(event_name: str) -> bool:
|
|
59
|
+
"""Check if event name is a default dataset item event."""
|
|
60
|
+
return event_name in (APIFY_DEFAULT_DATASET_ITEM_EVENT, SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_default_dataset_item_event_name(pricing_info: dict) -> str:
|
|
64
|
+
"""Resolve the default dataset item event name from pricing info."""
|
|
65
|
+
if APIFY_DEFAULT_DATASET_ITEM_EVENT in pricing_info:
|
|
66
|
+
return APIFY_DEFAULT_DATASET_ITEM_EVENT
|
|
67
|
+
if SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in pricing_info:
|
|
68
|
+
return SCRAPELY_DEFAULT_DATASET_ITEM_EVENT
|
|
69
|
+
return APIFY_DEFAULT_DATASET_ITEM_EVENT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def has_default_dataset_item_event_in_pricing(per_event_prices: dict) -> bool:
|
|
73
|
+
"""Check if default dataset item event pricing is configured."""
|
|
74
|
+
return (
|
|
75
|
+
APIFY_DEFAULT_DATASET_ITEM_EVENT in per_event_prices or
|
|
76
|
+
SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in per_event_prices
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Backwards-compatible alias for the pre-rename camelCase name.
|
|
81
|
+
# Deprecated — use ``has_default_dataset_item_event_in_pricing`` instead.
|
|
82
|
+
hasDefaultDatasetItemEventInPricing = has_default_dataset_item_event_in_pricing
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def merge_charge_results(a: ChargeResult, b: ChargeResult) -> ChargeResult:
|
|
86
|
+
"""Merge two :class:`ChargeResult` objects (Node SDK ``mergeChargeResults`` parity).
|
|
87
|
+
|
|
88
|
+
Sums charged counts, ORs the limit-reached flag, and keeps the most
|
|
89
|
+
restrictive remaining-limit per event type.
|
|
90
|
+
"""
|
|
91
|
+
def _min_optional(x: Optional[int], y: Optional[int]) -> Optional[int]:
|
|
92
|
+
# None means "no limit known" — the least restrictive value.
|
|
93
|
+
if x is None:
|
|
94
|
+
return y
|
|
95
|
+
if y is None:
|
|
96
|
+
return x
|
|
97
|
+
return min(x, y)
|
|
98
|
+
|
|
99
|
+
merged_limits: Dict[str, Optional[int]] = {
|
|
100
|
+
key: _min_optional(value, b.chargeable_within_limit.get(key, value))
|
|
101
|
+
for key, value in a.chargeable_within_limit.items()
|
|
102
|
+
}
|
|
103
|
+
return ChargeResult(
|
|
104
|
+
event_charge_limit_reached=a.event_charge_limit_reached or b.event_charge_limit_reached,
|
|
105
|
+
charged_count=a.charged_count + b.charged_count,
|
|
106
|
+
chargeable_within_limit=merged_limits,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ChargeUncertainError(Exception):
|
|
111
|
+
"""Raised when a charge POST could not be confirmed after retries.
|
|
112
|
+
|
|
113
|
+
Local budget still reserves the count for this operation. The intent remains
|
|
114
|
+
pending for later flush with the same Idempotency-Key. Do not assume the
|
|
115
|
+
platform did or did not apply the charge.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
message: str,
|
|
121
|
+
*,
|
|
122
|
+
operation_id: str,
|
|
123
|
+
event_name: str,
|
|
124
|
+
count: int,
|
|
125
|
+
) -> None:
|
|
126
|
+
super().__init__(message)
|
|
127
|
+
self.operation_id = operation_id
|
|
128
|
+
self.event_name = event_name
|
|
129
|
+
self.count = count
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ChargeRejectedError(Exception):
|
|
133
|
+
"""Definitive platform rejection (402/400/409). Do not retry with the same key."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
message: str,
|
|
138
|
+
*,
|
|
139
|
+
operation_id: str,
|
|
140
|
+
event_name: str,
|
|
141
|
+
count: int,
|
|
142
|
+
status_code: Optional[int] = None,
|
|
143
|
+
reason: str = 'rejected',
|
|
144
|
+
) -> None:
|
|
145
|
+
super().__init__(message)
|
|
146
|
+
self.operation_id = operation_id
|
|
147
|
+
self.event_name = event_name
|
|
148
|
+
self.count = count
|
|
149
|
+
self.status_code = status_code
|
|
150
|
+
self.reason = reason
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ChargingManager:
|
|
154
|
+
"""Manages charging for pay-per-event Actors.
|
|
155
|
+
|
|
156
|
+
Tracks event counts and enforces spending limits.
|
|
157
|
+
Provides fine-grained access to pay-per-event functionality.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
_PENDING_MAX_ENTRIES = _PENDING_MAX_ENTRIES
|
|
161
|
+
_IDEMPOTENCY_SAFE_AGE_SECS = CHARGE_IDEMPOTENCY_SAFE_AGE_SECS
|
|
162
|
+
_POST_ATTEMPTS = _POST_ATTEMPTS
|
|
163
|
+
|
|
164
|
+
def __init__(self, config: 'Configuration', client: 'ApiClient'):
|
|
165
|
+
"""Initialize the charging manager.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
config: Configuration instance
|
|
169
|
+
client: API client for making charge requests
|
|
170
|
+
"""
|
|
171
|
+
self._config = config
|
|
172
|
+
# Optional in practice: guards treat a missing client as "cannot bill".
|
|
173
|
+
self._client: Optional['ApiClient'] = client
|
|
174
|
+
self._pricing_info: Optional[PayPerEventActorPricingInfo] = None
|
|
175
|
+
self._charged_events: Dict[str, int] = {}
|
|
176
|
+
self._max_total_charge_usd: Optional[Decimal] = None
|
|
177
|
+
self._total_charged_usd: Decimal = Decimal('0.0')
|
|
178
|
+
self._charge_lock = RLock()
|
|
179
|
+
self._flush_lock = asyncio.Lock()
|
|
180
|
+
# Serializes budget calculation/reservation with PPE-aware dataset pushes.
|
|
181
|
+
# The threading lock protects synchronous state mutation; this async lock
|
|
182
|
+
# protects invariants across awaited push operations.
|
|
183
|
+
self._operation_lock = asyncio.Lock()
|
|
184
|
+
# Set during actor shutdown; rejects new charge operations after final flush.
|
|
185
|
+
self._closed = False
|
|
186
|
+
self._per_event_prices: Dict[str, Decimal] = {}
|
|
187
|
+
self._is_at_home = bool(config.get('is_at_home'))
|
|
188
|
+
# Prefer run_id from the API client (storage credentials) over env config.
|
|
189
|
+
client_run_id = getattr(client, 'run_id', None) if client is not None else None
|
|
190
|
+
self._actor_run_id: Optional[str] = client_run_id or config.get('actor_run_id')
|
|
191
|
+
self._initialized = False
|
|
192
|
+
self._not_ppe_warning_printed = False
|
|
193
|
+
# Unresolved platform-billable intents: idempotency_key -> metadata.
|
|
194
|
+
# Identity is the UUID key only — never keyed by event_name.
|
|
195
|
+
self._pending_charge_keys: Dict[str, Dict[str, Any]] = {}
|
|
196
|
+
# Optional local charging-log dataset (ACTOR_USE_CHARGING_LOG_DATASET).
|
|
197
|
+
self._charging_log_dataset: Any = None
|
|
198
|
+
|
|
199
|
+
def close(self) -> None:
|
|
200
|
+
"""Mark the manager closed; subsequent charge/push operations are rejected."""
|
|
201
|
+
self._closed = True
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def charge_lock(self) -> RLock:
|
|
205
|
+
"""Lock to synchronize charge operations."""
|
|
206
|
+
return self._charge_lock
|
|
207
|
+
|
|
208
|
+
def _load_pricing_data(self, pricing_data: Any) -> None:
|
|
209
|
+
"""Populate PPE price map from a pricing info object/dict."""
|
|
210
|
+
if not isinstance(pricing_data, dict):
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Worker may inject either the full ActorPricingInfo or just pricingPerEvent.
|
|
214
|
+
model = pricing_data.get('pricingModel')
|
|
215
|
+
pricing_per_event = pricing_data.get('pricingPerEvent') or {}
|
|
216
|
+
actor_charge_events = pricing_per_event.get('actorChargeEvents') or pricing_per_event
|
|
217
|
+
|
|
218
|
+
if model and model != PricingModel.PAY_PER_EVENT.value and model != 'PAY_PER_EVENT':
|
|
219
|
+
return
|
|
220
|
+
if not actor_charge_events and model not in (PricingModel.PAY_PER_EVENT.value, 'PAY_PER_EVENT'):
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
per_event = {}
|
|
224
|
+
for event_name, event_data in (actor_charge_events or {}).items():
|
|
225
|
+
if not isinstance(event_data, dict):
|
|
226
|
+
continue
|
|
227
|
+
price = event_data.get('eventPriceUsd', event_data.get('event_price_usd', 0))
|
|
228
|
+
per_event[event_name] = PricingPerEvent(
|
|
229
|
+
event_price_usd=Decimal(str(price or 0)),
|
|
230
|
+
event_description=event_data.get('eventDescription') or event_data.get('eventTitle'),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if not per_event and model not in (PricingModel.PAY_PER_EVENT.value, 'PAY_PER_EVENT'):
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
self._pricing_info = PayPerEventActorPricingInfo(pricing_per_event=per_event)
|
|
237
|
+
self._per_event_prices = {
|
|
238
|
+
name: info.event_price_usd
|
|
239
|
+
for name, info in per_event.items()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
default_event = pricing_data.get('defaultDatasetItemEventName')
|
|
243
|
+
if default_event and default_event not in self._per_event_prices:
|
|
244
|
+
self._per_event_prices[default_event] = Decimal('0')
|
|
245
|
+
|
|
246
|
+
async def init(self) -> None:
|
|
247
|
+
"""Initialize the charging manager (JS ChargingManager.init parity).
|
|
248
|
+
|
|
249
|
+
Loads pricing info, prior charged counts and the budget from the
|
|
250
|
+
environment (when complete) or the platform run API, honors
|
|
251
|
+
ACTOR_TEST_PAY_PER_EVENT, and opens the optional charging-log dataset.
|
|
252
|
+
"""
|
|
253
|
+
if self._initialized:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
use_charging_log_dataset = bool(self._config.get('use_charging_log_dataset'))
|
|
257
|
+
if use_charging_log_dataset and self._is_at_home:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
'Using the ACTOR_USE_CHARGING_LOG_DATASET environment variable is only supported '
|
|
260
|
+
'in a local development environment'
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
test_pay_per_event = bool(self._config.get('test_pay_per_event'))
|
|
264
|
+
if test_pay_per_event and self._is_at_home:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
'Using the ACTOR_TEST_PAY_PER_EVENT environment variable is only supported '
|
|
267
|
+
'in a local development environment'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
pricing_data = await self._fetch_pricing_data()
|
|
271
|
+
|
|
272
|
+
# Load max total charge (absent/zero means no cap — JS `|| Infinity` parity).
|
|
273
|
+
max_charge = pricing_data.get('max_total_charge_usd')
|
|
274
|
+
self._max_total_charge_usd = Decimal(str(max_charge)) if max_charge else None
|
|
275
|
+
|
|
276
|
+
# Load pricing info (env when complete, otherwise the run API at home).
|
|
277
|
+
pricing_info_raw = pricing_data.get('pricing_info')
|
|
278
|
+
if pricing_info_raw:
|
|
279
|
+
try:
|
|
280
|
+
parsed = json.loads(pricing_info_raw) if isinstance(pricing_info_raw, str) else pricing_info_raw
|
|
281
|
+
self._load_pricing_data(parsed)
|
|
282
|
+
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
283
|
+
logger.warning(f'Failed to parse actor pricing info: {e}')
|
|
284
|
+
|
|
285
|
+
# ACTOR_TEST_PAY_PER_EVENT forces pay-per-event mode locally even
|
|
286
|
+
# without configured pricing (every event then costs $1).
|
|
287
|
+
if test_pay_per_event and self._pricing_info is None:
|
|
288
|
+
self._pricing_info = PayPerEventActorPricingInfo(pricing_per_event={})
|
|
289
|
+
|
|
290
|
+
# Load charged event counts
|
|
291
|
+
charged_counts = pricing_data.get('charged_event_counts')
|
|
292
|
+
if charged_counts:
|
|
293
|
+
try:
|
|
294
|
+
if isinstance(charged_counts, str):
|
|
295
|
+
self._charged_events = json.loads(charged_counts)
|
|
296
|
+
else:
|
|
297
|
+
self._charged_events = dict(charged_counts)
|
|
298
|
+
except (json.JSONDecodeError, TypeError):
|
|
299
|
+
logger.warning('Failed to parse charged event counts')
|
|
300
|
+
|
|
301
|
+
# Recompute total charged from prior counts when prices are known.
|
|
302
|
+
self._total_charged_usd = Decimal('0.0')
|
|
303
|
+
for event_name, event_count in self._charged_events.items():
|
|
304
|
+
price = self._per_event_prices.get(event_name, Decimal('0'))
|
|
305
|
+
self._total_charged_usd += price * Decimal(str(event_count or 0))
|
|
306
|
+
|
|
307
|
+
self._initialized = True
|
|
308
|
+
|
|
309
|
+
# Optional charging-log dataset (pay-per-event runs only). The platform
|
|
310
|
+
# branch of the JS SDK is unreachable here because the environment
|
|
311
|
+
# variable is rejected at home above.
|
|
312
|
+
if self._pricing_info is not None and use_charging_log_dataset:
|
|
313
|
+
try:
|
|
314
|
+
await self._open_charging_log_dataset()
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.warning('Failed to open the charging log dataset: %s', e)
|
|
317
|
+
|
|
318
|
+
async def _fetch_pricing_data(self) -> Dict[str, Any]:
|
|
319
|
+
"""Resolve pricing info, charged counts and budget (JS fetchPricingInfo parity).
|
|
320
|
+
|
|
321
|
+
Prefers environment variables when both pricing info and charged counts are
|
|
322
|
+
present; on the platform an incomplete configuration falls back to the run API.
|
|
323
|
+
"""
|
|
324
|
+
pricing_info_raw = self._config.get('actor_pricing_info')
|
|
325
|
+
charged_counts = self._config.get('charged_event_counts')
|
|
326
|
+
if pricing_info_raw and charged_counts:
|
|
327
|
+
return {
|
|
328
|
+
'pricing_info': pricing_info_raw,
|
|
329
|
+
'charged_event_counts': charged_counts,
|
|
330
|
+
'max_total_charge_usd': self._config.get('max_total_charge_usd'),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if self._is_at_home:
|
|
334
|
+
if not self._actor_run_id:
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
'Actor run ID not found even though the Actor is running on the platform'
|
|
337
|
+
)
|
|
338
|
+
if self._client is not None:
|
|
339
|
+
run = None
|
|
340
|
+
try:
|
|
341
|
+
run = await self._client.get_json(f'/v2/actor-runs/{self._actor_run_id}')
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.warning(
|
|
344
|
+
'Failed to fetch actor run %s for pricing info: %s',
|
|
345
|
+
self._actor_run_id,
|
|
346
|
+
e,
|
|
347
|
+
)
|
|
348
|
+
if run is None:
|
|
349
|
+
raise RuntimeError('Actor run not found')
|
|
350
|
+
options = run.get('options') or {}
|
|
351
|
+
return {
|
|
352
|
+
'pricing_info': run.get('pricingInfo'),
|
|
353
|
+
'charged_event_counts': run.get('chargedEventCounts') or {},
|
|
354
|
+
'max_total_charge_usd': options.get('maxTotalChargeUsd'),
|
|
355
|
+
}
|
|
356
|
+
logger.warning('No API client available; cannot fetch run pricing info')
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
'pricing_info': pricing_info_raw,
|
|
360
|
+
'charged_event_counts': charged_counts,
|
|
361
|
+
'max_total_charge_usd': self._config.get('max_total_charge_usd'),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def _calculate_event_price(self, event_name: str) -> Optional[Decimal]:
|
|
365
|
+
"""Effective unit price for affordability (JS calculateEventPrice parity).
|
|
366
|
+
|
|
367
|
+
Locally every event costs $1 so budgets constrain during development;
|
|
368
|
+
on the platform unregistered events are free (None).
|
|
369
|
+
"""
|
|
370
|
+
if not self._is_at_home:
|
|
371
|
+
return Decimal('1')
|
|
372
|
+
if is_default_dataset_item_event(event_name):
|
|
373
|
+
if APIFY_DEFAULT_DATASET_ITEM_EVENT in self._per_event_prices:
|
|
374
|
+
return self._per_event_prices[APIFY_DEFAULT_DATASET_ITEM_EVENT]
|
|
375
|
+
return self._per_event_prices.get(SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
|
|
376
|
+
return self._per_event_prices.get(event_name)
|
|
377
|
+
|
|
378
|
+
def _get_event_pricing(self, event_name: str) -> tuple[Decimal, str]:
|
|
379
|
+
"""Unit price and title used for reservation/logging (JS getEventPricing parity)."""
|
|
380
|
+
canonical_event_name = (
|
|
381
|
+
resolve_default_dataset_item_event_name(self._per_event_prices)
|
|
382
|
+
if is_default_dataset_item_event(event_name)
|
|
383
|
+
else event_name
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
price = self._per_event_prices.get(canonical_event_name)
|
|
387
|
+
if price is not None:
|
|
388
|
+
info = (
|
|
389
|
+
self._pricing_info.pricing_per_event.get(canonical_event_name)
|
|
390
|
+
if self._pricing_info is not None
|
|
391
|
+
else None
|
|
392
|
+
)
|
|
393
|
+
title = info.event_description if info is not None and info.event_description else canonical_event_name
|
|
394
|
+
return price, title
|
|
395
|
+
|
|
396
|
+
# Unknown event: free on the platform, $1 locally.
|
|
397
|
+
price = Decimal('0') if self._is_at_home else Decimal('1')
|
|
398
|
+
return price, f"Unknown event '{event_name}'"
|
|
399
|
+
|
|
400
|
+
async def _open_charging_log_dataset(self) -> None:
|
|
401
|
+
"""Open the local charging-log dataset (JS parity: local development only)."""
|
|
402
|
+
from crawlee.storages import Dataset
|
|
403
|
+
|
|
404
|
+
if self._config.get('purge_on_start'):
|
|
405
|
+
dataset = await Dataset.open(name=LOCAL_CHARGING_LOG_DATASET_NAME)
|
|
406
|
+
await dataset.drop()
|
|
407
|
+
self._charging_log_dataset = await Dataset.open(name=LOCAL_CHARGING_LOG_DATASET_NAME)
|
|
408
|
+
|
|
409
|
+
def _snapshot_pending(self) -> List[tuple[str, Dict[str, Any]]]:
|
|
410
|
+
with self._charge_lock:
|
|
411
|
+
return [(k, dict(v)) for k, v in self._pending_charge_keys.items()]
|
|
412
|
+
|
|
413
|
+
def _drop_pending(self, idempotency_key: str) -> None:
|
|
414
|
+
with self._charge_lock:
|
|
415
|
+
self._pending_charge_keys.pop(idempotency_key, None)
|
|
416
|
+
|
|
417
|
+
def _unwind_reservation(self, event_name: str, count: int) -> None:
|
|
418
|
+
"""Subtract a previously reserved local count/spend (reject or partial accept)."""
|
|
419
|
+
if count <= 0:
|
|
420
|
+
return
|
|
421
|
+
with self._charge_lock:
|
|
422
|
+
event_price = self._per_event_prices.get(event_name, Decimal('0'))
|
|
423
|
+
if event_name in self._charged_events:
|
|
424
|
+
self._charged_events[event_name] = max(
|
|
425
|
+
0, self._charged_events[event_name] - count
|
|
426
|
+
)
|
|
427
|
+
self._total_charged_usd = max(
|
|
428
|
+
Decimal('0'),
|
|
429
|
+
self._total_charged_usd - event_price * Decimal(count),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def _quarantine_stale_pending(self) -> None:
|
|
433
|
+
"""Remove intents older than the safe replay window (fail closed, no replay).
|
|
434
|
+
|
|
435
|
+
Unwinds the local reservation so quarantining cannot permanently consume budget.
|
|
436
|
+
"""
|
|
437
|
+
now = time.monotonic()
|
|
438
|
+
stale: List[tuple[str, Dict[str, Any]]] = []
|
|
439
|
+
with self._charge_lock:
|
|
440
|
+
for key, pending in self._pending_charge_keys.items():
|
|
441
|
+
age = now - float(pending.get('created_at', now) or now)
|
|
442
|
+
if age > self._IDEMPOTENCY_SAFE_AGE_SECS:
|
|
443
|
+
stale.append((key, dict(pending)))
|
|
444
|
+
for key, _pending in stale:
|
|
445
|
+
self._pending_charge_keys.pop(key, None)
|
|
446
|
+
for key, pending in stale:
|
|
447
|
+
event_name = str(pending.get('event_name') or '')
|
|
448
|
+
count = int(pending.get('count', 0) or 0)
|
|
449
|
+
if event_name and count > 0:
|
|
450
|
+
self._unwind_reservation(event_name, count)
|
|
451
|
+
logger.error(
|
|
452
|
+
'Quarantined stale charge intent past safe idempotency age '
|
|
453
|
+
'(key=%s event=%s count=%s); unwound local reservation; will not replay',
|
|
454
|
+
key,
|
|
455
|
+
pending.get('event_name'),
|
|
456
|
+
pending.get('count'),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def _post_charge_once(
|
|
460
|
+
self,
|
|
461
|
+
*,
|
|
462
|
+
event_name: str,
|
|
463
|
+
count: int,
|
|
464
|
+
idempotency_key: str,
|
|
465
|
+
) -> Dict[str, Any]:
|
|
466
|
+
if self._client is None:
|
|
467
|
+
raise RuntimeError('ChargingManager has no API client; cannot post charge intent')
|
|
468
|
+
response = await self._client.post(
|
|
469
|
+
f'/v2/actor-runs/{self._actor_run_id}/charge',
|
|
470
|
+
json_data={
|
|
471
|
+
'eventName': event_name,
|
|
472
|
+
'count': count,
|
|
473
|
+
},
|
|
474
|
+
headers={'Idempotency-Key': idempotency_key},
|
|
475
|
+
)
|
|
476
|
+
if isinstance(response, dict):
|
|
477
|
+
return response
|
|
478
|
+
# httpx.Response-like
|
|
479
|
+
try:
|
|
480
|
+
body = response.json()
|
|
481
|
+
if isinstance(body, dict):
|
|
482
|
+
return body
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
return {}
|
|
486
|
+
|
|
487
|
+
async def _post_charge_with_retries(
|
|
488
|
+
self,
|
|
489
|
+
*,
|
|
490
|
+
event_name: str,
|
|
491
|
+
count: int,
|
|
492
|
+
idempotency_key: str,
|
|
493
|
+
) -> Optional[int]:
|
|
494
|
+
"""POST charge; return acceptedCount on success, None if uncertain.
|
|
495
|
+
|
|
496
|
+
Raises ChargeRejectedError for definitive platform rejections.
|
|
497
|
+
Same idempotency key on every attempt.
|
|
498
|
+
"""
|
|
499
|
+
last_error: Optional[BaseException] = None
|
|
500
|
+
for attempt in range(self._POST_ATTEMPTS):
|
|
501
|
+
try:
|
|
502
|
+
body = await self._post_charge_once(
|
|
503
|
+
event_name=event_name,
|
|
504
|
+
count=count,
|
|
505
|
+
idempotency_key=idempotency_key,
|
|
506
|
+
)
|
|
507
|
+
return _read_accepted_count(body, count)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
last_error = e
|
|
510
|
+
rejected = _classify_charge_http_error(
|
|
511
|
+
e,
|
|
512
|
+
operation_id=idempotency_key,
|
|
513
|
+
event_name=event_name,
|
|
514
|
+
count=count,
|
|
515
|
+
)
|
|
516
|
+
if rejected is not None:
|
|
517
|
+
raise rejected from e
|
|
518
|
+
if attempt + 1 < self._POST_ATTEMPTS:
|
|
519
|
+
await asyncio.sleep(0.25 * (attempt + 1))
|
|
520
|
+
if last_error is not None:
|
|
521
|
+
logger.warning(
|
|
522
|
+
'Failed to record charge for event %s (count=%s, key=%s): %s',
|
|
523
|
+
event_name,
|
|
524
|
+
count,
|
|
525
|
+
idempotency_key,
|
|
526
|
+
last_error,
|
|
527
|
+
)
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
async def flush_pending(
|
|
531
|
+
self, *, max_intents: int = 32, deadline_ms: float | None = None
|
|
532
|
+
) -> int:
|
|
533
|
+
"""Best-effort flush under the operation lock; drain until empty or deadline."""
|
|
534
|
+
if not self._is_at_home or self._client is None or not self._actor_run_id:
|
|
535
|
+
return 0
|
|
536
|
+
async with self._operation_lock:
|
|
537
|
+
return await self._flush_pending_exclusive(
|
|
538
|
+
max_intents=max_intents, deadline_ms=deadline_ms
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
async def _flush_pending_exclusive(
|
|
542
|
+
self, *, max_intents: int = 32, deadline_ms: float | None = None
|
|
543
|
+
) -> int:
|
|
544
|
+
"""Caller must hold _operation_lock."""
|
|
545
|
+
if not self._is_at_home or self._client is None or not self._actor_run_id:
|
|
546
|
+
return 0
|
|
547
|
+
|
|
548
|
+
import time
|
|
549
|
+
|
|
550
|
+
deadline = None if deadline_ms is None else (time.monotonic() + max(0.0, deadline_ms) / 1000.0)
|
|
551
|
+
confirmed_total = 0
|
|
552
|
+
async with self._flush_lock:
|
|
553
|
+
while True:
|
|
554
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
555
|
+
break
|
|
556
|
+
self._quarantine_stale_pending()
|
|
557
|
+
snapshot = self._snapshot_pending()[: max(0, max_intents)]
|
|
558
|
+
if not snapshot:
|
|
559
|
+
break
|
|
560
|
+
confirmed = 0
|
|
561
|
+
for idempotency_key, pending in snapshot:
|
|
562
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
563
|
+
break
|
|
564
|
+
event_name = str(pending.get('event_name') or '')
|
|
565
|
+
count = int(pending.get('count', 0) or 0)
|
|
566
|
+
if not event_name or count <= 0:
|
|
567
|
+
self._drop_pending(idempotency_key)
|
|
568
|
+
continue
|
|
569
|
+
try:
|
|
570
|
+
accepted = await self._post_charge_with_retries(
|
|
571
|
+
event_name=event_name,
|
|
572
|
+
count=count,
|
|
573
|
+
idempotency_key=idempotency_key,
|
|
574
|
+
)
|
|
575
|
+
except ChargeRejectedError as err:
|
|
576
|
+
self._unwind_reservation(event_name, count)
|
|
577
|
+
self._drop_pending(idempotency_key)
|
|
578
|
+
logger.warning(
|
|
579
|
+
'Dropping rejected pending charge (event=%s key=%s): %s',
|
|
580
|
+
event_name,
|
|
581
|
+
idempotency_key,
|
|
582
|
+
err,
|
|
583
|
+
)
|
|
584
|
+
continue
|
|
585
|
+
if accepted is not None:
|
|
586
|
+
if accepted < count:
|
|
587
|
+
self._unwind_reservation(event_name, count - accepted)
|
|
588
|
+
self._drop_pending(idempotency_key)
|
|
589
|
+
confirmed += 1
|
|
590
|
+
confirmed_total += confirmed
|
|
591
|
+
if confirmed == 0:
|
|
592
|
+
break
|
|
593
|
+
return confirmed_total
|
|
594
|
+
|
|
595
|
+
async def charge(self, event_name: str, count: int = 1) -> ChargeResult:
|
|
596
|
+
"""Atomically reserve and submit one logical charge operation."""
|
|
597
|
+
async with self._operation_lock:
|
|
598
|
+
return await self._charge_exclusive(event_name, count)
|
|
599
|
+
|
|
600
|
+
async def _charge_exclusive(self, event_name: str, count: int = 1) -> ChargeResult:
|
|
601
|
+
if self._closed:
|
|
602
|
+
raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
|
|
603
|
+
"""Charge for a specified number of events.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
event_name: Name of the event to charge
|
|
607
|
+
count: Number of events to charge
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
ChargeResult indicating if the limit was reached
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
ChargeUncertainError: POST could not be confirmed after retries; the
|
|
614
|
+
operation remains reserved locally and pending for flush.
|
|
615
|
+
"""
|
|
616
|
+
if not self._initialized:
|
|
617
|
+
await self.init()
|
|
618
|
+
|
|
619
|
+
# Not a PPE actor — ignore charges (matches JS ChargingManager).
|
|
620
|
+
if self._pricing_info is None:
|
|
621
|
+
if not self._not_ppe_warning_printed:
|
|
622
|
+
logger.warning(
|
|
623
|
+
'Ignored attempt to charge for an event - the Actor does not use the pay-per-event pricing'
|
|
624
|
+
)
|
|
625
|
+
self._not_ppe_warning_printed = True
|
|
626
|
+
return ChargeResult(
|
|
627
|
+
chargeable_within_limit={},
|
|
628
|
+
charged_count=0,
|
|
629
|
+
event_charge_limit_reached=False,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
|
633
|
+
raise ValueError('count must be a positive integer')
|
|
634
|
+
|
|
635
|
+
# Best-effort: clear older unresolved intents before starting a new one.
|
|
636
|
+
try:
|
|
637
|
+
await self._flush_pending_exclusive()
|
|
638
|
+
except Exception as e:
|
|
639
|
+
logger.warning('flush_pending before charge failed: %s', e)
|
|
640
|
+
|
|
641
|
+
idempotency_key: Optional[str] = None
|
|
642
|
+
chargeable_count = 0
|
|
643
|
+
will_post = False
|
|
644
|
+
|
|
645
|
+
with self._charge_lock:
|
|
646
|
+
event_price, event_title = self._get_event_pricing(event_name)
|
|
647
|
+
affordability_price = self._calculate_event_price(event_name) or Decimal('0')
|
|
648
|
+
|
|
649
|
+
# JS ChargingManager parity: canPost requires isAtHome — a local
|
|
650
|
+
# process with an API client/run ID must never post platform charges.
|
|
651
|
+
can_post_base = (
|
|
652
|
+
self._is_at_home
|
|
653
|
+
and bool(self._actor_run_id)
|
|
654
|
+
and self._client is not None
|
|
655
|
+
and not is_default_dataset_item_event(event_name)
|
|
656
|
+
and event_name in self._per_event_prices
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# Budget gate (JS parity): trim unaffordable counts, but keep the
|
|
660
|
+
# legacy one-event overdraw while the budget is not yet exceeded.
|
|
661
|
+
chargeable_count = count
|
|
662
|
+
if self._max_total_charge_usd is not None and affordability_price > 0:
|
|
663
|
+
remaining = self._max_total_charge_usd - self._total_charged_usd
|
|
664
|
+
max_count_by_limit = max(0, int(remaining / affordability_price))
|
|
665
|
+
if count > max_count_by_limit:
|
|
666
|
+
chargeable_count = (
|
|
667
|
+
max_count_by_limit + 1
|
|
668
|
+
if self._total_charged_usd <= self._max_total_charge_usd
|
|
669
|
+
else 0
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
if chargeable_count <= 0:
|
|
673
|
+
return ChargeResult(
|
|
674
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
675
|
+
charged_count=0,
|
|
676
|
+
event_charge_limit_reached=True,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
will_post = can_post_base and chargeable_count > 0
|
|
680
|
+
|
|
681
|
+
if will_post:
|
|
682
|
+
if len(self._pending_charge_keys) >= self._PENDING_MAX_ENTRIES:
|
|
683
|
+
logger.warning(
|
|
684
|
+
'Too many unresolved charge intents (%s); refusing new '
|
|
685
|
+
'billable charge for event %s until pending POSTs succeed',
|
|
686
|
+
len(self._pending_charge_keys),
|
|
687
|
+
event_name,
|
|
688
|
+
)
|
|
689
|
+
return ChargeResult(
|
|
690
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
691
|
+
charged_count=0,
|
|
692
|
+
event_charge_limit_reached=False,
|
|
693
|
+
)
|
|
694
|
+
idempotency_key = str(uuid.uuid4())
|
|
695
|
+
self._pending_charge_keys[idempotency_key] = {
|
|
696
|
+
'idempotency_key': idempotency_key,
|
|
697
|
+
'created_at': time.monotonic(),
|
|
698
|
+
'event_name': event_name,
|
|
699
|
+
'count': chargeable_count,
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
# Reserve local budget once per logical operation.
|
|
703
|
+
if event_name not in self._charged_events:
|
|
704
|
+
self._charged_events[event_name] = 0
|
|
705
|
+
self._charged_events[event_name] += chargeable_count
|
|
706
|
+
self._total_charged_usd += event_price * Decimal(chargeable_count)
|
|
707
|
+
|
|
708
|
+
result = ChargeResult(
|
|
709
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
710
|
+
charged_count=chargeable_count,
|
|
711
|
+
event_charge_limit_reached=self._is_event_limit_reached(event_name),
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Record charge with the platform.
|
|
715
|
+
# Synthetic default-dataset events are tracked locally only.
|
|
716
|
+
if will_post and idempotency_key is not None:
|
|
717
|
+
try:
|
|
718
|
+
accepted = await self._post_charge_with_retries(
|
|
719
|
+
event_name=event_name,
|
|
720
|
+
count=chargeable_count,
|
|
721
|
+
idempotency_key=idempotency_key,
|
|
722
|
+
)
|
|
723
|
+
except ChargeRejectedError:
|
|
724
|
+
# Server definitively rejected: unwind local reservation.
|
|
725
|
+
self._drop_pending(idempotency_key)
|
|
726
|
+
self._unwind_reservation(event_name, chargeable_count)
|
|
727
|
+
return ChargeResult(
|
|
728
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
729
|
+
charged_count=0,
|
|
730
|
+
event_charge_limit_reached=True,
|
|
731
|
+
)
|
|
732
|
+
if accepted is not None:
|
|
733
|
+
self._drop_pending(idempotency_key)
|
|
734
|
+
if accepted < chargeable_count:
|
|
735
|
+
self._unwind_reservation(event_name, chargeable_count - accepted)
|
|
736
|
+
chargeable_count = accepted
|
|
737
|
+
result = ChargeResult(
|
|
738
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
739
|
+
charged_count=chargeable_count,
|
|
740
|
+
event_charge_limit_reached=self._is_event_limit_reached(event_name),
|
|
741
|
+
)
|
|
742
|
+
else:
|
|
743
|
+
raise ChargeUncertainError(
|
|
744
|
+
f'Charge for event {event_name!r} (count={chargeable_count}) '
|
|
745
|
+
f'was not confirmed by the platform; operation_id={idempotency_key}',
|
|
746
|
+
operation_id=idempotency_key,
|
|
747
|
+
event_name=event_name,
|
|
748
|
+
count=chargeable_count,
|
|
749
|
+
)
|
|
750
|
+
elif (
|
|
751
|
+
chargeable_count > 0
|
|
752
|
+
and self._is_at_home
|
|
753
|
+
and not is_default_dataset_item_event(event_name)
|
|
754
|
+
and event_name not in self._per_event_prices
|
|
755
|
+
and self._pricing_info is not None
|
|
756
|
+
):
|
|
757
|
+
logger.warning("Attempting to charge for an unknown event '%s'", event_name)
|
|
758
|
+
|
|
759
|
+
if self._charging_log_dataset is not None:
|
|
760
|
+
try:
|
|
761
|
+
await self._charging_log_dataset.push_data({
|
|
762
|
+
'eventName': event_name,
|
|
763
|
+
'eventTitle': event_title,
|
|
764
|
+
'eventPriceUsd': float(event_price),
|
|
765
|
+
'chargedCount': chargeable_count,
|
|
766
|
+
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
767
|
+
})
|
|
768
|
+
except Exception as log_err:
|
|
769
|
+
logger.warning('Failed to append charging log entry: %s', log_err)
|
|
770
|
+
|
|
771
|
+
if chargeable_count < count:
|
|
772
|
+
subject = 'instance' if count == 1 else 'instances'
|
|
773
|
+
logger.info(
|
|
774
|
+
"Charging %s %s of '%s' event would exceed maxTotalChargeUsd - "
|
|
775
|
+
'only %s events were charged',
|
|
776
|
+
count,
|
|
777
|
+
subject,
|
|
778
|
+
event_name,
|
|
779
|
+
chargeable_count,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
return result
|
|
783
|
+
|
|
784
|
+
def calculate_max_event_charge_count_within_limit(self, event_name: str) -> Optional[int]:
|
|
785
|
+
"""Calculate how many events can be charged before reaching the limit.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
event_name: Name of the inspected event
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
Maximum count that can be charged, or None if no limit
|
|
792
|
+
"""
|
|
793
|
+
if self._max_total_charge_usd is None:
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
event_price = self._calculate_event_price(event_name)
|
|
797
|
+
if event_price is None or event_price <= 0:
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
remaining = self._max_total_charge_usd - self._total_charged_usd
|
|
801
|
+
return max(0, int(remaining / event_price))
|
|
802
|
+
|
|
803
|
+
def calculate_total_charged_amount(self) -> Decimal:
|
|
804
|
+
"""Calculate the total amount charged so far.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
Total charged amount in USD
|
|
808
|
+
"""
|
|
809
|
+
return self._total_charged_usd
|
|
810
|
+
|
|
811
|
+
def compute_chargeable(self) -> Dict[str, Optional[int]]:
|
|
812
|
+
"""Compute max events of each type that can be charged within budget.
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
Dictionary of event names to maximum chargeable counts
|
|
816
|
+
"""
|
|
817
|
+
result = {}
|
|
818
|
+
for event_name in self._per_event_prices:
|
|
819
|
+
result[event_name] = self.calculate_max_event_charge_count_within_limit(event_name)
|
|
820
|
+
return result
|
|
821
|
+
|
|
822
|
+
def compute_push_data_limit(
|
|
823
|
+
self,
|
|
824
|
+
items_count: int,
|
|
825
|
+
event_name: str,
|
|
826
|
+
*,
|
|
827
|
+
is_default_dataset: bool = False,
|
|
828
|
+
) -> int:
|
|
829
|
+
"""Compute how many items can be pushed and charged within budget.
|
|
830
|
+
|
|
831
|
+
Accounts for both the explicit event and the default dataset item event.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
items_count: Number of items to push
|
|
835
|
+
event_name: Explicit event name to charge for each item
|
|
836
|
+
is_default_dataset: Whether pushing to default dataset
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Maximum number of items that can be pushed within budget
|
|
840
|
+
"""
|
|
841
|
+
# Calculate combined price per item (effective prices: $1 per event locally)
|
|
842
|
+
explicit_price = (
|
|
843
|
+
self._calculate_event_price(event_name) or Decimal('0') if event_name else Decimal('0')
|
|
844
|
+
)
|
|
845
|
+
default_price = Decimal('0')
|
|
846
|
+
|
|
847
|
+
if is_default_dataset:
|
|
848
|
+
default_event = self._config.get('default_dataset_item_event_name')
|
|
849
|
+
if not default_event:
|
|
850
|
+
default_event = resolve_default_dataset_item_event_name(self._per_event_prices)
|
|
851
|
+
if default_event:
|
|
852
|
+
default_price = self._calculate_event_price(default_event) or Decimal('0')
|
|
853
|
+
|
|
854
|
+
combined_price = explicit_price + default_price
|
|
855
|
+
|
|
856
|
+
if combined_price <= 0:
|
|
857
|
+
return items_count
|
|
858
|
+
|
|
859
|
+
if self._max_total_charge_usd is None:
|
|
860
|
+
return items_count
|
|
861
|
+
|
|
862
|
+
remaining = self._max_total_charge_usd - self._total_charged_usd
|
|
863
|
+
max_items = int(remaining / combined_price)
|
|
864
|
+
|
|
865
|
+
return min(items_count, max(0, max_items))
|
|
866
|
+
|
|
867
|
+
def get_charged_event_count(self, event_name: str) -> int:
|
|
868
|
+
"""Get the number of events charged so far.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
event_name: Name of the inspected event
|
|
872
|
+
|
|
873
|
+
Returns:
|
|
874
|
+
Number of events charged
|
|
875
|
+
"""
|
|
876
|
+
if is_default_dataset_item_event(event_name):
|
|
877
|
+
return (
|
|
878
|
+
self._charged_events.get(APIFY_DEFAULT_DATASET_ITEM_EVENT, 0)
|
|
879
|
+
+ self._charged_events.get(SCRAPELY_DEFAULT_DATASET_ITEM_EVENT, 0)
|
|
880
|
+
)
|
|
881
|
+
return self._charged_events.get(event_name, 0)
|
|
882
|
+
|
|
883
|
+
def get_max_total_charge_usd(self) -> Decimal:
|
|
884
|
+
"""Get the configured maximum total charge.
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
Maximum total charge in USD
|
|
888
|
+
"""
|
|
889
|
+
# No configured cap (or zero) means unlimited — JS returns Infinity.
|
|
890
|
+
return self._max_total_charge_usd if self._max_total_charge_usd else Decimal('Infinity')
|
|
891
|
+
|
|
892
|
+
def get_pricing_info(self) -> ActorPricingInfoResult:
|
|
893
|
+
"""Retrieve detailed pricing information for the current Actor run.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
ActorPricingInfoResult with pricing details
|
|
897
|
+
"""
|
|
898
|
+
return ActorPricingInfoResult(
|
|
899
|
+
is_pay_per_event=self._pricing_info is not None,
|
|
900
|
+
max_total_charge_usd=(
|
|
901
|
+
self.get_max_total_charge_usd()
|
|
902
|
+
if self.get_max_total_charge_usd().is_finite()
|
|
903
|
+
else float('inf')
|
|
904
|
+
),
|
|
905
|
+
per_event_prices=self._per_event_prices,
|
|
906
|
+
pricing_model=PricingModel.PAY_PER_EVENT if self._pricing_info else None,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def is_event_charge_limit_reached(self, event_name: str) -> bool:
|
|
910
|
+
"""Check if the budget is insufficient to charge even one more event.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
event_name: Name of the event to check
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
True if no more events of this type can be charged
|
|
917
|
+
"""
|
|
918
|
+
event_price = self._calculate_event_price(event_name) or Decimal('0')
|
|
919
|
+
|
|
920
|
+
if self._max_total_charge_usd is None:
|
|
921
|
+
return False
|
|
922
|
+
|
|
923
|
+
remaining = self._max_total_charge_usd - self._total_charged_usd
|
|
924
|
+
return remaining < event_price
|
|
925
|
+
|
|
926
|
+
def _compute_chargeable(self) -> Dict[str, Optional[int]]:
|
|
927
|
+
"""Internal method to compute chargeable counts."""
|
|
928
|
+
return self.compute_chargeable()
|
|
929
|
+
|
|
930
|
+
def _is_event_limit_reached(self, event_name: str) -> bool:
|
|
931
|
+
"""Internal method to check if event limit is reached."""
|
|
932
|
+
return self.is_event_charge_limit_reached(event_name)
|
|
933
|
+
|
|
934
|
+
def get_charged_events(self) -> Dict[str, int]:
|
|
935
|
+
"""Get the count of charged events by event name.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
Dictionary of event names to counts
|
|
939
|
+
"""
|
|
940
|
+
return self._charged_events.copy()
|
|
941
|
+
|
|
942
|
+
def get_total_charged_usd(self) -> Decimal:
|
|
943
|
+
"""Get the total amount charged in USD.
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
Total charged amount
|
|
947
|
+
"""
|
|
948
|
+
return self._total_charged_usd
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _read_accepted_count(body: Any, fallback: int) -> int:
|
|
953
|
+
if not isinstance(body, dict):
|
|
954
|
+
return fallback
|
|
955
|
+
# Support both bare body and {data: ...} envelopes.
|
|
956
|
+
data = body.get('data') if isinstance(body.get('data'), dict) else body
|
|
957
|
+
raw = data.get('acceptedCount') if isinstance(data, dict) else None
|
|
958
|
+
if isinstance(raw, (int, float)) and raw >= 0:
|
|
959
|
+
return int(raw)
|
|
960
|
+
return fallback
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _classify_charge_http_error(
|
|
964
|
+
err: BaseException,
|
|
965
|
+
*,
|
|
966
|
+
operation_id: str,
|
|
967
|
+
event_name: str,
|
|
968
|
+
count: int,
|
|
969
|
+
) -> Optional[ChargeRejectedError]:
|
|
970
|
+
status = getattr(err, 'status_code', None)
|
|
971
|
+
if status is None:
|
|
972
|
+
response = getattr(err, 'response', None)
|
|
973
|
+
status = getattr(response, 'status_code', None) if response is not None else None
|
|
974
|
+
|
|
975
|
+
# 401/403: ownership or scoped-run authorization failures are permanent.
|
|
976
|
+
if status in (400, 401, 402, 403, 404, 405, 409):
|
|
977
|
+
return ChargeRejectedError(
|
|
978
|
+
f'Charge rejected by platform (HTTP {status}) for event {event_name!r}',
|
|
979
|
+
operation_id=operation_id,
|
|
980
|
+
event_name=event_name,
|
|
981
|
+
count=count,
|
|
982
|
+
status_code=int(status) if status is not None else None,
|
|
983
|
+
reason='charge-limit-exceeded' if status == 402 else f'http-{status}',
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
message = str(err)
|
|
987
|
+
if any(
|
|
988
|
+
token in message.lower()
|
|
989
|
+
for token in (
|
|
990
|
+
'charge-limit-exceeded',
|
|
991
|
+
'unknown-event',
|
|
992
|
+
'ledger-sealed',
|
|
993
|
+
'invalid-input',
|
|
994
|
+
'conflict',
|
|
995
|
+
)
|
|
996
|
+
):
|
|
997
|
+
return ChargeRejectedError(
|
|
998
|
+
message,
|
|
999
|
+
operation_id=operation_id,
|
|
1000
|
+
event_name=event_name,
|
|
1001
|
+
count=count,
|
|
1002
|
+
status_code=int(status) if isinstance(status, int) else None,
|
|
1003
|
+
reason='message-match',
|
|
1004
|
+
)
|
|
1005
|
+
return None
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
async def push_data_and_charge(
|
|
1009
|
+
charging_manager: ChargingManager,
|
|
1010
|
+
items: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
1011
|
+
event_name: Optional[str],
|
|
1012
|
+
is_default_dataset: bool,
|
|
1013
|
+
push_fn: Any,
|
|
1014
|
+
) -> ChargeResult:
|
|
1015
|
+
"""Push data to dataset and charge for each item.
|
|
1016
|
+
|
|
1017
|
+
Limits items to remaining budget before push (JS parity).
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
charging_manager: Charging manager instance
|
|
1021
|
+
items: Items to push
|
|
1022
|
+
event_name: Event name for charging
|
|
1023
|
+
is_default_dataset: Whether pushing to default dataset
|
|
1024
|
+
push_fn: Function to push items
|
|
1025
|
+
|
|
1026
|
+
Returns:
|
|
1027
|
+
ChargeResult
|
|
1028
|
+
"""
|
|
1029
|
+
if not isinstance(items, list):
|
|
1030
|
+
items = [items]
|
|
1031
|
+
|
|
1032
|
+
async with charging_manager._operation_lock:
|
|
1033
|
+
if charging_manager._closed:
|
|
1034
|
+
raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
|
|
1035
|
+
# Non-PPE actors push everything without charging (JS calculatePushDataLimits
|
|
1036
|
+
# parity: `if (!this.isPayPerEvent) return { limitedItems, eventsToCharge: {} }`).
|
|
1037
|
+
is_ppe = charging_manager._pricing_info is not None
|
|
1038
|
+
|
|
1039
|
+
resolved_event = event_name
|
|
1040
|
+
if is_ppe and not resolved_event and is_default_dataset:
|
|
1041
|
+
fallback_event = charging_manager._config.get('default_dataset_item_event_name')
|
|
1042
|
+
if not fallback_event:
|
|
1043
|
+
fallback_event = resolve_default_dataset_item_event_name(
|
|
1044
|
+
charging_manager._per_event_prices
|
|
1045
|
+
)
|
|
1046
|
+
if fallback_event:
|
|
1047
|
+
resolved_event = fallback_event
|
|
1048
|
+
|
|
1049
|
+
limited_count = len(items)
|
|
1050
|
+
if is_ppe and resolved_event:
|
|
1051
|
+
# Keep the explicit event separate from the implicit default-dataset
|
|
1052
|
+
# event. Passing resolved_event here would count the default price twice.
|
|
1053
|
+
limited_count = charging_manager.compute_push_data_limit(
|
|
1054
|
+
len(items),
|
|
1055
|
+
event_name or '',
|
|
1056
|
+
is_default_dataset=is_default_dataset,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
limited_items = items[:limited_count] if limited_count < len(items) else items
|
|
1060
|
+
|
|
1061
|
+
if not limited_items:
|
|
1062
|
+
return ChargeResult(
|
|
1063
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1064
|
+
charged_count=0,
|
|
1065
|
+
event_charge_limit_reached=len(items) > 0 and limited_count == 0,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
# Charge BEFORE persisting so a server cap/reject cannot leave unbilled
|
|
1069
|
+
# items in the dataset. Operation lock is held across both steps.
|
|
1070
|
+
if is_ppe and resolved_event:
|
|
1071
|
+
results: List[ChargeResult] = []
|
|
1072
|
+
default_event: Optional[str] = None
|
|
1073
|
+
if is_default_dataset:
|
|
1074
|
+
default_event = charging_manager._config.get('default_dataset_item_event_name')
|
|
1075
|
+
if not default_event:
|
|
1076
|
+
default_event = resolve_default_dataset_item_event_name(
|
|
1077
|
+
charging_manager._per_event_prices
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
# Synthetic storage event first (local-only); then explicit billable event.
|
|
1081
|
+
reserved_in_op: list[tuple[str, int]] = []
|
|
1082
|
+
try:
|
|
1083
|
+
if default_event:
|
|
1084
|
+
r = await charging_manager._charge_exclusive(default_event, len(limited_items))
|
|
1085
|
+
results.append(r)
|
|
1086
|
+
reserved_in_op.append((default_event, r.charged_count))
|
|
1087
|
+
if event_name and event_name != default_event:
|
|
1088
|
+
r = await charging_manager._charge_exclusive(event_name, len(limited_items))
|
|
1089
|
+
results.append(r)
|
|
1090
|
+
reserved_in_op.append((event_name, r.charged_count))
|
|
1091
|
+
except Exception:
|
|
1092
|
+
for name, reserved in reserved_in_op:
|
|
1093
|
+
if reserved > 0:
|
|
1094
|
+
charging_manager._unwind_reservation(name, reserved)
|
|
1095
|
+
raise
|
|
1096
|
+
|
|
1097
|
+
if results:
|
|
1098
|
+
# Pair each result with the event it reserved so we can unwind
|
|
1099
|
+
# over-reservations when the min accepted count is lower.
|
|
1100
|
+
charged_events: List[tuple[str, ChargeResult]] = []
|
|
1101
|
+
if default_event:
|
|
1102
|
+
charged_events.append((default_event, results[0]))
|
|
1103
|
+
if event_name and event_name != default_event:
|
|
1104
|
+
charged_events.append((event_name, results[-1]))
|
|
1105
|
+
|
|
1106
|
+
accepted_for_items = min(
|
|
1107
|
+
len(limited_items),
|
|
1108
|
+
*(result.charged_count for result in results),
|
|
1109
|
+
)
|
|
1110
|
+
# Unwind any event that reserved more than we will persist
|
|
1111
|
+
# (synthetic default-dataset is local-only and otherwise sticks).
|
|
1112
|
+
for name, result in charged_events:
|
|
1113
|
+
if result.charged_count > accepted_for_items:
|
|
1114
|
+
charging_manager._unwind_reservation(
|
|
1115
|
+
name, result.charged_count - accepted_for_items
|
|
1116
|
+
)
|
|
1117
|
+
if accepted_for_items > 0:
|
|
1118
|
+
try:
|
|
1119
|
+
await push_fn(limited_items[:accepted_for_items])
|
|
1120
|
+
except Exception as push_err:
|
|
1121
|
+
logger.error(
|
|
1122
|
+
'Dataset push failed after charge accepted (items=%s): %s',
|
|
1123
|
+
accepted_for_items,
|
|
1124
|
+
push_err,
|
|
1125
|
+
)
|
|
1126
|
+
raise
|
|
1127
|
+
return ChargeResult(
|
|
1128
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1129
|
+
charged_count=accepted_for_items * len(results) if results else 0,
|
|
1130
|
+
event_charge_limit_reached=any(
|
|
1131
|
+
result.event_charge_limit_reached for result in results
|
|
1132
|
+
)
|
|
1133
|
+
or accepted_for_items < len(limited_items),
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
# No billable events — still persist the budget-limited items.
|
|
1138
|
+
await push_fn(limited_items)
|
|
1139
|
+
return ChargeResult(
|
|
1140
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1141
|
+
charged_count=0,
|
|
1142
|
+
event_charge_limit_reached=False,
|
|
1143
|
+
)
|