scrapely 1.2.15 → 1.2.16

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.
package/dist/actor.js CHANGED
@@ -273,6 +273,8 @@ class Actor {
273
273
  apifyClientInstance = this._apifyClient;
274
274
  apifyClientAssigned = true;
275
275
  await this.chargingManager.init();
276
+ // Storage clients refuse default-dataset writes past this run's limit.
277
+ (0, charging_js_1.setActiveChargingManager)(this.chargingManager);
276
278
  // Flush pending PPE intents before hard exit so uncertain charges can still settle.
277
279
  const gracefulSignalExit = (signal) => {
278
280
  const doExit = () => {
@@ -517,6 +519,14 @@ class Actor {
517
519
  const { Dataset } = await import('crawlee');
518
520
  const dataset = await Dataset.open();
519
521
  if (eventName === undefined && !this.usesPushDataInterception(dataset)) {
522
+ // Charge-limit safety net (also for local storage): this push is not
523
+ // charged, so past the run's spending limit it is not stored.
524
+ if (this.initialized &&
525
+ dataset.id === this.config.get('defaultDatasetId') &&
526
+ this.chargingManager.isChargeLimitReached()) {
527
+ this.chargingManager._noteRefusedDefaultDatasetItems(Array.isArray(item) ? item.length : 1);
528
+ return;
529
+ }
520
530
  await dataset.pushData(item);
521
531
  return;
522
532
  }
@@ -951,6 +961,7 @@ class Actor {
951
961
  await eventManager.close();
952
962
  (0, events_client_js_1.disconnectEvents)();
953
963
  (0, patch_storage_js_1.disconnectStorage)();
964
+ (0, charging_js_1.setActiveChargingManager)(undefined);
954
965
  // Let buffered stdout/stderr reach the log pipe before the process exits
955
966
  // (pipe writes are async in Node). Storage was flushed above, so this
956
967
  // replaces the old fixed 1 s sleep: it resolves as soon as both streams
@@ -29,6 +29,17 @@ export declare class ChargeRejectedError extends Error {
29
29
  reason: string;
30
30
  });
31
31
  }
32
+ /** @internal Register the manager whose limit guards default-dataset writes. */
33
+ export declare function setActiveChargingManager(manager: ChargingManager | undefined): void;
34
+ /** @internal The run's default dataset ID, as the platform passes it. */
35
+ export declare function defaultDatasetIdFromEnv(): string | undefined;
36
+ /**
37
+ * @internal True when a write of `itemCount` rows to the default dataset must
38
+ * be dropped (pay-per-event run past its spending limit). Called by storage
39
+ * clients for writes that did not go through `pushDataAndCharge`
40
+ * (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
41
+ */
42
+ export declare function refuseDefaultDatasetPush(itemCount: number): boolean;
32
43
  export declare const APIFY_DEFAULT_DATASET_ITEM_EVENT = "apify-default-dataset-item";
33
44
  /** Scrapely-native synthetic event for default-dataset items. */
34
45
  export declare const SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = "scrapely-default-dataset-item";
@@ -62,8 +73,32 @@ export declare class ChargingManager {
62
73
  private readonly apifyClient;
63
74
  /** Set during actor shutdown; rejects new charge operations after the final flush. */
64
75
  private _closed;
76
+ /** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
77
+ private chargeLimitHit;
78
+ /** Default-dataset rows refused after the limit (one warning, then a count). */
79
+ private refusedDefaultDatasetItems;
65
80
  /** @internal Called by Actor shutdown before the final flush. */
66
81
  close(): void;
82
+ /**
83
+ * Whether this pay-per-event run has used up its spending limit.
84
+ *
85
+ * True once a charge was refused or capped by the limit (the platform's
86
+ * HTTP 402 or a partial acceptance, or the local budget gate), or once the
87
+ * remaining budget cannot pay for one event of the cheapest priced type.
88
+ * From then on, pushes to the default dataset that are not paid for are
89
+ * refused. Always false for actors without pay-per-event pricing, without
90
+ * a limit, or with only free events.
91
+ */
92
+ isChargeLimitReached(): boolean;
93
+ private markChargeLimitHit;
94
+ /** @internal Count rows dropped because of the limit; warn on the first one only. */
95
+ _noteRefusedDefaultDatasetItems(count: number): void;
96
+ /** @internal */
97
+ _markChargeLimitHit(): void;
98
+ /** @internal Whether every row of a push with these events is charged a positive price. */
99
+ _isPaidPerItem(eventName: string | undefined, isDefaultDataset: boolean): boolean;
100
+ /** @internal */
101
+ _refuseDefaultDatasetPush(itemCount: number): boolean;
67
102
  /** @internal Whether shutdown has closed this manager to new charges. */
68
103
  isClosed(): boolean;
69
104
  private readonly LOCAL_CHARGING_LOG_DATASET_NAME;
package/dist/charging.js CHANGED
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ChargingManager = exports.DEFAULT_DATASET_ITEM_EVENT = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = void 0;
4
+ exports.setActiveChargingManager = setActiveChargingManager;
5
+ exports.defaultDatasetIdFromEnv = defaultDatasetIdFromEnv;
6
+ exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
4
7
  exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
5
8
  exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
6
9
  exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
@@ -11,6 +14,7 @@ exports.pushCertainlyFailed = pushCertainlyFailed;
11
14
  // Derived from apify-sdk-js (src/charging.ts),
12
15
  // Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
13
16
  // Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
17
+ const node_async_hooks_1 = require("node:async_hooks");
14
18
  const node_crypto_1 = require("node:crypto");
15
19
  /** Client safe-replay window; server TTL is longer (35d). */
16
20
  exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -48,6 +52,44 @@ class ChargeRejectedError extends Error {
48
52
  }
49
53
  }
50
54
  exports.ChargeRejectedError = ChargeRejectedError;
55
+ /** True when a ChargeRejectedError means the run's spending limit is used up. */
56
+ function isChargeLimitRejection(err) {
57
+ return (err.statusCode === 402 ||
58
+ err.reason === 'charge-limit-exceeded' ||
59
+ /charge-limit-exceeded/i.test(err.message));
60
+ }
61
+ /*
62
+ * Charge-limit safety net for the default dataset.
63
+ *
64
+ * Once a pay-per-event run has used up its spending limit (maxTotalChargeUsd),
65
+ * rows written to the run's default dataset would go out unbilled. The SDK then
66
+ * refuses such writes: nothing is stored, one warning is logged and
67
+ * `ChargingManager.isChargeLimitReached()` turns true. Pushes made by
68
+ * `pushDataAndCharge` are decided there and run inside `chargeGatedPush`, so
69
+ * the storage client does not check them twice.
70
+ */
71
+ const chargeGatedPush = new node_async_hooks_1.AsyncLocalStorage();
72
+ /** The charging manager of the running Actor; storage clients consult it. */
73
+ let activeChargingManager;
74
+ /** @internal Register the manager whose limit guards default-dataset writes. */
75
+ function setActiveChargingManager(manager) {
76
+ activeChargingManager = manager;
77
+ }
78
+ /** @internal The run's default dataset ID, as the platform passes it. */
79
+ function defaultDatasetIdFromEnv() {
80
+ return process.env.ACTOR_DEFAULT_DATASET_ID || process.env.APIFY_DEFAULT_DATASET_ID || undefined;
81
+ }
82
+ /**
83
+ * @internal True when a write of `itemCount` rows to the default dataset must
84
+ * be dropped (pay-per-event run past its spending limit). Called by storage
85
+ * clients for writes that did not go through `pushDataAndCharge`
86
+ * (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
87
+ */
88
+ function refuseDefaultDatasetPush(itemCount) {
89
+ if (chargeGatedPush.getStore())
90
+ return false;
91
+ return activeChargingManager?._refuseDefaultDatasetPush(itemCount) ?? false;
92
+ }
51
93
  exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item';
52
94
  /** Scrapely-native synthetic event for default-dataset items. */
53
95
  exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item';
@@ -95,9 +137,77 @@ class ChargingManager {
95
137
  apifyClient;
96
138
  /** Set during actor shutdown; rejects new charge operations after the final flush. */
97
139
  _closed = false;
140
+ /** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
141
+ chargeLimitHit = false;
142
+ /** Default-dataset rows refused after the limit (one warning, then a count). */
143
+ refusedDefaultDatasetItems = 0;
98
144
  /** @internal Called by Actor shutdown before the final flush. */
99
145
  close() {
100
146
  this._closed = true;
147
+ if (this.refusedDefaultDatasetItems > 0) {
148
+ console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after the spending limit was reached were not saved`);
149
+ }
150
+ }
151
+ /**
152
+ * Whether this pay-per-event run has used up its spending limit.
153
+ *
154
+ * True once a charge was refused or capped by the limit (the platform's
155
+ * HTTP 402 or a partial acceptance, or the local budget gate), or once the
156
+ * remaining budget cannot pay for one event of the cheapest priced type.
157
+ * From then on, pushes to the default dataset that are not paid for are
158
+ * refused. Always false for actors without pay-per-event pricing, without
159
+ * a limit, or with only free events.
160
+ */
161
+ isChargeLimitReached() {
162
+ if (this.chargingState === undefined || !this.isPayPerEvent)
163
+ return false;
164
+ if (this.chargeLimitHit)
165
+ return true;
166
+ if (!Number.isFinite(this.maxTotalChargeUsd))
167
+ return false;
168
+ const prices = Object.values(this.pricingInfo)
169
+ .map(({ price }) => price)
170
+ .filter((price) => price > 0);
171
+ if (prices.length === 0)
172
+ return false;
173
+ // Same rounding as the budget gate, so 0.03 - 0.02 still pays a $0.01 event.
174
+ return this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0;
175
+ }
176
+ markChargeLimitHit() {
177
+ if (this.isPayPerEvent)
178
+ this.chargeLimitHit = true;
179
+ }
180
+ /** @internal Count rows dropped because of the limit; warn on the first one only. */
181
+ _noteRefusedDefaultDatasetItems(count) {
182
+ if (count <= 0)
183
+ return;
184
+ if (this.refusedDefaultDatasetItems === 0) {
185
+ const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
186
+ console.warn(`[Scrapely] The spending limit of this run (maxTotalChargeUsd = ${limit}) has been reached: ` +
187
+ 'results pushed to the default dataset are no longer saved. The Actor should stop now ' +
188
+ '(check ChargeResult.eventChargeLimitReached or Actor.getChargingManager().isChargeLimitReached()).');
189
+ }
190
+ this.refusedDefaultDatasetItems += count;
191
+ }
192
+ /** @internal */
193
+ _markChargeLimitHit() {
194
+ this.markChargeLimitHit();
195
+ }
196
+ /** @internal Whether every row of a push with these events is charged a positive price. */
197
+ _isPaidPerItem(eventName, isDefaultDataset) {
198
+ const names = [];
199
+ if (eventName !== undefined)
200
+ names.push(eventName);
201
+ if (isDefaultDataset)
202
+ names.push(resolveDefaultDatasetItemEventName(this.pricingInfo));
203
+ return names.some((name) => (this.calculateEventPrice(name) ?? 0) > 0);
204
+ }
205
+ /** @internal */
206
+ _refuseDefaultDatasetPush(itemCount) {
207
+ if (itemCount <= 0 || !this.isChargeLimitReached())
208
+ return false;
209
+ this._noteRefusedDefaultDatasetItems(itemCount);
210
+ return true;
101
211
  }
102
212
  /** @internal Whether shutdown has closed this manager to new charges. */
103
213
  isClosed() {
@@ -538,6 +648,8 @@ class ChargingManager {
538
648
  try {
539
649
  const accepted = await this.postChargeWithRetries(intent);
540
650
  if (accepted !== null) {
651
+ if (accepted < intent.count)
652
+ this.markChargeLimitHit();
541
653
  const eventPrice = this.getEventPricing(intent.eventName).price;
542
654
  this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
543
655
  this.pendingCharges.delete(intent.idempotencyKey);
@@ -546,6 +658,8 @@ class ChargingManager {
546
658
  }
547
659
  catch (err) {
548
660
  if (err instanceof ChargeRejectedError) {
661
+ if (isChargeLimitRejection(err))
662
+ this.markChargeLimitHit();
549
663
  const eventPrice = this.getEventPricing(intent.eventName).price;
550
664
  this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
551
665
  this.pendingCharges.delete(intent.idempotencyKey);
@@ -613,6 +727,8 @@ class ChargingManager {
613
727
  }
614
728
  return 0;
615
729
  })();
730
+ if (chargedCount < count)
731
+ this.markChargeLimitHit();
616
732
  if (chargedCount === 0) {
617
733
  return {
618
734
  eventChargeLimitReached: count > 0,
@@ -655,6 +771,8 @@ class ChargingManager {
655
771
  }
656
772
  catch (err) {
657
773
  if (err instanceof ChargeRejectedError) {
774
+ if (isChargeLimitRejection(err))
775
+ this.markChargeLimitHit();
658
776
  // Server definitively rejected: unwind local reservation.
659
777
  this.pendingCharges.delete(intent.idempotencyKey);
660
778
  chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
@@ -670,6 +788,9 @@ class ChargingManager {
670
788
  this.pendingCharges.delete(intent.idempotencyKey);
671
789
  if (accepted > 0)
672
790
  this.lastAcceptedOperationId = intent.idempotencyKey;
791
+ // The platform capped the charge at the run's limit.
792
+ if (accepted < chargedCount)
793
+ this.markChargeLimitHit();
673
794
  chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
674
795
  }
675
796
  else {
@@ -772,6 +893,22 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
772
893
  // A void that did not go through for an earlier failed push must land
773
894
  // before this push charges again, or one unsaved result is billed twice.
774
895
  const voidsConfirmed = (await chargingManager._retryPendingVoids?.()) !== false;
896
+ const requested = Array.isArray(items) ? items.length : 1;
897
+ // Charge-limit safety net: once the run's spending limit is used up, a
898
+ // default-dataset push that does not pay per row (no event, or only free
899
+ // events) stores nothing. Paid pushes go on below, where the budget cap
900
+ // stores only the rows that are actually charged.
901
+ if (isDefaultDataset &&
902
+ requested > 0 &&
903
+ chargingManager.isChargeLimitReached?.() &&
904
+ !chargingManager._isPaidPerItem(eventName, isDefaultDataset)) {
905
+ chargingManager._noteRefusedDefaultDatasetItems(requested);
906
+ return {
907
+ eventChargeLimitReached: true,
908
+ chargedCount: 0,
909
+ chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {},
910
+ };
911
+ }
775
912
  const { limitedItems, eventsToCharge } = chargingManager.calculatePushDataLimits({
776
913
  items,
777
914
  eventName,
@@ -787,6 +924,11 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
787
924
  }
788
925
  const itemsArray = Array.isArray(items) ? items : [items];
789
926
  const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
927
+ if (isDefaultDataset && limitedItems.length < itemsArray.length) {
928
+ // Rows beyond what the budget can pay for are dropped.
929
+ chargingManager._markChargeLimitHit?.();
930
+ chargingManager._noteRefusedDefaultDatasetItems?.(itemsArray.length - limitedItems.length);
931
+ }
790
932
  if (limitedItems.length === 0) {
791
933
  return {
792
934
  eventChargeLimitReached: allItemsTrimmed,
@@ -875,6 +1017,9 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
875
1017
  // Report rows charged, as Apify does for the pushed event: summing the
876
1018
  // explicit and default-dataset events would count every row twice.
877
1019
  chargeResult = { ...results.reduce(mergeChargeResults), chargedCount: acceptedForItems };
1020
+ if (isDefaultDataset && acceptedForItems < limitedItems.length) {
1021
+ chargingManager._noteRefusedDefaultDatasetItems?.(limitedItems.length - acceptedForItems);
1022
+ }
878
1023
  if (acceptedForItems <= 0) {
879
1024
  // Nothing will be stored (another event of this push was capped to
880
1025
  // zero): undo every platform charge this operation made, so no
@@ -898,7 +1043,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
898
1043
  }
899
1044
  const toPersist = limitedItems.slice(0, acceptedForItems);
900
1045
  try {
901
- await pushFn(Array.isArray(items) ? toPersist : toPersist[0]);
1046
+ await chargeGatedPush.run(true, () => pushFn(Array.isArray(items) ? toPersist : toPersist[0]));
902
1047
  }
903
1048
  catch (pushErr) {
904
1049
  // Storage failed after the charge was accepted: a result is billed
@@ -934,7 +1079,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
934
1079
  return chargeResult;
935
1080
  }
936
1081
  // No billable events — still persist the budget-limited items.
937
- await pushFn(Array.isArray(items) ? limitedItems : limitedItems[0]);
1082
+ await chargeGatedPush.run(true, () => pushFn(Array.isArray(items) ? limitedItems : limitedItems[0]));
938
1083
  return {
939
1084
  eventChargeLimitReached: false,
940
1085
  chargedCount: 0,
@@ -17,7 +17,11 @@ function createChargingHooks(actor) {
17
17
  // Called by the client at `dataset()` time, so pricing is consulted
18
18
  // lazily — same timing as the former subclass override.
19
19
  shouldChargeDatasetPushes() {
20
- return (0, charging_js_1.hasDefaultDatasetItemEventInPricing)(actor.getChargingManager().getPricingInfo().perEventPrices);
20
+ const chargingManager = actor.getChargingManager();
21
+ // Past the spending limit, route default-dataset pushes through
22
+ // pushDataAndCharge too, which refuses rows nobody pays for.
23
+ return ((0, charging_js_1.hasDefaultDatasetItemEventInPricing)(chargingManager.getPricingInfo().perEventPrices) ||
24
+ (chargingManager.isChargeLimitReached?.() ?? false));
21
25
  },
22
26
  async pushAndCharge({ items, eventName, pushFn }) {
23
27
  return (0, charging_js_1.pushDataAndCharge)({
@@ -5,6 +5,7 @@ exports.isRetryableResponse = isRetryableResponse;
5
5
  exports.serverRetryDelayMs = serverRetryDelayMs;
6
6
  exports.isConnectFailure = isConnectFailure;
7
7
  const node_crypto_1 = require("node:crypto");
8
+ const charging_js_1 = require("../charging.js");
8
9
  const kvs_iteration_js_1 = require("./kvs-iteration.js");
9
10
  function buildRqQueryParams(baseClientKey, options) {
10
11
  const params = new URLSearchParams();
@@ -559,6 +560,10 @@ class ApiDatasetClient extends BaseApiClient {
559
560
  else {
560
561
  parsed = [items];
561
562
  }
563
+ // Charge-limit safety net: a pay-per-event run past its spending limit
564
+ // must not store unbilled rows in its default dataset.
565
+ if (this.id === (0, charging_js_1.defaultDatasetIdFromEnv)() && (0, charging_js_1.refuseDefaultDatasetPush)(parsed.length))
566
+ return;
562
567
  await this.request('POST', `/datasets/${this.id}/items`, parsed);
563
568
  }
564
569
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.15",
3
+ "version": "1.2.16",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,6 +22,7 @@
22
22
  "scripts": {
23
23
  "test": "npx tsx tests/test-mvp.ts",
24
24
  "test:charging": "npx tsx --test tests/charging-pending.test.ts",
25
+ "test:charge-limit-guard": "npx tsx --test tests/charge-limit-guard.test.ts",
25
26
  "test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
26
27
  "test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
27
28
  "test:charging-client": "npx tsx --test tests/charging-client.test.ts",