scrapely-python 0.1.10__tar.gz → 0.1.12__tar.gz
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_python-0.1.10/src/scrapely_python.egg-info → scrapely_python-0.1.12}/PKG-INFO +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/pyproject.toml +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/actor.py +13 -2
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/charging.py +228 -28
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/charging_client.py +5 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/api_client.py +132 -98
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/scrapely_dataset_client.py +6 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_api_client_retries.py +80 -9
- scrapely_python-0.1.12/tests/test_charge_limit_guard.py +291 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_charge_retries.py +74 -20
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_storage.py +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_terminal_status_message.py +1 -1
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/LICENSE +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/NOTICE +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/README.md +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/setup.cfg +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_input_proxy_aliases.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.10 → scrapely_python-0.1.12}/tests/test_secrets.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.12"
|
|
8
8
|
description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT AND Apache-2.0"
|
|
@@ -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
|
|
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 ~
|
|
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
|
|
|
@@ -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 /
|
|
51
|
-
# client itself retries 5xx answers and refused or reset
|
|
52
|
-
# POSTs with
|
|
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),
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
|
573
|
-
|
|
574
|
-
errors
|
|
575
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|