@crawlee/core 4.0.0-beta.111 → 4.0.0-beta.113

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/errors.d.ts CHANGED
@@ -42,6 +42,26 @@ export declare class RequestValidationError extends NonRetryableError {
42
42
  export declare class RetryRequestError extends Error {
43
43
  constructor(message?: string);
44
44
  }
45
+ /**
46
+ * Thrown when a domain has rate-limited us and the request should simply be attempted again later.
47
+ *
48
+ * The request is reclaimed without recording a failure: it costs neither a retry nor session reputation, because
49
+ * nothing about the request or the session was at fault. A {@link ThrottlingRequestManager} holds it back until
50
+ * the domain's backoff expires, so retries are paced rather than immediate.
51
+ */
52
+ export declare class RequestThrottledError extends RetryRequestError {
53
+ constructor(message?: string);
54
+ }
55
+ /**
56
+ * Thrown when a domain has rate-limited us for so long that no request has got through, and the crawl is
57
+ * abandoned rather than kept waiting.
58
+ *
59
+ * Waiting longer will not help: at this point the concurrency is too high for the domain, or it has blocked us.
60
+ * The affected requests are deliberately left in their queue, so re-running the crawl without purging storages
61
+ * resumes them once the domain recovers.
62
+ */
63
+ export declare class PersistentRateLimitError extends CriticalError {
64
+ }
45
65
  /**
46
66
  * Errors of `SessionError` type retire the session associated with the request and trigger a regular retry.
47
67
  *
package/errors.js CHANGED
@@ -47,6 +47,28 @@ export class RetryRequestError extends Error {
47
47
  super(message ?? "Request is being retried at the user's request");
48
48
  }
49
49
  }
50
+ /**
51
+ * Thrown when a domain has rate-limited us and the request should simply be attempted again later.
52
+ *
53
+ * The request is reclaimed without recording a failure: it costs neither a retry nor session reputation, because
54
+ * nothing about the request or the session was at fault. A {@link ThrottlingRequestManager} holds it back until
55
+ * the domain's backoff expires, so retries are paced rather than immediate.
56
+ */
57
+ export class RequestThrottledError extends RetryRequestError {
58
+ constructor(message) {
59
+ super(message ?? 'Request is being retried later because its domain is rate-limiting us');
60
+ }
61
+ }
62
+ /**
63
+ * Thrown when a domain has rate-limited us for so long that no request has got through, and the crawl is
64
+ * abandoned rather than kept waiting.
65
+ *
66
+ * Waiting longer will not help: at this point the concurrency is too high for the domain, or it has blocked us.
67
+ * The affected requests are deliberately left in their queue, so re-running the crawl without purging storages
68
+ * resumes them once the domain recovers.
69
+ */
70
+ export class PersistentRateLimitError extends CriticalError {
71
+ }
50
72
  /**
51
73
  * Errors of `SessionError` type retire the session associated with the request and trigger a regular retry.
52
74
  *
package/http.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parses a `Retry-After` response header into a delay in milliseconds.
3
+ *
4
+ * The header holds either a non-negative number of seconds or an HTTP-date.
5
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After).
6
+ *
7
+ * @returns The delay in milliseconds, or `null` if the header is absent, unparseable, or already elapsed.
8
+ */
9
+ export declare function parseRetryAfterHeader(value?: string | null): number | null;
package/http.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Parses a `Retry-After` response header into a delay in milliseconds.
3
+ *
4
+ * The header holds either a non-negative number of seconds or an HTTP-date.
5
+ * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After).
6
+ *
7
+ * @returns The delay in milliseconds, or `null` if the header is absent, unparseable, or already elapsed.
8
+ */
9
+ export function parseRetryAfterHeader(value) {
10
+ if (!value) {
11
+ return null;
12
+ }
13
+ const trimmed = value.trim();
14
+ // Per the spec this is a `delay-seconds`: digits only, so a negative or fractional value is not one.
15
+ if (/^\d+$/.test(trimmed)) {
16
+ // `Retry-After: 0` names no future deadline, same as an HTTP-date that has already passed. Reporting it
17
+ // as a zero delay would leave the domain unthrottled while still counting as a rate-limit event, so the
18
+ // caller would defer the request for free and re-send it immediately.
19
+ const delayMs = Number(trimmed) * 1000;
20
+ return delayMs > 0 ? delayMs : null;
21
+ }
22
+ const date = Date.parse(trimmed);
23
+ if (!Number.isNaN(date)) {
24
+ const delayMs = date - Date.now();
25
+ return delayMs > 0 ? delayMs : null;
26
+ }
27
+ return null;
28
+ }
package/index.d.ts CHANGED
@@ -17,5 +17,6 @@ export * from './storages/index.js';
17
17
  export * from './memory-storage/index.js';
18
18
  export * from './validators.js';
19
19
  export * from './cookie_utils.js';
20
+ export * from './http.js';
20
21
  export * from './recoverable_state.js';
21
22
  export type { StorageBackend } from '@crawlee/types';
package/index.js CHANGED
@@ -17,4 +17,5 @@ export * from './storages/index.js';
17
17
  export * from './memory-storage/index.js';
18
18
  export * from './validators.js';
19
19
  export * from './cookie_utils.js';
20
+ export * from './http.js';
20
21
  export * from './recoverable_state.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/core",
3
- "version": "4.0.0-beta.111",
3
+ "version": "4.0.0-beta.113",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -52,9 +52,9 @@
52
52
  "@apify/log": "^2.5.18",
53
53
  "@apify/timeout": "^0.4.4",
54
54
  "@apify/utilities": "^2.15.5",
55
- "@crawlee/fs-storage": "4.0.0-beta.111",
56
- "@crawlee/types": "4.0.0-beta.111",
57
- "@crawlee/utils": "4.0.0-beta.111",
55
+ "@crawlee/fs-storage": "4.0.0-beta.113",
56
+ "@crawlee/types": "4.0.0-beta.113",
57
+ "@crawlee/utils": "4.0.0-beta.113",
58
58
  "@sapphire/async-queue": "^1.5.5",
59
59
  "@sapphire/shapeshift": "^4.0.0",
60
60
  "@vladfrangu/async_event_emitter": "^2.4.6",
@@ -78,5 +78,5 @@
78
78
  }
79
79
  }
80
80
  },
81
- "gitHead": "a6b7dec651fa7d81c61689e0a3cd0a8d06fdb586"
81
+ "gitHead": "a80d4b05a84ecc651dcd68fafd7a1ff35e6e7532"
82
82
  }
@@ -0,0 +1,37 @@
1
+ import type { ProcessedRequest } from '@crawlee/types';
2
+ import type { Source } from '../request.js';
3
+ import type { AddRequestsBatchedResult } from './request_queue.js';
4
+ export interface DrainRequestBatchesOptions<TItem extends Source> {
5
+ /**
6
+ * The requests to add, already normalized by the caller. Consumed lazily: an unbounded or expensive
7
+ * iterable is only pulled from as far as the batching (and any `maxNewRequests` budget) requires.
8
+ */
9
+ items: AsyncGenerator<TItem>;
10
+ batchSize: number;
11
+ waitBetweenBatchesMillis: number;
12
+ waitForAllRequestsToBeAdded: boolean;
13
+ maxNewRequests?: number;
14
+ /**
15
+ * Adds a single chunk and reports what it processed.
16
+ *
17
+ * @param isInitial Whether this is the first chunk, which is added before this function returns. Later
18
+ * chunks land in the background, which is why some callers cache only the first.
19
+ */
20
+ processChunk: (chunk: TItem[], isInitial: boolean) => Promise<ProcessedRequest[]>;
21
+ /**
22
+ * Called with the promise covering every chunk after the first, so the caller can keep its own
23
+ * `isFinished` honest while batches are still landing.
24
+ */
25
+ trackBackgroundBatches?: (batches: Promise<unknown>) => void;
26
+ }
27
+ /**
28
+ * Drives the chunk-by-chunk half of `addRequestsBatched`: the first chunk is added before returning and the
29
+ * rest continue in the background, paced by `waitBetweenBatchesMillis`.
30
+ *
31
+ * Callers differ only in how a chunk is added and how the input is normalized, so that is all
32
+ * {@link DrainRequestBatchesOptions} asks for - the budget arithmetic, the lazy chunking, the
33
+ * over-limit reporting and the transaction handling are identical for everyone and live here. In
34
+ * particular, every caller has to keep its background chunks out of a transaction they will outlive,
35
+ * so that is read from the ambient transaction rather than asked of the caller.
36
+ */
37
+ export declare function drainRequestBatches<TItem extends Source>(options: DrainRequestBatchesOptions<TItem>): Promise<AddRequestsBatchedResult>;
@@ -0,0 +1,73 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { chunkedAsyncIterable, peekableAsyncIterable } from '../iterables.js';
3
+ import { activeStorageTransaction, withDirectStorageAccess } from './transaction.js';
4
+ /**
5
+ * Drives the chunk-by-chunk half of `addRequestsBatched`: the first chunk is added before returning and the
6
+ * rest continue in the background, paced by `waitBetweenBatchesMillis`.
7
+ *
8
+ * Callers differ only in how a chunk is added and how the input is normalized, so that is all
9
+ * {@link DrainRequestBatchesOptions} asks for - the budget arithmetic, the lazy chunking, the
10
+ * over-limit reporting and the transaction handling are identical for everyone and live here. In
11
+ * particular, every caller has to keep its background chunks out of a transaction they will outlive,
12
+ * so that is read from the ambient transaction rather than asked of the caller.
13
+ */
14
+ export async function drainRequestBatches(options) {
15
+ const { items, batchSize, waitBetweenBatchesMillis, waitForAllRequestsToBeAdded, maxNewRequests, processChunk, trackBackgroundBatches, } = options;
16
+ const deferred = activeStorageTransaction()?.policy.requestQueue === 'deferred';
17
+ let remainingBudget = maxNewRequests ?? Infinity;
18
+ const requestsOverLimit = [];
19
+ // Never hand a chunk more than the budget allows, so an over-large final batch cannot overshoot.
20
+ const effectiveChunkSize = maxNewRequests !== undefined ? () => Math.min(batchSize, remainingBudget) : batchSize;
21
+ const chunks = peekableAsyncIterable(chunkedAsyncIterable(items, effectiveChunkSize));
22
+ const chunksIterator = chunks[Symbol.asyncIterator]();
23
+ const addChunk = async (chunk, isInitial) => {
24
+ const processedRequests = await processChunk(chunk, isInitial);
25
+ if (maxNewRequests !== undefined) {
26
+ remainingBudget -= processedRequests.filter((request) => !request.wasAlreadyPresent).length;
27
+ }
28
+ return processedRequests;
29
+ };
30
+ const buildResult = async (addedRequests, waitForAll) => {
31
+ if (maxNewRequests !== undefined) {
32
+ // `chunkedAsyncIterable` stops pulling once the budget-derived chunk size hits zero, so whatever
33
+ // is left is still sitting in `items` rather than in a chunk we have seen.
34
+ for await (const item of items) {
35
+ requestsOverLimit.push(item);
36
+ }
37
+ }
38
+ return { addedRequests, waitForAllRequestsToBeAdded: waitForAll, requestsOverLimit };
39
+ };
40
+ const initialChunk = await chunksIterator.peek();
41
+ if (initialChunk === undefined) {
42
+ return buildResult([], Promise.resolve([]));
43
+ }
44
+ const addedRequests = await addChunk(initialChunk, true);
45
+ await chunksIterator.next();
46
+ if ((await chunksIterator.peek()) === undefined) {
47
+ return buildResult(addedRequests, Promise.resolve([]));
48
+ }
49
+ const processRemainingChunks = async () => {
50
+ const added = [];
51
+ for await (const chunk of chunks) {
52
+ added.push(...(await addChunk(chunk, false)));
53
+ // Under `deferred` no chunk performs backend I/O, so pacing them would only stall the handler.
54
+ await sleep(deferred ? 0 : waitBetweenBatchesMillis);
55
+ }
56
+ return added;
57
+ };
58
+ // With a budget we must drain everything before we can report what went over it; under `deferred` a
59
+ // writer that finishes after commit would have nowhere to put its journal entries.
60
+ const awaitsRemainder = waitForAllRequestsToBeAdded || maxNewRequests !== undefined || deferred;
61
+ // An un-awaited writer outlives the transaction scope it inherits, so it must not record into a
62
+ // transaction that may already be closed. It writes directly - its write-through additions were never
63
+ // going to be rolled back anyway - which means the requests it adds are not journaled.
64
+ // See `StorageTransactionView.enqueuedUrls`.
65
+ const remainder = awaitsRemainder ? processRemainingChunks() : withDirectStorageAccess(processRemainingChunks);
66
+ // The caller is not obliged to await `remainder`, so give it a handler of its own - an unhandled
67
+ // rejection here would otherwise take the process down.
68
+ trackBackgroundBatches?.(remainder.catch(() => { }));
69
+ if (awaitsRemainder) {
70
+ addedRequests.push(...(await remainder));
71
+ }
72
+ return buildResult(addedRequests, remainder);
73
+ }
@@ -11,3 +11,4 @@ export * from './utils.js';
11
11
  export * from './transaction.js';
12
12
  export * from './sitemap_request_loader.js';
13
13
  export * from './request_manager_tandem.js';
14
+ export * from './throttling_request_manager.js';
package/storages/index.js CHANGED
@@ -9,3 +9,4 @@ export * from './utils.js';
9
9
  export * from './transaction.js';
10
10
  export * from './sitemap_request_loader.js';
11
11
  export * from './request_manager_tandem.js';
12
+ export * from './throttling_request_manager.js';
@@ -54,6 +54,11 @@ export interface IRequestLoader {
54
54
  * Resolves to `true` if the next call to {@link IRequestLoader.fetchNextRequest} function
55
55
  * would return `null`, otherwise it resolves to `false`.
56
56
  * Note that even if the loader is empty, there might be some pending requests currently being processed.
57
+ *
58
+ * This is a statement about what the *next fetch* would return, not about how much work is left, so it
59
+ * may report `true` while {@link IRequestLoader.getPendingCount} is non-zero - a loader that withholds
60
+ * requests for a while (as {@link ThrottlingRequestManager} does for a rate-limited domain) is empty
61
+ * for as long as it will not hand anything over. Use `isFinished()` to ask whether the work is done.
57
62
  */
58
63
  isEmpty(): Promise<boolean>;
59
64
  /**
@@ -1,15 +1,15 @@
1
1
  import { inspect } from 'node:util';
2
2
  import { isAsyncIterable, isIterable } from '@crawlee/utils/internal';
3
- import { downloadListOfUrls, sleep } from '@crawlee/utils';
3
+ import { downloadListOfUrls } from '@crawlee/utils';
4
4
  import ow from 'ow';
5
5
  import { LruCache } from '@apify/datastructures';
6
6
  import { tryCancel } from '@apify/timeout';
7
7
  import { Configuration } from '../configuration.js';
8
8
  import { getObjectType } from '../debug.js';
9
- import { chunkedAsyncIterable, peekableAsyncIterable } from '../iterables.js';
10
9
  import { Request } from '../request.js';
11
10
  import { serviceLocator } from '../service_locator.js';
12
- import { activeStorageTransaction, rejectOperationInTransaction, withDirectStorageAccess } from './transaction.js';
11
+ import { activeStorageTransaction, rejectOperationInTransaction } from './transaction.js';
12
+ import { drainRequestBatches } from './batched_adds.js';
13
13
  import { StorageStatsTracker } from './storage_stats.js';
14
14
  import { resolveStorageIdentifier } from './storage_instance_manager.js';
15
15
  import { getRequestId, purgeDefaultStorages } from './utils.js';
@@ -438,8 +438,6 @@ export class RequestQueue {
438
438
  * @param options Options for the request queue
439
439
  */
440
440
  async addRequestsBatched(requests, options = {}) {
441
- const transaction = activeStorageTransaction();
442
- const deferred = transaction?.policy.requestQueue === 'deferred';
443
441
  ow(requests, ow.object
444
442
  .is((value) => isIterable(value) || isAsyncIterable(value))
445
443
  .message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
@@ -476,103 +474,36 @@ export class RequestQueue {
476
474
  }
477
475
  }
478
476
  }
479
- const { batchSize = 1000, maxNewRequests = undefined } = options;
480
- // Under `deferred` no chunk performs backend I/O, so pacing them would only stall the handler.
481
- const waitBetweenBatchesMillis = deferred ? 0 : (options.waitBetweenBatchesMillis ?? 1000);
482
- let remainingBudget = maxNewRequests ?? Infinity;
483
- const requestsOverLimit = [];
484
- // If there's a limit on the number of added requests, do not send batches bigger than the limit
485
- const effectiveChunkSize = maxNewRequests !== undefined ? () => Math.min(batchSize, remainingBudget) : batchSize;
486
- // Hold onto the underlying iterator so we can drain leftovers from it in buildResult
487
- const requestIterator = generateRequests();
488
- const chunks = peekableAsyncIterable(chunkedAsyncIterable(requestIterator, effectiveChunkSize));
489
- const chunksIterator = chunks[Symbol.asyncIterator]();
490
- /**
491
- * Process a chunk: send it to the queue, then update the remaining budget if maxNewRequests is active.
492
- *
493
- * Requests the backend reports as unprocessed are warned about and skipped rather than retried:
494
- * `unprocessedRequests` is what remains after the backend's own transient-error handling - a
495
- * semantic rejection (e.g. a malformed `userData` shape) that re-sending would only re-poke.
496
- * Retrying transient failures is the storage backend's job, not the frontend's.
497
- */
498
- const processChunk = async (chunk, cache = true) => {
499
- const { processedRequests, unprocessedRequests } = await this.addRequests(chunk, {
500
- forefront: options.forefront,
501
- cache,
502
- });
503
- if (unprocessedRequests.length > 0) {
504
- this.log.warning('Some requests were rejected by the request queue and will be skipped. ' +
505
- "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
506
- }
507
- if (maxNewRequests !== undefined) {
508
- remainingBudget -= processedRequests.filter((r) => !r.wasAlreadyPresent).length;
509
- }
510
- return processedRequests;
511
- };
512
- /**
513
- * Build the final result. When maxNewRequests is set, drains any remaining items
514
- * from the underlying request iterator into requestsOverLimit.
515
- *
516
- * We accept the iterator explicitly (rather than closing over it) to make it obvious
517
- * that this is the *same* iterator that `chunkedAsyncIterable` has been consuming —
518
- * so only unconsumed items are drained. We drain `requestIterator` (not `chunks`)
519
- * because `chunkedAsyncIterable` stops yielding when the budget-based chunk size
520
- * drops to 0, leaving unconsumed items in the underlying iterator.
521
- */
522
- const buildResult = async (addedRequests, waitForAllRequestsToBeAdded, unconsumedIterator) => {
523
- if (maxNewRequests !== undefined) {
524
- for await (const request of unconsumedIterator) {
525
- requestsOverLimit.push(request);
477
+ return drainRequestBatches({
478
+ items: generateRequests(),
479
+ batchSize: options.batchSize ?? 1000,
480
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis ?? 1000,
481
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded ?? false,
482
+ maxNewRequests: options.maxNewRequests,
483
+ /**
484
+ * Requests the backend reports as unprocessed are warned about and skipped rather than retried:
485
+ * `unprocessedRequests` is what remains after the backend's own transient-error handling - a
486
+ * semantic rejection (e.g. a malformed `userData` shape) that re-sending would only re-poke.
487
+ * Retrying transient failures is the storage backend's job, not the frontend's.
488
+ */
489
+ processChunk: async (chunk, isInitial) => {
490
+ const { processedRequests, unprocessedRequests } = await this.addRequests(chunk, {
491
+ forefront: options.forefront,
492
+ cache: isInitial,
493
+ });
494
+ if (unprocessedRequests.length > 0) {
495
+ this.log.warning('Some requests were rejected by the request queue and will be skipped. ' +
496
+ "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
526
497
  }
527
- }
528
- return { addedRequests, waitForAllRequestsToBeAdded, requestsOverLimit };
529
- };
530
- // Add initial batch to process right away
531
- const initialChunk = await chunksIterator.peek();
532
- if (initialChunk === undefined) {
533
- return buildResult([], Promise.resolve([]), requestIterator);
534
- }
535
- const addedRequests = await processChunk(initialChunk);
536
- await chunksIterator.next();
537
- // If we have no more requests to add (either exhausted or budget hit), return immediately
538
- if ((await chunksIterator.peek()) === undefined) {
539
- return buildResult(addedRequests, Promise.resolve([]), requestIterator);
540
- }
541
- const processRemainingChunks = async () => {
542
- const finalAddedRequests = [];
543
- for await (const requestChunk of chunks) {
544
- finalAddedRequests.push(...(await processChunk(requestChunk, false)));
545
- await sleep(waitBetweenBatchesMillis);
546
- }
547
- return finalAddedRequests;
548
- };
549
- // maxNewRequests needs all batches to report skipped requests accurately; `deferred` needs them
550
- // too - a writer that finishes after commit would have nowhere to put its journal entries.
551
- const awaitsRemainingChunks = options.waitForAllRequestsToBeAdded || maxNewRequests !== undefined || deferred;
552
- // eslint-disable-next-line no-async-promise-executor
553
- const promise = new Promise(async (resolve) => {
554
- if (awaitsRemainingChunks) {
555
- // Awaited below, i.e. still within the caller's transaction scope, so the additions are
556
- // journaled like the initial chunk - introspection must not depend on where the chunk
557
- // boundary happened to fall.
558
- resolve(await processRemainingChunks());
559
- }
560
- else {
561
- // Nobody awaits this writer, so it outlives the transaction scope it inherits and must
562
- // not record into a transaction that may already be closed. It writes directly - its
563
- // write-through additions were never going to be rolled back anyway - which means the
564
- // requests it adds are not journaled. See `StorageTransactionView.enqueuedUrls`.
565
- resolve(await withDirectStorageAccess(processRemainingChunks));
566
- }
567
- });
568
- this.inProgressRequestBatchCount += 1;
569
- void promise.finally(() => {
570
- this.inProgressRequestBatchCount -= 1;
498
+ return processedRequests;
499
+ },
500
+ trackBackgroundBatches: (batches) => {
501
+ this.inProgressRequestBatchCount += 1;
502
+ void batches.finally(() => {
503
+ this.inProgressRequestBatchCount -= 1;
504
+ });
505
+ },
571
506
  });
572
- if (awaitsRemainingChunks) {
573
- addedRequests.push(...(await promise));
574
- }
575
- return buildResult(addedRequests, promise, requestIterator);
576
507
  }
577
508
  /**
578
509
  * Gets the request from the queue specified by its `uniqueKey`.
@@ -0,0 +1,216 @@
1
+ import type { Dictionary } from '@crawlee/types';
2
+ import type { Configuration } from '../configuration.js';
3
+ import type { Request, Source } from '../request.js';
4
+ import type { IRequestManager, RequestsLike } from './request_manager.js';
5
+ import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, RequestQueueOperationInfo, RequestQueueOperationOptions } from './request_queue.js';
6
+ import type { StorageIdentifier } from './storage_instance_manager.js';
7
+ import type { StorageOpenOptions } from './utils.js';
8
+ /**
9
+ * Opens a request manager, matching the shape of storage `open` methods such as
10
+ * {@link RequestQueue.open|`RequestQueue.open`}.
11
+ *
12
+ * {@link ThrottlingRequestManager} calls this once per configured domain, so every per-domain queue shares the
13
+ * concrete type and storage backend of the manager being wrapped.
14
+ */
15
+ export type RequestManagerOpener<T extends IRequestManager = IRequestManager> = (identifier: string | StorageIdentifier, options?: StorageOpenOptions) => Promise<T>;
16
+ /**
17
+ * A request manager that can pace requests per domain, as {@link ThrottlingRequestManager} does.
18
+ *
19
+ * The crawlers detect this structurally rather than by type, so a wrapper can opt in by forwarding these three
20
+ * methods without {@link IRequestManager} having to know that throttling exists.
21
+ */
22
+ export interface SupportsDomainThrottling {
23
+ /** @see {@link ThrottlingRequestManager.recordDomainDelay} */
24
+ recordDomainDelay(url: string, retryAfterMs?: number | null): boolean;
25
+ /** @see {@link ThrottlingRequestManager.setCrawlDelay} */
26
+ setCrawlDelay(url: string, delaySeconds: number): boolean;
27
+ /** @see {@link ThrottlingRequestManager.assertNoStalledDomains} */
28
+ assertNoStalledDomains(): Promise<void>;
29
+ }
30
+ /** Whether `manager` can pace requests per domain. */
31
+ export declare function supportsDomainThrottling(manager: unknown): manager is SupportsDomainThrottling;
32
+ /** Options for {@link ThrottlingRequestManager}. */
33
+ export interface ThrottlingRequestManagerOptions<T extends IRequestManager = IRequestManager> {
34
+ /**
35
+ * The request manager to wrap, usually a {@link RequestQueue}. Requests for domains that are not throttled
36
+ * are stored here.
37
+ */
38
+ inner: T;
39
+ /**
40
+ * Hostnames to throttle. Matching is case-insensitive and exact - wildcards such as `*.example.com` are not
41
+ * supported, so list each subdomain you care about. Requests for any other domain bypass throttling entirely.
42
+ *
43
+ * An internationalized domain may be given in either its unicode or its punycode form, and an IPv6 address
44
+ * has to be bracketed (`[::1]`).
45
+ */
46
+ domains: string[];
47
+ /**
48
+ * Opens the per-domain queues, one per entry in `domains`, each under the alias `throttled-<domain>`.
49
+ * @default RequestQueue.open
50
+ */
51
+ requestManagerOpener?: RequestManagerOpener<T>;
52
+ /**
53
+ * The delay applied after a domain's first HTTP 429, doubled on each subsequent one.
54
+ * @default 2
55
+ */
56
+ baseDelaySecs?: number;
57
+ /**
58
+ * Upper bound on the delay between requests to a rate-limited domain, applied to both the exponential
59
+ * backoff and a `Retry-After` value.
60
+ * @default 60
61
+ */
62
+ maxDelaySecs?: number;
63
+ /**
64
+ * How long a domain may rate-limit us without a single request getting through before the crawl is
65
+ * abandoned with a {@link PersistentRateLimitError}.
66
+ *
67
+ * A domain that keeps answering 429 for this long is not going to be crawled by waiting longer - the
68
+ * concurrency is too high for it, or it has blocked us outright. Its requests are deliberately left in
69
+ * their queue, so re-running the crawl without purging storages picks them up once the domain recovers.
70
+ *
71
+ * A crawler running with `keepAlive` is exempt - outliving a domain that will not let us through is the
72
+ * whole point there.
73
+ * @default 900
74
+ */
75
+ maxDomainStallSecs?: number;
76
+ }
77
+ /**
78
+ * A request manager that wraps another one and paces requests per domain.
79
+ *
80
+ * Requests for the configured {@link ThrottlingRequestManagerOptions.domains|`domains`} are routed into their own
81
+ * queue when they are added, so each request lives in exactly one place and deduplication keeps working. Everything
82
+ * else goes to the wrapped manager untouched.
83
+ *
84
+ * {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
85
+ * longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
86
+ * remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
87
+ * reports `true`, so the crawler idles instead of holding a concurrency slot open.
88
+ *
89
+ * Delays come from two places:
90
+ * - HTTP 429 responses, honouring `Retry-After` and otherwise backing off exponentially. The crawlers report these
91
+ * automatically; a request that is throttled is retried later without counting against `maxRequestRetries` and
92
+ * without penalising its session.
93
+ * - robots.txt `Crawl-delay` directives, when `respectRobotsTxtFile` is enabled.
94
+ *
95
+ * This is opt-in: throttling only happens for a domain you list explicitly.
96
+ *
97
+ * **Example usage:**
98
+ *
99
+ * ```ts
100
+ * const crawler = new CheerioCrawler({
101
+ * requestManager: new ThrottlingRequestManager({
102
+ * inner: await RequestQueue.open(),
103
+ * domains: ['api.example.com', 'slow-site.org'],
104
+ * }),
105
+ * requestHandler: async ({ request }) => { ... },
106
+ * });
107
+ * ```
108
+ *
109
+ * @category Sources
110
+ */
111
+ export declare class ThrottlingRequestManager<T extends IRequestManager = IRequestManager> implements IRequestManager, SupportsDomainThrottling {
112
+ private readonly config;
113
+ private readonly inner;
114
+ private readonly requestManagerOpener;
115
+ private readonly baseDelayMs;
116
+ private readonly maxDelayMs;
117
+ private readonly maxDomainStallMs;
118
+ private readonly domainStates;
119
+ private readonly subManagers;
120
+ private readonly log;
121
+ /**
122
+ * Sub-managers are keyed by a stable alias, so they outlive the process. They must therefore be reopened
123
+ * for every configured domain rather than created on first insert - otherwise a restart sees an empty map,
124
+ * reports the crawl finished, and strands whatever the previous run left in them.
125
+ */
126
+ private subManagersReady?;
127
+ /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
128
+ private inProgressBatchCount;
129
+ private readonly warnedAbout;
130
+ private get hasThrottledDomains();
131
+ constructor(options: ThrottlingRequestManagerOptions<T>, config?: Configuration);
132
+ /** The wrapped manager, holding every request whose domain is not throttled. */
133
+ get innerManager(): T;
134
+ /** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
135
+ private warnIfNotRoutable;
136
+ private warnOnce;
137
+ private extractDomain;
138
+ private getDomainState;
139
+ private selectManager;
140
+ /** Only valid once {@link ThrottlingRequestManager.ensureSubManagers} has resolved. */
141
+ private managerForUrl;
142
+ private ensureSubManagers;
143
+ private getSubManagers;
144
+ /** Configured domains that are not currently backing off, longest-overdue first. */
145
+ private fetchableDomains;
146
+ /**
147
+ * Records a 429 response and puts the URL's domain into backoff.
148
+ *
149
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
150
+ */
151
+ recordDomainDelay(url: string, retryAfterMs?: number | null): boolean;
152
+ /**
153
+ * Applies a robots.txt `Crawl-delay` to the URL's domain, as a minimum interval between dispatches.
154
+ *
155
+ * The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
156
+ *
157
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
158
+ */
159
+ setCrawlDelay(url: string, delaySeconds: number): boolean;
160
+ /**
161
+ * Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
162
+ * {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
163
+ * request through.
164
+ *
165
+ * A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
166
+ * has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
167
+ * `Crawl-delay` is being obeyed, not stonewalled.
168
+ */
169
+ assertNoStalledDomains(): Promise<void>;
170
+ /** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
171
+ private recordProgress;
172
+ addRequest(requestLike: Source, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo>;
173
+ /**
174
+ * Adds requests in batches, routing each one to the manager that owns its domain.
175
+ *
176
+ * Batching, validation, deduplication and `Retry-After`-free bookkeeping are all delegated to the target
177
+ * managers - this only decides where each request goes, one batch at a time, so a lazy or unbounded input
178
+ * iterable is never fully materialized.
179
+ */
180
+ addRequestsBatched(requests: RequestsLike, options?: AddRequestsBatchedOptions): Promise<AddRequestsBatchedResult>;
181
+ reclaimRequest(request: Request, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo | null>;
182
+ markRequestAsHandled(request: Request): Promise<RequestQueueOperationInfo | void | null>;
183
+ getTotalCount(): Promise<number>;
184
+ getPendingCount(): Promise<number>;
185
+ getHandledCount(): Promise<number>;
186
+ /**
187
+ * Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
188
+ *
189
+ * Requests waiting on a throttled domain count as unavailable, so a crawler whose task loop is gated on
190
+ * this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
191
+ */
192
+ isEmpty(): Promise<boolean>;
193
+ /** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
194
+ isFinished(): Promise<boolean>;
195
+ /**
196
+ * Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
197
+ * site rather than of the run, so it survives.
198
+ */
199
+ purge(): Promise<void>;
200
+ setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
201
+ private forEachManager;
202
+ private sumOverManagers;
203
+ private everyManager;
204
+ /**
205
+ * Returns the next request from a domain that is not backing off, or from the inner manager.
206
+ *
207
+ * Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
208
+ * out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
209
+ * capacity and answers by scaling up. Callers poll instead, and {@link ThrottlingRequestManager.isEmpty}
210
+ * reports `true` meanwhile so the crawler's task loop idles rather than spins.
211
+ */
212
+ fetchNextRequest<R extends Dictionary = Dictionary>(): Promise<Request<R> | null>;
213
+ [Symbol.asyncIterator](): AsyncGenerator<Request<Dictionary>, void, unknown>;
214
+ persistState(): Promise<void>;
215
+ drop(): Promise<void>;
216
+ }
@@ -0,0 +1,453 @@
1
+ import { URL } from 'node:url';
2
+ import ow from 'ow';
3
+ import { PersistentRateLimitError } from '../errors.js';
4
+ import { asyncifyIterable } from '../iterables.js';
5
+ import { serviceLocator } from '../service_locator.js';
6
+ import { normalizeHostname } from '../url.js';
7
+ import { drainRequestBatches } from './batched_adds.js';
8
+ import { RequestQueue } from './request_queue.js';
9
+ /** Whether `manager` can pace requests per domain. */
10
+ export function supportsDomainThrottling(manager) {
11
+ const candidate = manager;
12
+ return (typeof candidate?.recordDomainDelay === 'function' &&
13
+ typeof candidate.setCrawlDelay === 'function' &&
14
+ typeof candidate.assertNoStalledDomains === 'function');
15
+ }
16
+ /** The moment a domain may be dispatched to again - whichever of its two independent clocks runs longer. */
17
+ function throttledUntil(state) {
18
+ return Math.max(state.backoffUntil, state.crawlDelayUntil);
19
+ }
20
+ /**
21
+ * A request manager that wraps another one and paces requests per domain.
22
+ *
23
+ * Requests for the configured {@link ThrottlingRequestManagerOptions.domains|`domains`} are routed into their own
24
+ * queue when they are added, so each request lives in exactly one place and deduplication keeps working. Everything
25
+ * else goes to the wrapped manager untouched.
26
+ *
27
+ * {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
28
+ * longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
29
+ * remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
30
+ * reports `true`, so the crawler idles instead of holding a concurrency slot open.
31
+ *
32
+ * Delays come from two places:
33
+ * - HTTP 429 responses, honouring `Retry-After` and otherwise backing off exponentially. The crawlers report these
34
+ * automatically; a request that is throttled is retried later without counting against `maxRequestRetries` and
35
+ * without penalising its session.
36
+ * - robots.txt `Crawl-delay` directives, when `respectRobotsTxtFile` is enabled.
37
+ *
38
+ * This is opt-in: throttling only happens for a domain you list explicitly.
39
+ *
40
+ * **Example usage:**
41
+ *
42
+ * ```ts
43
+ * const crawler = new CheerioCrawler({
44
+ * requestManager: new ThrottlingRequestManager({
45
+ * inner: await RequestQueue.open(),
46
+ * domains: ['api.example.com', 'slow-site.org'],
47
+ * }),
48
+ * requestHandler: async ({ request }) => { ... },
49
+ * });
50
+ * ```
51
+ *
52
+ * @category Sources
53
+ */
54
+ export class ThrottlingRequestManager {
55
+ config;
56
+ inner;
57
+ requestManagerOpener;
58
+ baseDelayMs;
59
+ maxDelayMs;
60
+ maxDomainStallMs;
61
+ domainStates = new Map();
62
+ subManagers = new Map();
63
+ log;
64
+ /**
65
+ * Sub-managers are keyed by a stable alias, so they outlive the process. They must therefore be reopened
66
+ * for every configured domain rather than created on first insert - otherwise a restart sees an empty map,
67
+ * reports the crawl finished, and strands whatever the previous run left in them.
68
+ */
69
+ subManagersReady;
70
+ /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
71
+ inProgressBatchCount = 0;
72
+ warnedAbout = new Set();
73
+ get hasThrottledDomains() {
74
+ return this.domainStates.size > 0;
75
+ }
76
+ constructor(options, config = serviceLocator.getConfiguration()) {
77
+ this.config = config;
78
+ ow(options, ow.object.exactShape({
79
+ inner: ow.object,
80
+ domains: ow.array.ofType(ow.string.nonEmpty),
81
+ requestManagerOpener: ow.optional.function,
82
+ baseDelaySecs: ow.optional.number.positive,
83
+ maxDelaySecs: ow.optional.number.positive,
84
+ maxDomainStallSecs: ow.optional.number.positive,
85
+ }));
86
+ this.inner = options.inner;
87
+ this.requestManagerOpener =
88
+ options.requestManagerOpener ??
89
+ ((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
90
+ this.baseDelayMs = (options.baseDelaySecs ?? 2) * 1000;
91
+ this.maxDelayMs = (options.maxDelaySecs ?? 60) * 1000;
92
+ this.maxDomainStallMs = (options.maxDomainStallSecs ?? 900) * 1000;
93
+ this.log = serviceLocator.getLogger().child({ prefix: 'ThrottlingRequestManager' });
94
+ for (const domain of options.domains) {
95
+ let hostname;
96
+ try {
97
+ // These are bare hostnames, so they only reach `URL` - and with it IDNA - via a synthetic URL.
98
+ hostname = normalizeHostname(new URL(`http://${domain}`).hostname);
99
+ }
100
+ catch {
101
+ throw new Error(`"${domain}" is not a valid hostname. The \`domains\` option takes bare hostnames such as ` +
102
+ `"example.com"; an IPv6 address has to be bracketed, as in "[::1]".`);
103
+ }
104
+ this.domainStates.set(hostname, {
105
+ domain: hostname,
106
+ backoffUntil: 0,
107
+ crawlDelayUntil: 0,
108
+ backoffDecaysAt: 0,
109
+ consecutive429Count: 0,
110
+ crawlDelayMs: null,
111
+ rateLimitedSince: 0,
112
+ lastRateLimitedAt: 0,
113
+ });
114
+ }
115
+ }
116
+ /** The wrapped manager, holding every request whose domain is not throttled. */
117
+ get innerManager() {
118
+ return this.inner;
119
+ }
120
+ /** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
121
+ warnIfNotRoutable(requestLike) {
122
+ if ('requestsFromUrl' in requestLike && requestLike.requestsFromUrl !== undefined && this.hasThrottledDomains) {
123
+ // The URL list is only fetched once the owning manager expands it, so we cannot know which domains
124
+ // it covers and cannot route it. Warn instead of silently exempting those URLs from throttling.
125
+ this.warnOnce('urlListNotRouted', `Requests loaded via \`requestsFromUrl\` cannot be routed to a per-domain queue, because their URLs ` +
126
+ `are not known at insertion time. They will be added to the inner request manager and will not ` +
127
+ `be throttled, even if they belong to a configured domain.`);
128
+ }
129
+ }
130
+ warnOnce(key, message) {
131
+ if (this.warnedAbout.has(key)) {
132
+ return;
133
+ }
134
+ this.warnedAbout.add(key);
135
+ this.log.warning(message);
136
+ }
137
+ extractDomain(url) {
138
+ try {
139
+ return normalizeHostname(new URL(url).hostname);
140
+ }
141
+ catch {
142
+ return '';
143
+ }
144
+ }
145
+ getDomainState(url) {
146
+ const domain = this.extractDomain(url);
147
+ return this.domainStates.get(domain) ?? null;
148
+ }
149
+ async selectManager(url) {
150
+ await this.ensureSubManagers();
151
+ return this.managerForUrl(url);
152
+ }
153
+ /** Only valid once {@link ThrottlingRequestManager.ensureSubManagers} has resolved. */
154
+ managerForUrl(url) {
155
+ return this.subManagers.get(this.extractDomain(url)) ?? this.inner;
156
+ }
157
+ async ensureSubManagers() {
158
+ this.subManagersReady ??= (async () => {
159
+ await Promise.all(Array.from(this.domainStates.keys(), async (domain) => {
160
+ const subManager = await this.requestManagerOpener(
161
+ // Backends use the alias as a directory name, and an IPv6 literal is full of characters
162
+ // Windows will not accept. Ordinary hostnames survive this untouched.
163
+ { alias: `throttled-${encodeURIComponent(domain)}` }, { configuration: this.config });
164
+ this.subManagers.set(domain, subManager);
165
+ }));
166
+ })();
167
+ await this.subManagersReady;
168
+ }
169
+ async getSubManagers() {
170
+ await this.ensureSubManagers();
171
+ return Array.from(this.subManagers.values());
172
+ }
173
+ /** Configured domains that are not currently backing off, longest-overdue first. */
174
+ fetchableDomains() {
175
+ const now = Date.now();
176
+ return Array.from(this.domainStates.values())
177
+ .filter((state) => now >= throttledUntil(state))
178
+ .sort((a, b) => throttledUntil(a) - throttledUntil(b))
179
+ .map((state) => state.domain);
180
+ }
181
+ /**
182
+ * Records a 429 response and puts the URL's domain into backoff.
183
+ *
184
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
185
+ */
186
+ recordDomainDelay(url, retryAfterMs) {
187
+ const state = this.getDomainState(url);
188
+ if (!state) {
189
+ return false;
190
+ }
191
+ const now = Date.now();
192
+ // Recorded before the burst suppression below, because a suppressed 429 is still the domain turning us
193
+ // away - which is exactly what stall detection needs to know about.
194
+ state.lastRateLimitedAt = now;
195
+ if (state.rateLimitedSince === 0) {
196
+ state.rateLimitedSince = now;
197
+ }
198
+ // Requests already in flight when the limit was hit all come back 429. They describe one rate-limit
199
+ // event, so only the first advances the backoff - otherwise concurrency alone drives the exponent.
200
+ // Only the backoff clock may suppress here: `crawlDelayUntil` is in the future after every dispatch,
201
+ // so consulting it would discard every 429 the domain ever sends, `Retry-After` included.
202
+ if (now < state.backoffUntil) {
203
+ return true;
204
+ }
205
+ // A domain that has served us for a full extra backoff window is no longer rate-limiting; start over
206
+ // rather than carrying the old exponent into an unrelated burst.
207
+ if (now >= state.backoffDecaysAt) {
208
+ state.consecutive429Count = 0;
209
+ }
210
+ state.consecutive429Count += 1;
211
+ const retryAfterGiven = retryAfterMs !== undefined && retryAfterMs !== null;
212
+ let delayMs = retryAfterGiven ? retryAfterMs : this.baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
213
+ if (delayMs > this.maxDelayMs) {
214
+ const source = retryAfterGiven ? 'Retry-After header' : 'exponential backoff';
215
+ this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
216
+ `to maxDelaySecs (${(this.maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
217
+ `Consider increasing maxDelaySecs if this recurs.`);
218
+ delayMs = this.maxDelayMs;
219
+ }
220
+ state.backoffUntil = now + delayMs;
221
+ state.backoffDecaysAt = state.backoffUntil + delayMs;
222
+ this.log.info(`Rate limit (429) detected for domain "${state.domain}" ` +
223
+ `(consecutive: ${state.consecutive429Count}, delay: ${(delayMs / 1000).toFixed(1)}s)`);
224
+ return true;
225
+ }
226
+ /**
227
+ * Applies a robots.txt `Crawl-delay` to the URL's domain, as a minimum interval between dispatches.
228
+ *
229
+ * The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
230
+ *
231
+ * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
232
+ */
233
+ setCrawlDelay(url, delaySeconds) {
234
+ const state = this.getDomainState(url);
235
+ if (!state) {
236
+ return false;
237
+ }
238
+ if (state.crawlDelayMs === null) {
239
+ state.crawlDelayMs = delaySeconds * 1000;
240
+ this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${delaySeconds}s`);
241
+ }
242
+ return true;
243
+ }
244
+ /**
245
+ * Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
246
+ * {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
247
+ * request through.
248
+ *
249
+ * A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
250
+ * has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
251
+ * `Crawl-delay` is being obeyed, not stonewalled.
252
+ */
253
+ async assertNoStalledDomains() {
254
+ await this.ensureSubManagers();
255
+ const now = Date.now();
256
+ const candidates = Array.from(this.domainStates.values()).filter(
257
+ // Together: it is still turning us away, and has been doing so without a break for longer than the
258
+ // window. A domain that has simply been idle starts this clock at its first 429 rather than
259
+ // arriving with the idle time already on it.
260
+ (state) => state.rateLimitedSince !== 0 &&
261
+ now - state.lastRateLimitedAt <= this.maxDomainStallMs &&
262
+ now - state.rateLimitedSince > this.maxDomainStallMs);
263
+ const stalled = (await Promise.all(candidates.map(async (state) => ((await this.subManagers.get(state.domain).isEmpty()) ? null : state)))).filter((state) => state !== null);
264
+ if (stalled.length === 0) {
265
+ return;
266
+ }
267
+ const summary = stalled
268
+ .map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
269
+ .join(', ');
270
+ throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
271
+ `(${(this.maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
272
+ `crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
273
+ `without purging storages will resume them if the rate limit lifts.`);
274
+ }
275
+ /** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
276
+ recordProgress(url) {
277
+ const state = this.getDomainState(url);
278
+ if (state) {
279
+ state.rateLimitedSince = 0;
280
+ }
281
+ }
282
+ // --- IRequestManager Implementation ---
283
+ async addRequest(requestLike, options) {
284
+ this.warnIfNotRoutable(requestLike);
285
+ const manager = await this.selectManager(requestLike.url ?? '');
286
+ return manager.addRequest(requestLike, options);
287
+ }
288
+ /**
289
+ * Adds requests in batches, routing each one to the manager that owns its domain.
290
+ *
291
+ * Batching, validation, deduplication and `Retry-After`-free bookkeeping are all delegated to the target
292
+ * managers - this only decides where each request goes, one batch at a time, so a lazy or unbounded input
293
+ * iterable is never fully materialized.
294
+ */
295
+ async addRequestsBatched(requests, options = {}) {
296
+ await this.ensureSubManagers();
297
+ // Normalized up front so the shared batching helper - and `requestsOverLimit` - only ever see `Source`.
298
+ async function* iterateRequests() {
299
+ for await (const request of asyncifyIterable(requests)) {
300
+ yield typeof request === 'string' ? { url: request } : request;
301
+ }
302
+ }
303
+ return drainRequestBatches({
304
+ items: iterateRequests(),
305
+ batchSize: options.batchSize ?? 1000,
306
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis ?? 1000,
307
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded ?? false,
308
+ maxNewRequests: options.maxNewRequests,
309
+ // Routing is the only thing this manager adds; the targets do the batching, validation and
310
+ // deduplication themselves.
311
+ processChunk: async (chunk) => {
312
+ const byManager = new Map();
313
+ for (const request of chunk) {
314
+ this.warnIfNotRoutable(request);
315
+ const manager = this.managerForUrl(request.url ?? '');
316
+ const bucket = byManager.get(manager);
317
+ if (bucket) {
318
+ bucket.push(request);
319
+ }
320
+ else {
321
+ byManager.set(manager, [request]);
322
+ }
323
+ }
324
+ const results = await Promise.all(Array.from(byManager, ([manager, slice]) => manager.addRequestsBatched(slice, {
325
+ forefront: options.forefront,
326
+ // The slice is already one batch, and we need its results before releasing the next one.
327
+ batchSize: slice.length,
328
+ waitForAllRequestsToBeAdded: true,
329
+ })));
330
+ return results.flatMap((result) => result.addedRequests);
331
+ },
332
+ // Keeps the crawler from concluding it is finished while batches are still landing.
333
+ trackBackgroundBatches: (batches) => {
334
+ this.inProgressBatchCount += 1;
335
+ void batches.finally(() => {
336
+ this.inProgressBatchCount -= 1;
337
+ });
338
+ },
339
+ });
340
+ }
341
+ async reclaimRequest(request, options) {
342
+ const manager = await this.selectManager(request.url);
343
+ return manager.reclaimRequest(request, options);
344
+ }
345
+ async markRequestAsHandled(request) {
346
+ const manager = await this.selectManager(request.url);
347
+ // Reached whether the request succeeded or ran out of retries; either way the domain answered us.
348
+ this.recordProgress(request.url);
349
+ return manager.markRequestAsHandled(request);
350
+ }
351
+ async getTotalCount() {
352
+ return this.sumOverManagers((manager) => manager.getTotalCount());
353
+ }
354
+ async getPendingCount() {
355
+ return this.sumOverManagers((manager) => manager.getPendingCount());
356
+ }
357
+ async getHandledCount() {
358
+ return this.sumOverManagers((manager) => manager.getHandledCount());
359
+ }
360
+ /**
361
+ * Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
362
+ *
363
+ * Requests waiting on a throttled domain count as unavailable, so a crawler whose task loop is gated on
364
+ * this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
365
+ */
366
+ async isEmpty() {
367
+ await this.ensureSubManagers();
368
+ const fetchable = [this.inner, ...this.fetchableDomains().map((domain) => this.subManagers.get(domain))];
369
+ const results = await Promise.all(fetchable.map((manager) => manager.isEmpty()));
370
+ return results.every(Boolean);
371
+ }
372
+ /** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
373
+ async isFinished() {
374
+ if (this.inProgressBatchCount > 0) {
375
+ return false;
376
+ }
377
+ return this.everyManager((manager) => manager.isFinished());
378
+ }
379
+ /**
380
+ * Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
381
+ * site rather than of the run, so it survives.
382
+ */
383
+ async purge() {
384
+ await this.forEachManager((manager) => manager.purge?.());
385
+ for (const state of this.domainStates.values()) {
386
+ state.consecutive429Count = 0;
387
+ state.backoffUntil = 0;
388
+ state.crawlDelayUntil = 0;
389
+ state.backoffDecaysAt = 0;
390
+ state.rateLimitedSince = 0;
391
+ state.lastRateLimitedAt = 0;
392
+ }
393
+ }
394
+ async setExpectedRequestProcessingTimeSecs(secs) {
395
+ await this.forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
396
+ }
397
+ async forEachManager(fn) {
398
+ // `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
399
+ await Promise.all([this.inner, ...(await this.getSubManagers())].map(async (manager) => fn(manager)));
400
+ }
401
+ async sumOverManagers(fn) {
402
+ const counts = await Promise.all([this.inner, ...(await this.getSubManagers())].map(fn));
403
+ return counts.reduce((a, b) => a + b, 0);
404
+ }
405
+ async everyManager(fn) {
406
+ const results = await Promise.all([this.inner, ...(await this.getSubManagers())].map(fn));
407
+ return results.every(Boolean);
408
+ }
409
+ /**
410
+ * Returns the next request from a domain that is not backing off, or from the inner manager.
411
+ *
412
+ * Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
413
+ * out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
414
+ * capacity and answers by scaling up. Callers poll instead, and {@link ThrottlingRequestManager.isEmpty}
415
+ * reports `true` meanwhile so the crawler's task loop idles rather than spins.
416
+ */
417
+ async fetchNextRequest() {
418
+ await this.ensureSubManagers();
419
+ for (const domain of this.fetchableDomains()) {
420
+ const state = this.domainStates.get(domain);
421
+ // Armed while the fetch below is still suspended, so that a concurrent `fetchNextRequest` cannot
422
+ // find the domain fetchable and dispatch into the same window - which would pace each task
423
+ // rather than the domain.
424
+ const crawlDelayBefore = state.crawlDelayUntil;
425
+ if (state.crawlDelayMs !== null) {
426
+ state.crawlDelayUntil = Date.now() + state.crawlDelayMs;
427
+ }
428
+ const request = await this.subManagers.get(domain).fetchNextRequest();
429
+ if (request) {
430
+ return request;
431
+ }
432
+ // No dispatch to pace, so the domain keeps its slot.
433
+ state.crawlDelayUntil = crawlDelayBefore;
434
+ }
435
+ return this.inner.fetchNextRequest();
436
+ }
437
+ async *[Symbol.asyncIterator]() {
438
+ while (true) {
439
+ const req = await this.fetchNextRequest();
440
+ if (!req)
441
+ break;
442
+ yield req;
443
+ }
444
+ }
445
+ async persistState() {
446
+ await this.forEachManager((manager) => manager.persistState?.());
447
+ }
448
+ async drop() {
449
+ await this.forEachManager((manager) => manager.drop?.());
450
+ this.subManagers.clear();
451
+ this.subManagersReady = undefined;
452
+ }
453
+ }
package/url.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The canonical form of a hostname: lower-case, punycode, and without the optional root dot.
3
+ *
4
+ * Pass both sides of a hostname comparison through this, so that a domain written as `háčky.cz` still matches the
5
+ * `xn--hky-ela4t.cz` that `URL` reports.
6
+ *
7
+ * @internal
8
+ */
9
+ export declare function normalizeHostname(hostname: string): string;
package/url.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The canonical form of a hostname: lower-case, punycode, and without the optional root dot.
3
+ *
4
+ * Pass both sides of a hostname comparison through this, so that a domain written as `háčky.cz` still matches the
5
+ * `xn--hky-ela4t.cz` that `URL` reports.
6
+ *
7
+ * @internal
8
+ */
9
+ export function normalizeHostname(hostname) {
10
+ return hostname.toLowerCase().replace(/\.$/, '');
11
+ }