scrapely 1.2.9 → 1.2.11
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/charging.d.ts +10 -1
- package/dist/charging.js +80 -10
- package/dist/storage/api-storage-client.d.ts +24 -1
- package/dist/storage/api-storage-client.js +159 -83
- package/package.json +2 -1
package/dist/charging.d.ts
CHANGED
|
@@ -122,6 +122,8 @@ export declare class ChargingManager {
|
|
|
122
122
|
private getEventPricing;
|
|
123
123
|
private normalizeChargingEventName;
|
|
124
124
|
private calculateMaxChargesByPrice;
|
|
125
|
+
/** @internal Remaining allowance per event (pushDataAndCharge's uncertain path). */
|
|
126
|
+
_chargeableWithinLimit(): Record<string, number>;
|
|
125
127
|
private getChargeableWithinLimit;
|
|
126
128
|
private quarantineStalePending;
|
|
127
129
|
/**
|
|
@@ -137,7 +139,14 @@ export declare class ChargingManager {
|
|
|
137
139
|
* (POST /v2/actor-runs/:runId/charge/void), then unwind the local
|
|
138
140
|
* reservation. Returns true when the platform voided it.
|
|
139
141
|
*/
|
|
140
|
-
|
|
142
|
+
/**
|
|
143
|
+
* @internal Cancel a charge whose outcome was never confirmed, for items that
|
|
144
|
+
* were then not stored: drop the pending intent (so the exit flush never
|
|
145
|
+
* posts it) and void it in case the original request did apply. A 404 here
|
|
146
|
+
* means it never applied.
|
|
147
|
+
*/
|
|
148
|
+
_cancelUncertainCharge(eventName: string, operationId: string, reservedCount: number): Promise<boolean>;
|
|
149
|
+
_voidCharge(eventName: string, operationId: string, reservedCount: number, notFoundMeansVoided?: boolean): Promise<boolean>;
|
|
141
150
|
/**
|
|
142
151
|
* @internal Retry unconfirmed voids (caller holds the operation lock).
|
|
143
152
|
* Returns false while any earlier void is still unconfirmed.
|
package/dist/charging.js
CHANGED
|
@@ -345,6 +345,10 @@ class ChargingManager {
|
|
|
345
345
|
const roundedResult = Math.floor(Number(unroundedResult.toFixed(4)));
|
|
346
346
|
return Math.max(0, roundedResult);
|
|
347
347
|
}
|
|
348
|
+
/** @internal Remaining allowance per event (pushDataAndCharge's uncertain path). */
|
|
349
|
+
_chargeableWithinLimit() {
|
|
350
|
+
return this.chargingState === undefined ? {} : this.getChargeableWithinLimit();
|
|
351
|
+
}
|
|
348
352
|
getChargeableWithinLimit() {
|
|
349
353
|
if (Object.keys(this.pricingInfo).length === 0) {
|
|
350
354
|
return {};
|
|
@@ -414,7 +418,17 @@ class ChargingManager {
|
|
|
414
418
|
* (POST /v2/actor-runs/:runId/charge/void), then unwind the local
|
|
415
419
|
* reservation. Returns true when the platform voided it.
|
|
416
420
|
*/
|
|
417
|
-
|
|
421
|
+
/**
|
|
422
|
+
* @internal Cancel a charge whose outcome was never confirmed, for items that
|
|
423
|
+
* were then not stored: drop the pending intent (so the exit flush never
|
|
424
|
+
* posts it) and void it in case the original request did apply. A 404 here
|
|
425
|
+
* means it never applied.
|
|
426
|
+
*/
|
|
427
|
+
async _cancelUncertainCharge(eventName, operationId, reservedCount) {
|
|
428
|
+
this.pendingCharges.delete(operationId);
|
|
429
|
+
return this._voidCharge(eventName, operationId, reservedCount, true);
|
|
430
|
+
}
|
|
431
|
+
async _voidCharge(eventName, operationId, reservedCount, notFoundMeansVoided = false) {
|
|
418
432
|
if (!this.isAtHome || !this.actorRunId)
|
|
419
433
|
return false;
|
|
420
434
|
const runClient = this.apifyClient.run(this.actorRunId);
|
|
@@ -431,6 +445,13 @@ class ChargingManager {
|
|
|
431
445
|
}
|
|
432
446
|
catch (err) {
|
|
433
447
|
const status = httpStatusOf(err);
|
|
448
|
+
// Only the void route's own answer proves it never applied; a server
|
|
449
|
+
// without the route (older deployment) also answers 404.
|
|
450
|
+
if (status === 404 && notFoundMeansVoided && apiErrorTypeOf(err) === 'charge-not-found') {
|
|
451
|
+
// The unconfirmed charge never reached the ledger: nothing billed.
|
|
452
|
+
this.applyAcceptedCount(eventName, reservedCount, 0);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
434
455
|
// 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
435
456
|
// The charge stands; keep the local reservation that matches it.
|
|
436
457
|
if (status !== undefined && status >= 400 && status < 500)
|
|
@@ -440,7 +461,7 @@ class ChargingManager {
|
|
|
440
461
|
}
|
|
441
462
|
}
|
|
442
463
|
// Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
443
|
-
this.pendingVoids.push({ eventName, operationId, reservedCount });
|
|
464
|
+
this.pendingVoids.push({ eventName, operationId, reservedCount, notFoundMeansVoided });
|
|
444
465
|
console.error(`[Scrapely] Void of charge ${operationId} ('${eventName}') not confirmed yet; will retry`);
|
|
445
466
|
return false;
|
|
446
467
|
}
|
|
@@ -452,7 +473,7 @@ class ChargingManager {
|
|
|
452
473
|
const pending = this.pendingVoids;
|
|
453
474
|
this.pendingVoids = [];
|
|
454
475
|
for (const v of pending) {
|
|
455
|
-
await this._voidCharge(v.eventName, v.operationId, v.reservedCount);
|
|
476
|
+
await this._voidCharge(v.eventName, v.operationId, v.reservedCount, v.notFoundMeansVoided);
|
|
456
477
|
}
|
|
457
478
|
return this.pendingVoids.length === 0;
|
|
458
479
|
}
|
|
@@ -784,10 +805,30 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
784
805
|
try {
|
|
785
806
|
for (const [name, chargeCount] of ordered) {
|
|
786
807
|
chargingManager._takeLastAcceptedOperationId?.();
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
808
|
+
let r;
|
|
809
|
+
try {
|
|
810
|
+
r = await chargingManager._chargeExclusive({
|
|
811
|
+
eventName: name,
|
|
812
|
+
count: chargeCount,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (chargeErr) {
|
|
816
|
+
if (!(chargeErr instanceof ChargeUncertainError))
|
|
817
|
+
throw chargeErr;
|
|
818
|
+
// The platform did not confirm the charge (5xx / timeouts after
|
|
819
|
+
// retries). Never drop the items for that: the intent stays
|
|
820
|
+
// pending with its idempotency key and is re-posted by the exit
|
|
821
|
+
// flush, so the items are stored now and billed once.
|
|
822
|
+
console.warn(`[Scrapely] Charge for '${name}' not confirmed yet (${chargeErr.operationId}); storing the items and retrying the charge later`);
|
|
823
|
+
r = {
|
|
824
|
+
eventChargeLimitReached: false,
|
|
825
|
+
chargedCount: chargeErr.count,
|
|
826
|
+
chargeableWithinLimit: chargingManager._chargeableWithinLimit?.() ?? {},
|
|
827
|
+
};
|
|
828
|
+
results.push(r);
|
|
829
|
+
reservedInOp.push({ name, reserved: chargeErr.count, operationId: chargeErr.operationId, uncertain: true });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
791
832
|
results.push(r);
|
|
792
833
|
reservedInOp.push({ name, reserved: r.chargedCount, operationId: chargingManager._takeLastAcceptedOperationId?.() });
|
|
793
834
|
}
|
|
@@ -814,7 +855,12 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
814
855
|
const [name] = ordered[i];
|
|
815
856
|
const reserved = results[i].chargedCount;
|
|
816
857
|
if (reserved > acceptedForItems) {
|
|
817
|
-
|
|
858
|
+
// Nothing stored and a platform charge was made: leave its local
|
|
859
|
+
// count to the void below, which releases it only once the
|
|
860
|
+
// platform confirms (never on a server without the void route).
|
|
861
|
+
const platformOp = acceptedForItems <= 0 && reservedInOp[i]?.operationId;
|
|
862
|
+
if (!platformOp)
|
|
863
|
+
chargingManager.applyAcceptedCount(name, reserved, acceptedForItems);
|
|
818
864
|
results[i] = {
|
|
819
865
|
...results[i],
|
|
820
866
|
chargedCount: acceptedForItems,
|
|
@@ -824,6 +870,20 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
824
870
|
}
|
|
825
871
|
chargeResult = results.reduce(mergeChargeResults);
|
|
826
872
|
if (acceptedForItems <= 0) {
|
|
873
|
+
// Nothing will be stored (another event of this push was capped to
|
|
874
|
+
// zero): undo every platform charge this operation made, so no
|
|
875
|
+
// result is billed for items that are never saved.
|
|
876
|
+
for (let i = 0; i < reservedInOp.length; i++) {
|
|
877
|
+
const { name, operationId, uncertain, reserved } = reservedInOp[i];
|
|
878
|
+
if (reserved <= 0 || !operationId)
|
|
879
|
+
continue;
|
|
880
|
+
// Original count: the local reservation is released only when
|
|
881
|
+
// the platform confirms the void (or that it never applied).
|
|
882
|
+
if (uncertain)
|
|
883
|
+
await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
|
|
884
|
+
else
|
|
885
|
+
await chargingManager._voidCharge(name, operationId, reserved);
|
|
886
|
+
}
|
|
827
887
|
return {
|
|
828
888
|
eventChargeLimitReached: true,
|
|
829
889
|
chargedCount: chargeResult.chargedCount,
|
|
@@ -848,11 +908,15 @@ async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultD
|
|
|
848
908
|
if (!pushCertainlyFailed(pushErr))
|
|
849
909
|
throw pushErr;
|
|
850
910
|
for (let i = 0; i < reservedInOp.length; i++) {
|
|
851
|
-
const { name, operationId } = reservedInOp[i];
|
|
911
|
+
const { name, operationId, uncertain } = reservedInOp[i];
|
|
852
912
|
const reserved = results[i].chargedCount;
|
|
853
913
|
if (reserved <= 0)
|
|
854
914
|
continue;
|
|
855
|
-
if (operationId) {
|
|
915
|
+
if (operationId && uncertain) {
|
|
916
|
+
// Never re-post a charge for items that were not stored.
|
|
917
|
+
await chargingManager._cancelUncertainCharge?.(name, operationId, reserved);
|
|
918
|
+
}
|
|
919
|
+
else if (operationId) {
|
|
856
920
|
await chargingManager._voidCharge(name, operationId, reserved);
|
|
857
921
|
}
|
|
858
922
|
else if (isSyntheticEvent(name)) {
|
|
@@ -895,3 +959,9 @@ function pushCertainlyFailed(err) {
|
|
|
895
959
|
}
|
|
896
960
|
return false;
|
|
897
961
|
}
|
|
962
|
+
/** API error type (e.g. 'charge-not-found') of a client error, if the server sent one. */
|
|
963
|
+
function apiErrorTypeOf(err) {
|
|
964
|
+
const e = err;
|
|
965
|
+
const t = e?.type ?? e?.data?.error?.type ?? e?.response?.data?.error?.type;
|
|
966
|
+
return typeof t === 'string' ? t : undefined;
|
|
967
|
+
}
|
|
@@ -12,6 +12,19 @@ export declare class ApiStorageClientError extends Error {
|
|
|
12
12
|
readonly statusCode: number;
|
|
13
13
|
constructor(message: string, statusCode: number);
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether a non-2xx answer is retried. Idempotent methods: 500/502/503/504.
|
|
17
|
+
* Others (dataset push, KV PUT) only on a 503 that certainly was not
|
|
18
|
+
* processed: the rate limiter refused it ("rate-limit-unavailable", before the
|
|
19
|
+
* handler), or HAProxy had no backend (its own page, nothing forwarded). Any
|
|
20
|
+
* other 503 may mean "outcome unknown" (e.g. a direct dataset write whose
|
|
21
|
+
* commit was not confirmed), and re-sending could store the items twice.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isRetryableResponse(status: number, idempotent: boolean, errorType?: string, bodyText?: string): boolean;
|
|
24
|
+
/** Exponential backoff with jitter; honours a Retry-After (seconds or HTTP date) of up to 30 s. */
|
|
25
|
+
export declare function serverRetryDelayMs(serverAttempt: number, retryAfter: string | null, random?: () => number, now?: number): number;
|
|
26
|
+
/** fetch() failed before the request reached the server. */
|
|
27
|
+
export declare function isConnectFailure(err: unknown): boolean;
|
|
15
28
|
/**
|
|
16
29
|
* Options for API requests (passed through from RecordOptions)
|
|
17
30
|
*/
|
|
@@ -20,19 +33,29 @@ interface ApiRequestOptions {
|
|
|
20
33
|
timeoutSecs?: number;
|
|
21
34
|
/** If true, don't retry on timeout errors */
|
|
22
35
|
doNotRetryTimeouts?: boolean;
|
|
36
|
+
/** Override the method-based idempotency (e.g. get-or-create by name is safe to repeat). */
|
|
37
|
+
idempotent?: boolean;
|
|
23
38
|
}
|
|
24
39
|
/**
|
|
25
40
|
* Base class for API-based storage clients.
|
|
26
41
|
* Communicates with internal storage API instead of direct DB access.
|
|
27
42
|
* No sensitive credentials are stored - only the user's API key.
|
|
28
43
|
*/
|
|
29
|
-
declare abstract class BaseApiClient {
|
|
44
|
+
export declare abstract class BaseApiClient {
|
|
30
45
|
protected config: ApiStorageConfig;
|
|
31
46
|
protected headers: Record<string, string>;
|
|
32
47
|
private static rateLimiter;
|
|
33
48
|
private static maxRetries;
|
|
49
|
+
static maxServerRetries: number;
|
|
34
50
|
private static defaultTimeoutMs;
|
|
35
51
|
constructor(config: ApiStorageConfig);
|
|
52
|
+
/**
|
|
53
|
+
* Sends the request and returns the successful (2xx) response, with the
|
|
54
|
+
* retry policy: 429 (rate limiter), timeouts, and the API being briefly
|
|
55
|
+
* unavailable (503, 5xx for idempotent methods, connection failures).
|
|
56
|
+
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
57
|
+
*/
|
|
58
|
+
private send;
|
|
36
59
|
protected request<T>(method: string, path: string, body?: any, contentType?: string, options?: ApiRequestOptions): Promise<T>;
|
|
37
60
|
protected requestWithHeaders<T>(method: string, path: string, body?: any, contentType?: string): Promise<{
|
|
38
61
|
data: T;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.ApiStorageClientError = void 0;
|
|
3
|
+
exports.ApiStorageClientFactory = exports.ApiRequestQueueClient = exports.ApiKeyValueStoreClient = exports.ApiDatasetClient = exports.BaseApiClient = exports.ApiStorageClientError = void 0;
|
|
4
|
+
exports.isRetryableResponse = isRetryableResponse;
|
|
5
|
+
exports.serverRetryDelayMs = serverRetryDelayMs;
|
|
6
|
+
exports.isConnectFailure = isConnectFailure;
|
|
4
7
|
const node_crypto_1 = require("node:crypto");
|
|
5
8
|
const kvs_iteration_js_1 = require("./kvs-iteration.js");
|
|
6
9
|
function buildRqQueryParams(baseClientKey, options) {
|
|
@@ -155,6 +158,53 @@ exports.ApiStorageClientError = ApiStorageClientError;
|
|
|
155
158
|
function isApiStorageClientError(err) {
|
|
156
159
|
return err instanceof ApiStorageClientError;
|
|
157
160
|
}
|
|
161
|
+
// PUT is left out: a KV record PUT is counted (and billed) as a write each
|
|
162
|
+
// time it is applied, so a lost response must not be re-sent blindly.
|
|
163
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
|
|
164
|
+
// HAProxy's own 503 page: no backend was available, so nothing was forwarded.
|
|
165
|
+
const NO_BACKEND_MARKER = 'No server is available to handle this request';
|
|
166
|
+
const CONNECT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
167
|
+
/**
|
|
168
|
+
* Whether a non-2xx answer is retried. Idempotent methods: 500/502/503/504.
|
|
169
|
+
* Others (dataset push, KV PUT) only on a 503 that certainly was not
|
|
170
|
+
* processed: the rate limiter refused it ("rate-limit-unavailable", before the
|
|
171
|
+
* handler), or HAProxy had no backend (its own page, nothing forwarded). Any
|
|
172
|
+
* other 503 may mean "outcome unknown" (e.g. a direct dataset write whose
|
|
173
|
+
* commit was not confirmed), and re-sending could store the items twice.
|
|
174
|
+
*/
|
|
175
|
+
function isRetryableResponse(status, idempotent, errorType, bodyText) {
|
|
176
|
+
if (idempotent)
|
|
177
|
+
return status === 500 || status === 502 || status === 503 || status === 504;
|
|
178
|
+
if (status !== 503)
|
|
179
|
+
return false;
|
|
180
|
+
return errorType === 'rate-limit-unavailable' || (bodyText ?? '').includes(NO_BACKEND_MARKER);
|
|
181
|
+
}
|
|
182
|
+
/** Exponential backoff with jitter; honours a Retry-After (seconds or HTTP date) of up to 30 s. */
|
|
183
|
+
function serverRetryDelayMs(serverAttempt, retryAfter, random = Math.random, now = Date.now()) {
|
|
184
|
+
if (retryAfter !== null && retryAfter.trim() !== '') {
|
|
185
|
+
let secs = Number(retryAfter);
|
|
186
|
+
if (!Number.isFinite(secs)) {
|
|
187
|
+
const at = Date.parse(retryAfter);
|
|
188
|
+
secs = Number.isFinite(at) ? Math.max(0, (at - now) / 1000) : NaN;
|
|
189
|
+
}
|
|
190
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
191
|
+
return Math.min(secs, 30) * 1000 + random() * 100;
|
|
192
|
+
}
|
|
193
|
+
return Math.min(8000, 500 * 2 ** serverAttempt) + random() * 250;
|
|
194
|
+
}
|
|
195
|
+
function fetchErrorCode(err) {
|
|
196
|
+
const e = err;
|
|
197
|
+
return e?.cause?.code ?? e?.cause?.cause?.code ?? e?.code;
|
|
198
|
+
}
|
|
199
|
+
/** fetch() failed before the request reached the server. */
|
|
200
|
+
function isConnectFailure(err) {
|
|
201
|
+
const code = fetchErrorCode(err);
|
|
202
|
+
return code !== undefined && CONNECT_FAILURE_CODES.has(code);
|
|
203
|
+
}
|
|
204
|
+
function describeFetchError(err) {
|
|
205
|
+
return fetchErrorCode(err) ?? err?.message ?? String(err);
|
|
206
|
+
}
|
|
207
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
158
208
|
/** Map GET storage errors: rethrow 403, return undefined for 404 (triggers getOrCreate for names only). */
|
|
159
209
|
function handleStorageGetError(err) {
|
|
160
210
|
if (isApiStorageClientError(err)) {
|
|
@@ -175,17 +225,11 @@ function storageCollectionHeaders(config) {
|
|
|
175
225
|
/** POST /v2/{datasets|key-value-stores|request-queues} to create or get-by-name. */
|
|
176
226
|
async function postStorageCollection(config, path, name) {
|
|
177
227
|
const query = name ? `?name=${encodeURIComponent(name)}` : '';
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
if (!response.ok) {
|
|
184
|
-
const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
|
|
185
|
-
throw new Error(error.error?.message || `Failed to create storage: ${response.status}`);
|
|
186
|
-
}
|
|
187
|
-
const body = await response.json();
|
|
188
|
-
return unwrapData(body);
|
|
228
|
+
// Not marked repeatable even by name: a 500 after the create (e.g. scoped
|
|
229
|
+
// token registration failed) must surface, since a re-send would take the
|
|
230
|
+
// existing-name path and hide it. POST rules apply (limiter/HAProxy 503,
|
|
231
|
+
// refused connection).
|
|
232
|
+
return new CollectionApiClient(config).call('POST', `${path}${query}`);
|
|
189
233
|
}
|
|
190
234
|
/**
|
|
191
235
|
* Helper to create AsyncIterable + Promise result (matches Crawlee pattern)
|
|
@@ -226,6 +270,10 @@ class BaseApiClient {
|
|
|
226
270
|
static rateLimiter = new AdaptiveRateLimiter();
|
|
227
271
|
// Maximum retries for rate-limited requests
|
|
228
272
|
static maxRetries = 3;
|
|
273
|
+
// Retries while the API is briefly unavailable (503/5xx, connection
|
|
274
|
+
// refused): backoff 0.5 s doubling to 8 s, about 45 s in total, which
|
|
275
|
+
// covers a main redeploy.
|
|
276
|
+
static maxServerRetries = 8;
|
|
229
277
|
// Default timeout for requests (30 seconds)
|
|
230
278
|
static defaultTimeoutMs = 30000;
|
|
231
279
|
constructor(config) {
|
|
@@ -236,7 +284,13 @@ class BaseApiClient {
|
|
|
236
284
|
'Content-Type': 'application/json',
|
|
237
285
|
};
|
|
238
286
|
}
|
|
239
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Sends the request and returns the successful (2xx) response, with the
|
|
289
|
+
* retry policy: 429 (rate limiter), timeouts, and the API being briefly
|
|
290
|
+
* unavailable (503, 5xx for idempotent methods, connection failures).
|
|
291
|
+
* Non-2xx answers that are not retried throw ApiStorageClientError.
|
|
292
|
+
*/
|
|
293
|
+
async send(method, path, body, contentType, options, decode) {
|
|
240
294
|
const url = `${this.config.apiUrl}/v2${path}`;
|
|
241
295
|
const headers = { ...this.headers };
|
|
242
296
|
if (contentType) {
|
|
@@ -246,8 +300,10 @@ class BaseApiClient {
|
|
|
246
300
|
? options.timeoutSecs * 1000
|
|
247
301
|
: BaseApiClient.defaultTimeoutMs;
|
|
248
302
|
const shouldRetryTimeouts = options?.doNotRetryTimeouts !== true;
|
|
249
|
-
|
|
250
|
-
|
|
303
|
+
const idempotent = options?.idempotent ?? IDEMPOTENT_METHODS.has(method.toUpperCase());
|
|
304
|
+
let attempt = 0; // timeouts and 429s
|
|
305
|
+
let serverAttempt = 0; // 503/5xx and connection failures (API briefly unavailable)
|
|
306
|
+
for (;;) {
|
|
251
307
|
// Wait for rate limiter slot before making request
|
|
252
308
|
await BaseApiClient.rateLimiter.waitForSlot();
|
|
253
309
|
// Create AbortController for timeout
|
|
@@ -265,89 +321,110 @@ class BaseApiClient {
|
|
|
265
321
|
// Handle rate limiting (429)
|
|
266
322
|
if (response.status === 429) {
|
|
267
323
|
if (attempt < BaseApiClient.maxRetries) {
|
|
324
|
+
attempt++;
|
|
268
325
|
await BaseApiClient.rateLimiter.handleRateLimited(response);
|
|
269
326
|
continue; // Retry
|
|
270
327
|
}
|
|
271
|
-
// Max retries exceeded
|
|
272
328
|
const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
|
|
273
|
-
throw new
|
|
329
|
+
throw new ApiStorageClientError(error.error?.message || 'Rate limit exceeded', 429);
|
|
274
330
|
}
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
331
|
+
if (response.ok) {
|
|
332
|
+
try {
|
|
333
|
+
// Decoded inside the retry loop: a body cut off after
|
|
334
|
+
// the headers is a transport failure like any other.
|
|
335
|
+
return await decode(response);
|
|
336
|
+
}
|
|
337
|
+
catch (bodyErr) {
|
|
338
|
+
// The request was applied (2xx); a write whose answer
|
|
339
|
+
// timed out while being read must not be sent again.
|
|
340
|
+
if (bodyErr.name === 'AbortError' && !idempotent) {
|
|
341
|
+
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms (response body; the request was applied)`);
|
|
342
|
+
timeoutError.isTimeout = true;
|
|
343
|
+
throw timeoutError;
|
|
344
|
+
}
|
|
345
|
+
if (idempotent && bodyErr.name !== 'AbortError' && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
346
|
+
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
347
|
+
serverAttempt++;
|
|
348
|
+
console.warn(`[Scrapely] ${method} ${path} response body failed (${describeFetchError(bodyErr)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
349
|
+
await sleep(waitMs);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
throw bodyErr;
|
|
353
|
+
}
|
|
278
354
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
355
|
+
// API briefly unavailable (main redeploy, limiter store blip).
|
|
356
|
+
const text = await response.text().catch(() => '');
|
|
357
|
+
let errorBody;
|
|
358
|
+
try {
|
|
359
|
+
errorBody = JSON.parse(text);
|
|
282
360
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (responseContentType.includes('text/plain')) {
|
|
286
|
-
return response.text();
|
|
361
|
+
catch {
|
|
362
|
+
errorBody = undefined;
|
|
287
363
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
364
|
+
if (isRetryableResponse(response.status, idempotent, errorBody?.error?.type, text)
|
|
365
|
+
&& serverAttempt < BaseApiClient.maxServerRetries) {
|
|
366
|
+
const waitMs = serverRetryDelayMs(serverAttempt, response.headers.get('retry-after'));
|
|
367
|
+
serverAttempt++;
|
|
368
|
+
console.warn(`[Scrapely] ${method} ${path} answered ${response.status}; retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
369
|
+
await sleep(waitMs);
|
|
370
|
+
continue;
|
|
292
371
|
}
|
|
293
|
-
|
|
294
|
-
return unwrapData(json);
|
|
372
|
+
throw new ApiStorageClientError(errorBody?.error?.message || response.statusText || `API request failed: ${response.status}`, response.status);
|
|
295
373
|
}
|
|
296
374
|
catch (err) {
|
|
297
375
|
// Handle timeout/abort errors
|
|
298
376
|
if (err.name === 'AbortError') {
|
|
299
377
|
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
300
378
|
timeoutError.isTimeout = true;
|
|
301
|
-
lastError = timeoutError;
|
|
302
379
|
// Don't retry if doNotRetryTimeouts is true
|
|
303
380
|
if (!shouldRetryTimeouts || attempt >= BaseApiClient.maxRetries) {
|
|
304
381
|
throw timeoutError;
|
|
305
382
|
}
|
|
383
|
+
attempt++;
|
|
306
384
|
// Wait a bit before retrying
|
|
307
|
-
await
|
|
385
|
+
await sleep(100 * attempt);
|
|
308
386
|
continue;
|
|
309
387
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const isNetworkError = !err.response && !err.status;
|
|
313
|
-
if (!isNetworkError || attempt >= BaseApiClient.maxRetries) {
|
|
388
|
+
// HTTP errors (already decided above) are final.
|
|
389
|
+
if (isApiStorageClientError(err))
|
|
314
390
|
throw err;
|
|
391
|
+
// fetch() failed without a response. A refused/unresolvable
|
|
392
|
+
// connection never reached the API, so it is safe for every
|
|
393
|
+
// method; anything else (reset mid-request) only for idempotent ones.
|
|
394
|
+
if ((idempotent || isConnectFailure(err)) && serverAttempt < BaseApiClient.maxServerRetries) {
|
|
395
|
+
const waitMs = serverRetryDelayMs(serverAttempt, null);
|
|
396
|
+
serverAttempt++;
|
|
397
|
+
console.warn(`[Scrapely] ${method} ${path} failed (${describeFetchError(err)}); retry ${serverAttempt}/${BaseApiClient.maxServerRetries} in ${Math.round(waitMs)}ms`);
|
|
398
|
+
await sleep(waitMs);
|
|
399
|
+
continue;
|
|
315
400
|
}
|
|
316
|
-
|
|
317
|
-
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
|
|
401
|
+
throw err;
|
|
318
402
|
}
|
|
319
403
|
finally {
|
|
320
404
|
clearTimeout(timeoutId);
|
|
321
405
|
}
|
|
322
406
|
}
|
|
323
|
-
throw lastError || new Error('Request failed after retries');
|
|
324
407
|
}
|
|
325
|
-
async
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
for (let attempt = 0; attempt <= BaseApiClient.maxRetries; attempt++) {
|
|
332
|
-
await BaseApiClient.rateLimiter.waitForSlot();
|
|
333
|
-
const response = await fetch(url, {
|
|
334
|
-
method,
|
|
335
|
-
headers,
|
|
336
|
-
body: body !== undefined ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
|
|
337
|
-
});
|
|
338
|
-
BaseApiClient.rateLimiter.updateFromHeaders(response.headers);
|
|
339
|
-
if (response.status === 429) {
|
|
340
|
-
if (attempt < BaseApiClient.maxRetries) {
|
|
341
|
-
await BaseApiClient.rateLimiter.handleRateLimited(response);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
const error = await response.json().catch(() => ({ error: { message: 'Rate limit exceeded' } }));
|
|
345
|
-
throw new Error(error.error?.message || 'Rate limit exceeded');
|
|
408
|
+
async request(method, path, body, contentType, options) {
|
|
409
|
+
return this.send(method, path, body, contentType, options, async (response) => {
|
|
410
|
+
// Handle empty responses (204 No Content)
|
|
411
|
+
if (response.status === 204) {
|
|
412
|
+
return undefined;
|
|
346
413
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
414
|
+
// Handle text/plain responses
|
|
415
|
+
const responseContentType = response.headers.get('content-type') || '';
|
|
416
|
+
if (responseContentType.includes('text/plain')) {
|
|
417
|
+
return (await response.text());
|
|
350
418
|
}
|
|
419
|
+
// Handle binary responses
|
|
420
|
+
if (responseContentType.includes('application/octet-stream')) {
|
|
421
|
+
return Buffer.from(await response.arrayBuffer());
|
|
422
|
+
}
|
|
423
|
+
return unwrapData(await response.json());
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async requestWithHeaders(method, path, body, contentType) {
|
|
427
|
+
return this.send(method, path, body, contentType, undefined, async (response) => {
|
|
351
428
|
if (response.status === 204) {
|
|
352
429
|
return { data: undefined, contentType: '' };
|
|
353
430
|
}
|
|
@@ -356,13 +433,18 @@ class BaseApiClient {
|
|
|
356
433
|
return { data: await response.text(), contentType: 'text/plain' };
|
|
357
434
|
}
|
|
358
435
|
if (responseContentType.includes('application/octet-stream')) {
|
|
359
|
-
|
|
360
|
-
return { data: buffer, contentType: 'application/octet-stream' };
|
|
436
|
+
return { data: Buffer.from(await response.arrayBuffer()), contentType: 'application/octet-stream' };
|
|
361
437
|
}
|
|
362
438
|
const json = await response.json();
|
|
363
439
|
return { data: json, contentType: responseContentType.split(';')[0].trim() || 'application/json' };
|
|
364
|
-
}
|
|
365
|
-
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
exports.BaseApiClient = BaseApiClient;
|
|
444
|
+
/** Collection-level calls (create/get by name, list) with the same retry policy. */
|
|
445
|
+
class CollectionApiClient extends BaseApiClient {
|
|
446
|
+
call(method, path, options) {
|
|
447
|
+
return this.request(method, path, undefined, undefined, options);
|
|
366
448
|
}
|
|
367
449
|
}
|
|
368
450
|
/**
|
|
@@ -531,8 +613,12 @@ class ApiKeyValueStoreClient extends BaseApiClient {
|
|
|
531
613
|
}
|
|
532
614
|
return { key, value, contentType: contentType || 'application/json' };
|
|
533
615
|
}
|
|
534
|
-
catch {
|
|
535
|
-
|
|
616
|
+
catch (err) {
|
|
617
|
+
// Missing record only; anything else (API unavailable after the
|
|
618
|
+
// retries) must not look like "no record" (lost state, restarts).
|
|
619
|
+
if (isApiStorageClientError(err) && err.statusCode === 404)
|
|
620
|
+
return undefined;
|
|
621
|
+
throw err;
|
|
536
622
|
}
|
|
537
623
|
}
|
|
538
624
|
async setRecord(record, options) {
|
|
@@ -850,17 +936,7 @@ class ApiRequestQueueCollectionClient {
|
|
|
850
936
|
};
|
|
851
937
|
}
|
|
852
938
|
async list() {
|
|
853
|
-
const
|
|
854
|
-
const response = await fetch(url, {
|
|
855
|
-
headers: storageCollectionHeaders(this.config),
|
|
856
|
-
});
|
|
857
|
-
if (!response.ok) {
|
|
858
|
-
const error = await response.json().catch(() => ({ error: { message: response.statusText } }));
|
|
859
|
-
throw new Error(error.error?.message
|
|
860
|
-
|| `Failed to list request queues: ${response.status}`);
|
|
861
|
-
}
|
|
862
|
-
const body = await response.json();
|
|
863
|
-
const data = unwrapData(body);
|
|
939
|
+
const data = await new CollectionApiClient(this.config).call('GET', '/request-queues');
|
|
864
940
|
return {
|
|
865
941
|
total: data.total,
|
|
866
942
|
count: data.count,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapely",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.11",
|
|
4
4
|
"description": "Scrapely SDK for building JavaScript and TypeScript Actors",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "npx tsx tests/test-mvp.ts",
|
|
21
21
|
"test:charging": "npx tsx --test tests/charging-pending.test.ts",
|
|
22
|
+
"test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
|
|
22
23
|
"test:stdio-drain": "npx tsx --test tests/stdio-drain.test.ts",
|
|
23
24
|
"test:charging-client": "npx tsx --test tests/charging-client.test.ts",
|
|
24
25
|
"test:proxy": "npx tsx --test tests/proxy-configuration.test.ts",
|