scrapely 1.2.37 → 1.2.39

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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.39] - 2026-10-29
8
+
9
+ - A refused charge names the limit that refused it. When a charge is refused or cut short, the platform now says why (HTTP 402 `error.data.reason`, and `limitedBy` on a partial acceptance): the run's item limit (`maxItems`), its maximum total charge (`maxTotalChargeUsd`) or the account balance. The SDK used to call all of them the spending limit. Now its log lines name the real limit; `ChargingManager.getChargeLimitReason()` returns `'max-items'`, `'max-charge'` or `'insufficient-funds'` and `getChargeLimitMessage()` the stop message ("Stopped: reached the item limit you set (800 items)", "Stopped: reached the maximum charge you set ($3.00)", "Stopped: your account has no credit left for this run"); and once a limit was reached, a status message (`Actor.setStatusMessage`, `Actor.exit`, `Actor.fail`) whose clause blames the maximum charge gets that clause replaced by the real stop message. Platforms that do not send the reason yet: inferred from the 402 message and `ACTOR_MAX_PAID_DATASET_ITEMS`. What is charged and saved does not change.
10
+
11
+ ## [1.2.38] - 2026-10-27
12
+
13
+ - A key-value store record write (`Actor.setValue`, `KeyValueStore.setValue`) is no longer lost to one failed answer. Each write now sends an `Idempotency-Key`, and when its answer is lost (a `502`/`504` page from the edge or load balancer, a dropped connection, a timeout) it is sent again with the same key; the API applies and counts it once. Before, the write was never re-sent, so one such page on the final `OUTPUT` write failed the whole run. An API that answers a keyed write without `X-Scrapely-Idempotency` (an older server) gets no re-sends, as before.
14
+
7
15
  ## [1.2.37] - 2026-10-26
8
16
 
9
17
  - Opening a named dataset, key-value store or request queue (`Actor.openDataset('name')` and the like) retries when the API answers `503 storage-name-busy`. The API sends it when another request kept creating or renaming a storage of that name for over 15 s; nothing was changed, so the open is simply repeated. It used to fail the open.
package/dist/actor.js CHANGED
@@ -1157,6 +1157,9 @@ class Actor {
1157
1157
  static async setStatusMessage(message, options) {
1158
1158
  const { isStatusMessageTerminal, level } = options || {};
1159
1159
  Actor._ensureActorInit('setStatusMessage');
1160
+ // Actors word a refused charge as "the maximum charge for this run was
1161
+ // reached" whatever refused it; name the limit that really stopped the run.
1162
+ message = (0, charging_js_2.truthfulStatusMessage)(message);
1160
1163
  const loggedStatusMessage = `[Status message]: ${message}`;
1161
1164
  switch (level) {
1162
1165
  case 'DEBUG':
@@ -21,14 +21,40 @@ export declare class ChargeRejectedError extends Error {
21
21
  readonly count: number;
22
22
  readonly statusCode?: number;
23
23
  readonly reason: string;
24
+ /** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
25
+ readonly limitReason?: ChargeLimitReason;
26
+ readonly maxItems?: number;
24
27
  constructor(message: string, opts: {
25
28
  operationId: string;
26
29
  eventName: string;
27
30
  count: number;
28
31
  statusCode?: number;
29
32
  reason: string;
33
+ limitReason?: ChargeLimitReason;
34
+ maxItems?: number;
30
35
  });
31
36
  }
37
+ /**
38
+ * Which run limit refused or cut down a charge: the platform's `error.reason`
39
+ * on HTTP 402 and `limitedBy` on a partial acceptance. 'max-items' is the item
40
+ * limit the user set (maxItems), 'max-charge' the maximum total charge
41
+ * (maxTotalChargeUsd), 'insufficient-funds' the account balance. The run's
42
+ * stop message names it, so an item limit is never called a spending limit.
43
+ */
44
+ export type ChargeLimitReason = 'max-items' | 'max-charge' | 'insufficient-funds';
45
+ /** The run's stop message for a charge limit, or undefined when the reason is unknown. */
46
+ export declare function chargeLimitStopMessage(reason: ChargeLimitReason | undefined, opts?: {
47
+ maxItems?: number | null;
48
+ maxTotalChargeUsd?: number | null;
49
+ }): string | undefined;
50
+ /**
51
+ * Replace the clause of `message` that blames the spending limit with
52
+ * `stopMessage`: "Saved 800 results; stopped because the maximum charge for
53
+ * this run was reached" becomes "Saved 800 results. Stopped: reached the item
54
+ * limit you set (800 items)". Only the first such clause is replaced (later
55
+ * ones are dropped); a message without one is returned as is.
56
+ */
57
+ export declare function rewriteChargeLimitClaims(message: string, stopMessage: string | undefined): string;
32
58
  /** @internal Register the manager whose limit guards default-dataset writes. */
33
59
  export declare function setActiveChargingManager(manager: ChargingManager | undefined): void;
34
60
  /** @internal The run's default dataset ID, as the platform passes it. */
@@ -39,6 +65,8 @@ export declare function defaultDatasetIdFromEnv(): string | undefined;
39
65
  * clients for writes that did not go through `pushDataAndCharge`
40
66
  * (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
41
67
  */
68
+ /** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
69
+ export declare function truthfulStatusMessage(message: string): string;
42
70
  export declare function refuseDefaultDatasetPush(itemCount: number): boolean;
43
71
  export declare const APIFY_DEFAULT_DATASET_ITEM_EVENT = "apify-default-dataset-item";
44
72
  /** Scrapely-native synthetic event for default-dataset items. */
@@ -74,6 +102,12 @@ export declare class ChargingManager {
74
102
  private _closed;
75
103
  /** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
76
104
  private chargeLimitHit;
105
+ /** Which limit that was (the first one known wins) and the item limit the platform reported. */
106
+ private chargeLimitReason;
107
+ private chargeLimitMaxItems;
108
+ /** limitedBy / maxItems of the last partially accepted charge response. */
109
+ private lastLimitedBy;
110
+ private lastLimitMaxItems;
77
111
  /** Default-dataset rows refused after the limit (one warning, then a count). */
78
112
  private refusedDefaultDatasetItems;
79
113
  /**
@@ -124,10 +158,33 @@ export declare class ChargingManager {
124
158
  */
125
159
  isChargeLimitReached(): boolean;
126
160
  private markChargeLimitHit;
161
+ private markRejectedCharge;
162
+ /** The platform accepted fewer events than asked: record which limit cut it. */
163
+ private markPartialCharge;
164
+ /** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
165
+ private maxItemsSetting;
166
+ /**
167
+ * Why this run's charges stopped being accepted, or undefined while they
168
+ * still are: 'max-items' (the item limit the user set), 'max-charge' (the
169
+ * maximum total charge) or 'insufficient-funds' (the account balance).
170
+ * Platforms that do not say which limit it was: inferred from the run's settings.
171
+ */
172
+ getChargeLimitReason(): ChargeLimitReason | undefined;
173
+ /**
174
+ * The run's stop message for the limit that was reached, or undefined:
175
+ * "Stopped: reached the item limit you set (800 items)", "Stopped: reached
176
+ * the maximum charge you set ($3.00)", ... Use it as (part of) the final
177
+ * status message when the Actor stops because a charge was refused.
178
+ */
179
+ getChargeLimitMessage(): string | undefined;
180
+ /** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
181
+ rewriteStatusMessage(message: string): string;
182
+ /** 'the item limit of this run (maxItems = 800)', ... for log lines. */
183
+ private limitName;
127
184
  /** @internal Count rows dropped because of the limit; warn on the first one only. */
128
185
  _noteRefusedDefaultDatasetItems(count: number): void;
129
- /** @internal */
130
- _markChargeLimitHit(): void;
186
+ /** @internal `reason`: the limit that trimmed the push, when known. */
187
+ _markChargeLimitHit(reason?: ChargeLimitReason): void;
131
188
  /** @internal Whether every row of a push with these events is charged a positive price. */
132
189
  _isPaidPerItem(eventName: string | undefined, isDefaultDataset: boolean): boolean;
133
190
  /** @internal */
package/dist/charging.js CHANGED
@@ -1,8 +1,11 @@
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.chargeLimitStopMessage = chargeLimitStopMessage;
5
+ exports.rewriteChargeLimitClaims = rewriteChargeLimitClaims;
4
6
  exports.setActiveChargingManager = setActiveChargingManager;
5
7
  exports.defaultDatasetIdFromEnv = defaultDatasetIdFromEnv;
8
+ exports.truthfulStatusMessage = truthfulStatusMessage;
6
9
  exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
7
10
  exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
8
11
  exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
@@ -57,6 +60,9 @@ class ChargeRejectedError extends Error {
57
60
  count;
58
61
  statusCode;
59
62
  reason;
63
+ /** Which run limit refused the charge, when the platform said (see ChargeLimitReason). */
64
+ limitReason;
65
+ maxItems;
60
66
  constructor(message, opts) {
61
67
  super(message);
62
68
  this.name = 'ChargeRejectedError';
@@ -65,9 +71,99 @@ class ChargeRejectedError extends Error {
65
71
  this.count = opts.count;
66
72
  this.statusCode = opts.statusCode;
67
73
  this.reason = opts.reason;
74
+ this.limitReason = opts.limitReason;
75
+ this.maxItems = opts.maxItems;
68
76
  }
69
77
  }
70
78
  exports.ChargeRejectedError = ChargeRejectedError;
79
+ const CHARGE_LIMIT_REASONS = new Set(['max-items', 'max-charge', 'insufficient-funds']);
80
+ function asChargeLimitReason(raw) {
81
+ return typeof raw === 'string' && CHARGE_LIMIT_REASONS.has(raw) ? raw : undefined;
82
+ }
83
+ function positiveInt(raw) {
84
+ return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
85
+ }
86
+ /** $3.00, $0.005: two decimals unless the amount needs more. */
87
+ function formatUsd(usd) {
88
+ return Math.abs(usd * 100 - Math.round(usd * 100)) < 1e-9 ? `$${usd.toFixed(2)}` : `$${Number(usd.toPrecision(6))}`;
89
+ }
90
+ /** The run's stop message for a charge limit, or undefined when the reason is unknown. */
91
+ function chargeLimitStopMessage(reason, opts = {}) {
92
+ if (reason === 'max-items') {
93
+ const n = opts.maxItems;
94
+ return n && n > 0
95
+ ? `Stopped: reached the item limit you set (${n} ${n === 1 ? 'item' : 'items'})`
96
+ : 'Stopped: reached the item limit you set';
97
+ }
98
+ if (reason === 'max-charge') {
99
+ const usd = opts.maxTotalChargeUsd;
100
+ return typeof usd === 'number' && Number.isFinite(usd)
101
+ ? `Stopped: reached the maximum charge you set (${formatUsd(usd)})`
102
+ : 'Stopped: reached the maximum charge you set';
103
+ }
104
+ if (reason === 'insufficient-funds')
105
+ return 'Stopped: your account has no credit left for this run';
106
+ return undefined;
107
+ }
108
+ // A status-message clause that blames the spending limit ("stopped because the
109
+ // maximum charge for this run was reached", "The run reached its maximum total
110
+ // charge. Stopping.", ...). Actors written before the platform said which limit
111
+ // was hit use such texts for every refused charge.
112
+ const CHARGE_CLAIM_RE = /\bmax(?:imum)?\s+(?:total\s+)?charge|\bspending\s+limit/i;
113
+ const CLAUSE_SEPARATOR_RE = /(;\s*|[.!?](?:\s+|$))/;
114
+ /**
115
+ * Replace the clause of `message` that blames the spending limit with
116
+ * `stopMessage`: "Saved 800 results; stopped because the maximum charge for
117
+ * this run was reached" becomes "Saved 800 results. Stopped: reached the item
118
+ * limit you set (800 items)". Only the first such clause is replaced (later
119
+ * ones are dropped); a message without one is returned as is.
120
+ */
121
+ function rewriteChargeLimitClaims(message, stopMessage) {
122
+ if (!message || !stopMessage || message.includes(stopMessage) || !CHARGE_CLAIM_RE.test(message))
123
+ return message;
124
+ const parts = message.split(CLAUSE_SEPARATOR_RE);
125
+ const out = [];
126
+ let replaced = false;
127
+ for (let i = 0; i < parts.length; i += 2) {
128
+ const clause = parts[i] ?? '';
129
+ const sep = parts[i + 1] ?? '';
130
+ if (!CHARGE_CLAIM_RE.test(clause)) {
131
+ out.push(clause + sep);
132
+ continue;
133
+ }
134
+ if (replaced)
135
+ continue;
136
+ replaced = true;
137
+ const prev = out.length ? out[out.length - 1].trimEnd() : '';
138
+ if (prev.endsWith(';'))
139
+ out[out.length - 1] = `${prev.slice(0, -1).trimEnd()}. `;
140
+ const lead = clause.slice(0, clause.length - clause.trimStart().length);
141
+ let tail = '';
142
+ if (sep)
143
+ tail = [';', '.', '!', '?'].includes(sep.trim()) ? `.${sep.slice(sep.trimEnd().length)}` : sep;
144
+ out.push(lead + stopMessage + tail);
145
+ }
146
+ let result = out.join('').trimEnd();
147
+ if (message.trimEnd().endsWith('.') && !result.endsWith('.'))
148
+ result += '.';
149
+ return result;
150
+ }
151
+ /** Which limit a 402 names: error.reason / error.data.reason, else its message (older platforms). */
152
+ function rejectionLimit(err, message) {
153
+ const e = err;
154
+ const data = e?.data && typeof e.data === 'object' ? e.data : undefined;
155
+ let limitReason = asChargeLimitReason(data?.reason) ?? asChargeLimitReason(e?.body?.error?.reason);
156
+ const maxItems = positiveInt(data?.maxItems) ?? positiveInt(e?.body?.maxItems);
157
+ if (!limitReason) {
158
+ if (/maxitems|maximum number of results/i.test(message))
159
+ limitReason = 'max-items';
160
+ else if (/insufficient balance/i.test(message))
161
+ limitReason = 'insufficient-funds';
162
+ else if (/maximum total charge/i.test(message))
163
+ limitReason = 'max-charge';
164
+ }
165
+ return { limitReason, maxItems };
166
+ }
71
167
  /** True when a ChargeRejectedError means the run's spending limit is used up. */
72
168
  function isChargeLimitRejection(err) {
73
169
  return (err.statusCode === 402 ||
@@ -101,6 +197,10 @@ function defaultDatasetIdFromEnv() {
101
197
  * clients for writes that did not go through `pushDataAndCharge`
102
198
  * (`Actor.openDataset().pushData()`, crawler `pushData`, ...).
103
199
  */
200
+ /** `message` naming the limit that really stopped the run (Actor.setStatusMessage). */
201
+ function truthfulStatusMessage(message) {
202
+ return activeChargingManager ? activeChargingManager.rewriteStatusMessage(message) : message;
203
+ }
104
204
  function refuseDefaultDatasetPush(itemCount) {
105
205
  if (chargeGatedPush.getStore())
106
206
  return false;
@@ -154,6 +254,12 @@ class ChargingManager {
154
254
  _closed = false;
155
255
  /** Sticky: the platform refused or capped a charge (HTTP 402 / fewer accepted) or the local gate trimmed one. */
156
256
  chargeLimitHit = false;
257
+ /** Which limit that was (the first one known wins) and the item limit the platform reported. */
258
+ chargeLimitReason;
259
+ chargeLimitMaxItems;
260
+ /** limitedBy / maxItems of the last partially accepted charge response. */
261
+ lastLimitedBy;
262
+ lastLimitMaxItems;
157
263
  /** Default-dataset rows refused after the limit (one warning, then a count). */
158
264
  refusedDefaultDatasetItems = 0;
159
265
  /**
@@ -214,7 +320,7 @@ class ChargingManager {
214
320
  close() {
215
321
  this._closed = true;
216
322
  if (this.refusedDefaultDatasetItems > 0) {
217
- console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after the spending limit was reached were not saved`);
323
+ console.warn(`[Scrapely] ${this.refusedDefaultDatasetItems} result(s) pushed to the default dataset after ${this.limitName()} was reached were not saved`);
218
324
  }
219
325
  }
220
326
  /**
@@ -242,25 +348,104 @@ class ChargingManager {
242
348
  // Same rounding as the budget gate, so 0.03 - 0.02 still pays a $0.01 event.
243
349
  return this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0;
244
350
  }
245
- markChargeLimitHit() {
246
- if (this.isPayPerEvent)
247
- this.chargeLimitHit = true;
351
+ markChargeLimitHit(reason, maxItems) {
352
+ if (!this.isPayPerEvent)
353
+ return;
354
+ this.chargeLimitHit = true;
355
+ if (reason && this.chargeLimitReason === undefined)
356
+ this.chargeLimitReason = reason;
357
+ if (reason === 'max-items' && maxItems !== undefined && maxItems > 0)
358
+ this.chargeLimitMaxItems = maxItems;
359
+ }
360
+ markRejectedCharge(err) {
361
+ if (isChargeLimitRejection(err))
362
+ this.markChargeLimitHit(err.limitReason, err.maxItems);
363
+ }
364
+ /** The platform accepted fewer events than asked: record which limit cut it. */
365
+ markPartialCharge() {
366
+ this.markChargeLimitHit(this.lastLimitedBy, this.lastLimitMaxItems);
367
+ }
368
+ /** The run's item limit: as the platform reported it, else ACTOR_MAX_PAID_DATASET_ITEMS. */
369
+ maxItemsSetting() {
370
+ if (this.chargeLimitMaxItems !== undefined)
371
+ return this.chargeLimitMaxItems;
372
+ const raw = Number(process.env.ACTOR_MAX_PAID_DATASET_ITEMS ?? process.env.APIFY_ACTOR_MAX_PAID_DATASET_ITEMS);
373
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : undefined;
374
+ }
375
+ /**
376
+ * Why this run's charges stopped being accepted, or undefined while they
377
+ * still are: 'max-items' (the item limit the user set), 'max-charge' (the
378
+ * maximum total charge) or 'insufficient-funds' (the account balance).
379
+ * Platforms that do not say which limit it was: inferred from the run's settings.
380
+ */
381
+ getChargeLimitReason() {
382
+ if (!this.isChargeLimitReached())
383
+ return undefined;
384
+ if (this.chargeLimitReason)
385
+ return this.chargeLimitReason;
386
+ const maxItems = this.maxItemsSetting();
387
+ if (maxItems !== undefined &&
388
+ Object.entries(this.chargingState ?? {}).some(([name, state]) => !isSyntheticEvent(name) && (this.pricingInfo[name]?.price ?? 0) > 0 && state.chargeCount >= maxItems)) {
389
+ return 'max-items';
390
+ }
391
+ if (Number.isFinite(this.maxTotalChargeUsd)) {
392
+ const prices = Object.values(this.pricingInfo).map(({ price }) => price).filter((price) => price > 0);
393
+ if (prices.length > 0 && this.calculateMaxChargesByPrice(Math.min(...prices)) <= 0)
394
+ return 'max-charge';
395
+ }
396
+ return undefined;
397
+ }
398
+ /**
399
+ * The run's stop message for the limit that was reached, or undefined:
400
+ * "Stopped: reached the item limit you set (800 items)", "Stopped: reached
401
+ * the maximum charge you set ($3.00)", ... Use it as (part of) the final
402
+ * status message when the Actor stops because a charge was refused.
403
+ */
404
+ getChargeLimitMessage() {
405
+ return chargeLimitStopMessage(this.getChargeLimitReason(), {
406
+ maxItems: this.maxItemsSetting(),
407
+ maxTotalChargeUsd: Number.isFinite(this.maxTotalChargeUsd) ? this.maxTotalChargeUsd : null,
408
+ });
409
+ }
410
+ /** `message` with a clause blaming the spending limit replaced by the real reason (once a limit was reached). */
411
+ rewriteStatusMessage(message) {
412
+ try {
413
+ return rewriteChargeLimitClaims(message, this.getChargeLimitMessage());
414
+ }
415
+ catch {
416
+ return message;
417
+ }
418
+ }
419
+ /** 'the item limit of this run (maxItems = 800)', ... for log lines. */
420
+ limitName() {
421
+ const reason = this.getChargeLimitReason();
422
+ if (reason === 'max-items') {
423
+ const n = this.maxItemsSetting();
424
+ return n ? `the item limit of this run (maxItems = ${n})` : 'the item limit of this run (maxItems)';
425
+ }
426
+ if (reason === 'insufficient-funds')
427
+ return 'the end of the account balance';
428
+ if (reason === 'max-charge' || Number.isFinite(this.maxTotalChargeUsd)) {
429
+ const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
430
+ return `the spending limit of this run (maxTotalChargeUsd = ${limit})`;
431
+ }
432
+ return 'the limit of this run';
248
433
  }
249
434
  /** @internal Count rows dropped because of the limit; warn on the first one only. */
250
435
  _noteRefusedDefaultDatasetItems(count) {
251
436
  if (count <= 0)
252
437
  return;
253
438
  if (this.refusedDefaultDatasetItems === 0) {
254
- const limit = Number.isFinite(this.maxTotalChargeUsd) ? `$${this.maxTotalChargeUsd}` : 'the limit';
255
- console.warn(`[Scrapely] The spending limit of this run (maxTotalChargeUsd = ${limit}) has been reached: ` +
439
+ const name = this.limitName();
440
+ console.warn(`[Scrapely] ${name[0].toUpperCase()}${name.slice(1)} has been reached: ` +
256
441
  'results pushed to the default dataset are no longer saved. The Actor should stop now ' +
257
442
  '(check ChargeResult.eventChargeLimitReached or Actor.getChargingManager().isChargeLimitReached()).');
258
443
  }
259
444
  this.refusedDefaultDatasetItems += count;
260
445
  }
261
- /** @internal */
262
- _markChargeLimitHit() {
263
- this.markChargeLimitHit();
446
+ /** @internal `reason`: the limit that trimmed the push, when known. */
447
+ _markChargeLimitHit(reason) {
448
+ this.markChargeLimitHit(reason);
264
449
  }
265
450
  /** @internal Whether every row of a push with these events is charged a positive price. */
266
451
  _isPaidPerItem(eventName, isDefaultDataset) {
@@ -728,6 +913,8 @@ class ChargingManager {
728
913
  const accepted = readAcceptedCount(body, intent.count);
729
914
  const remaining = body.remainingBudgetNd;
730
915
  this.lastRemainingBudgetNd = typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 ? remaining : null;
916
+ this.lastLimitedBy = asChargeLimitReason(body.limitedBy);
917
+ this.lastLimitMaxItems = positiveInt(body.maxItems);
731
918
  return accepted;
732
919
  }
733
920
  catch (err) {
@@ -966,7 +1153,7 @@ class ChargingManager {
966
1153
  const accepted = await this.postChargeWithRetries(intent, deadline);
967
1154
  if (accepted !== null) {
968
1155
  if (accepted < intent.count)
969
- this.markChargeLimitHit();
1156
+ this.markPartialCharge();
970
1157
  const eventPrice = this.getEventPricing(intent.eventName).price;
971
1158
  this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
972
1159
  this.pendingCharges.delete(intent.idempotencyKey);
@@ -975,8 +1162,7 @@ class ChargingManager {
975
1162
  }
976
1163
  catch (err) {
977
1164
  if (err instanceof ChargeRejectedError) {
978
- if (isChargeLimitRejection(err))
979
- this.markChargeLimitHit();
1165
+ this.markRejectedCharge(err);
980
1166
  const eventPrice = this.getEventPricing(intent.eventName).price;
981
1167
  this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
982
1168
  this.pendingCharges.delete(intent.idempotencyKey);
@@ -1049,8 +1235,9 @@ class ChargingManager {
1049
1235
  }
1050
1236
  return 0;
1051
1237
  })();
1238
+ // The local budget gate: only maxTotalChargeUsd trims here.
1052
1239
  if (chargedCount < count)
1053
- this.markChargeLimitHit();
1240
+ this.markChargeLimitHit('max-charge');
1054
1241
  if (chargedCount === 0) {
1055
1242
  return {
1056
1243
  eventChargeLimitReached: count > 0,
@@ -1093,8 +1280,7 @@ class ChargingManager {
1093
1280
  }
1094
1281
  catch (err) {
1095
1282
  if (err instanceof ChargeRejectedError) {
1096
- if (isChargeLimitRejection(err))
1097
- this.markChargeLimitHit();
1283
+ this.markRejectedCharge(err);
1098
1284
  // Server definitively rejected: unwind local reservation.
1099
1285
  this.pendingCharges.delete(intent.idempotencyKey);
1100
1286
  chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
@@ -1112,7 +1298,7 @@ class ChargingManager {
1112
1298
  this.lastAcceptedOperationId = intent.idempotencyKey;
1113
1299
  // The platform capped the charge at the run's limit.
1114
1300
  if (accepted < chargedCount)
1115
- this.markChargeLimitHit();
1301
+ this.markPartialCharge();
1116
1302
  chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
1117
1303
  this.syncPlatformBudget();
1118
1304
  }
@@ -1143,7 +1329,7 @@ class ChargingManager {
1143
1329
  }
1144
1330
  if (chargedCount < count) {
1145
1331
  const subject = count === 1 ? 'instance' : 'instances';
1146
- console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed maxTotalChargeUsd - only ${chargedCount} events were charged`);
1332
+ console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
1147
1333
  }
1148
1334
  return {
1149
1335
  eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
@@ -1197,6 +1383,7 @@ function classifyChargeHttpError(err, intent) {
1197
1383
  count: intent.count,
1198
1384
  statusCode: status,
1199
1385
  reason: status === 402 ? 'charge-limit-exceeded' : `http-${status}`,
1386
+ ...(status === 402 ? rejectionLimit(err, String(err?.message ?? '')) : {}),
1200
1387
  });
1201
1388
  }
1202
1389
  // A 5xx / 408 / 429 answer is never a definitive rejection, whatever its text
@@ -1213,6 +1400,7 @@ function classifyChargeHttpError(err, intent) {
1213
1400
  count: intent.count,
1214
1401
  statusCode: status,
1215
1402
  reason: 'message-match',
1403
+ ...(/charge-limit-exceeded/i.test(message) ? rejectionLimit(err, message) : {}),
1216
1404
  });
1217
1405
  }
1218
1406
  return null;
@@ -1302,7 +1490,7 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
1302
1490
  const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
1303
1491
  if (isDefaultDataset && limitedItems.length < itemsArray.length) {
1304
1492
  // Rows beyond what the budget can pay for are dropped.
1305
- chargingManager._markChargeLimitHit?.();
1493
+ chargingManager._markChargeLimitHit?.('max-charge');
1306
1494
  chargingManager._noteRefusedDefaultDatasetItems?.(itemsArray.length - limitedItems.length);
1307
1495
  }
1308
1496
  if (limitedItems.length === 0) {
@@ -31,8 +31,9 @@ function connectEvents(eventsWsUrl, token, runId) {
31
31
  return;
32
32
  shouldReconnect = true;
33
33
  try {
34
- const url = `${eventsWsUrl}?token=${encodeURIComponent(token)}&runId=${encodeURIComponent(runId)}`;
35
- ws = new ws_1.default(url);
34
+ // The token goes in a header, not the URL: URLs end up in access logs.
35
+ const url = `${eventsWsUrl}?runId=${encodeURIComponent(runId)}`;
36
+ ws = new ws_1.default(url, { headers: { Authorization: `Bearer ${token}` } });
36
37
  ws.onopen = () => {
37
38
  connected = true;
38
39
  console.log(`[Scrapely Events] Connected to ${eventsWsUrl}`);
@@ -26,7 +26,14 @@ export declare class ApiStorageClientError extends Error {
26
26
  export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
27
27
  /** Default time budget for retrying one API call (429, 503, network errors). */
28
28
  export declare const DEFAULT_API_RETRY_WINDOW_MS = 300000;
29
- /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
29
+ /** A key-value store record URL (`.../key-value-stores/<id>/records/<key>`). */
30
+ export declare function isKvRecordUrl(url: string): boolean;
31
+ /**
32
+ * Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes.
33
+ * Record PUTs count as de-duplicated until a keyed record PUT was stored without the support
34
+ * header: re-applying the same body to the same record leaves the same record, so the worst
35
+ * case on an API that ignores the key is one extra counted write, while not re-sending fails the run.
36
+ */
30
37
  export declare function apiDedupesKeyedWrites(url?: string): boolean;
31
38
  /** Test hook: forget what was learned about the API, or force an answer for every origin. */
32
39
  export declare function _resetKeyedWriteSupport(value?: boolean): void;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.DEFAULT_API_RETRY_WINDOW_MS = exports.ApiStorageClientError = void 0;
4
4
  exports.isRetryableResponse = isRetryableResponse;
5
+ exports.isKvRecordUrl = isKvRecordUrl;
5
6
  exports.apiDedupesKeyedWrites = apiDedupesKeyedWrites;
6
7
  exports._resetKeyedWriteSupport = _resetKeyedWriteSupport;
7
8
  exports.apiRetryWindowMs = apiRetryWindowMs;
@@ -139,7 +140,9 @@ function isApiStorageClientError(err) {
139
140
  return err instanceof ApiStorageClientError;
140
141
  }
141
142
  // PUT is left out: a KV record PUT is counted (and billed) as a write each
142
- // time it is applied, so a lost response must not be re-sent blindly.
143
+ // time it is applied, so a lost response must not be re-sent blindly. Record
144
+ // PUTs carry an Idempotency-Key instead (see isKvRecordUrl), which the API
145
+ // de-duplicates, so they are re-sent after a lost answer (e.g. an edge 502).
143
146
  const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
144
147
  // HAProxy's own 503 page: no backend was available, so nothing was forwarded.
145
148
  const NO_BACKEND_MARKER = 'No server is available to handle this request';
@@ -180,20 +183,39 @@ const IDEMPOTENCY_SUPPORT_HEADER = 'x-scrapely-idempotency';
180
183
  const keyedWritesDeduped = new Map();
181
184
  /** Test override for every origin (undefined: learn from answers). */
182
185
  let keyedWritesDedupedOverride;
183
- function originOf(url) {
186
+ /** A key-value store record URL (`.../key-value-stores/<id>/records/<key>`). */
187
+ function isKvRecordUrl(url) {
188
+ let path = url;
184
189
  try {
185
- return new URL(url).origin;
190
+ path = new URL(url).pathname;
186
191
  }
187
- catch {
188
- return url;
192
+ catch { /* a path already */ }
193
+ return path.includes('/key-value-stores/') && path.includes('/records/');
194
+ }
195
+ /** What is learned per API: its origin, separately for record PUTs and other keyed writes. */
196
+ function originOf(url) {
197
+ let origin = url;
198
+ try {
199
+ origin = new URL(url).origin;
189
200
  }
201
+ catch { /* keep the input */ }
202
+ return isKvRecordUrl(url) ? `${origin}#kv-record` : origin;
190
203
  }
191
- /** Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes. */
204
+ /**
205
+ * Whether the API at `url` (any URL of it; default: the last one seen) de-duplicates keyed writes.
206
+ * Record PUTs count as de-duplicated until a keyed record PUT was stored without the support
207
+ * header: re-applying the same body to the same record leaves the same record, so the worst
208
+ * case on an API that ignores the key is one extra counted write, while not re-sending fails the run.
209
+ */
192
210
  function apiDedupesKeyedWrites(url) {
193
211
  if (keyedWritesDedupedOverride !== undefined)
194
212
  return keyedWritesDedupedOverride;
195
- if (url !== undefined)
196
- return keyedWritesDeduped.get(originOf(url)) === true;
213
+ if (url !== undefined) {
214
+ const learned = keyedWritesDeduped.get(originOf(url));
215
+ if (learned === undefined && isKvRecordUrl(url))
216
+ return true;
217
+ return learned === true;
218
+ }
197
219
  return [...keyedWritesDeduped.values()].pop() === true;
198
220
  }
199
221
  /** Test hook: forget what was learned about the API, or force an answer for every origin. */
@@ -792,10 +814,13 @@ class ApiKeyValueStoreClient extends BaseApiClient {
792
814
  ct = 'application/json';
793
815
  }
794
816
  // Pass timeout/retry options from KeyValueStoreRecordOptions to the API request
795
- const requestOptions = options ? {
796
- timeoutSecs: options.timeoutSecs,
797
- doNotRetryTimeouts: options.doNotRetryTimeouts,
798
- } : undefined;
817
+ // One Idempotency-Key for every attempt of this write: a re-send after a lost answer
818
+ // (edge 502/504, dropped connection) is applied and counted once.
819
+ const requestOptions = {
820
+ timeoutSecs: options?.timeoutSecs,
821
+ doNotRetryTimeouts: options?.doNotRetryTimeouts,
822
+ idempotencyKey: (0, node_crypto_1.randomUUID)(),
823
+ };
799
824
  await this.request('PUT', `/key-value-stores/${this.id}/records/${(0, utils_js_1.pathSegment)(key)}`, body, contentType || ct, requestOptions);
800
825
  }
801
826
  async deleteRecord(key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.37",
3
+ "version": "1.2.39",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -75,7 +75,8 @@
75
75
  "test:charge-retries": "npx tsx --test tests/charge-retries.test.ts",
76
76
  "test:solver": "npx tsx --test tests/solver.test.ts",
77
77
  "test:lazy-preload": "npx tsx --test tests/lazy-preload.test.ts",
78
- "test:v2-utils": "npx tsx --test tests/v2-utils.test.ts"
78
+ "test:v2-utils": "npx tsx --test tests/v2-utils.test.ts",
79
+ "test:default-api-url": "npx tsx --test tests/default-api-url.test.ts"
79
80
  },
80
81
  "author": "TeraShift GmbH (Scrape.ly)",
81
82
  "license": "ISC AND Apache-2.0",
@@ -92,7 +93,7 @@
92
93
  "lodash": "^4.18.1",
93
94
  "mysql2": "^3.23.1",
94
95
  "playwright": "^1.58.2",
95
- "scrapely-client": "^2.25.2",
96
+ "scrapely-client": "^2.25.9",
96
97
  "tar-fs": "2.1.5",
97
98
  "ws": "^8.18.0"
98
99
  },
@@ -130,6 +131,7 @@
130
131
  "@inquirer/external-editor": "3.0.3",
131
132
  "nanoid": "5.1.16",
132
133
  "browserslist": "4.28.7",
133
- "basic-ftp": "^6.2.3"
134
+ "basic-ftp": "^6.2.3",
135
+ "@asamuzakjp/css-color": "^7.0.1"
134
136
  }
135
137
  }