@crawlee/core 4.0.0-beta.145 → 4.0.0-beta.147

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.
@@ -1,16 +1,16 @@
1
1
  import { URL } from 'node:url';
2
2
  import { getDomain } from 'tldts';
3
3
  import { z } from 'zod';
4
- import { PersistentRateLimitError } from '../errors.js';
5
4
  import { asyncifyIterable } from '../iterables.js';
6
5
  import { serviceLocator } from '../service_locator.js';
7
6
  import { normalizeHostname } from '../url.js';
8
7
  import { parseArgument, schemas } from '../validators.js';
9
8
  import { drainRequestBatches } from './batched_adds.js';
10
9
  import { KeyValueStore } from './key_value_store.js';
10
+ import { joinRequestSourceStatuses } from './request_loader.js';
11
11
  import { RequestQueue } from './request_queue.js';
12
12
  const throttlingRequestManagerOptionsSchema = z.strictObject({
13
- inner: schemas.anyObject,
13
+ inner: z.union([schemas.anyObject, schemas.anyFunction]).optional(),
14
14
  domains: z.union([schemas.arrayOf(z.string().nonempty(), 'non-empty strings'), z.literal('all')]),
15
15
  requestManagerOpener: schemas.anyFunction.optional(),
16
16
  baseDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
@@ -23,13 +23,6 @@ const throttlingRequestManagerOptionsSchema = z.strictObject({
23
23
  maxThrottledDomains: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
24
24
  persistStateKey: z.string().nonempty().optional(),
25
25
  });
26
- /** Whether `manager` can pace requests per domain. */
27
- export function supportsDomainThrottling(manager) {
28
- const candidate = manager;
29
- return (typeof candidate?.recordDomainDelay === 'function' &&
30
- typeof candidate.setCrawlDelay === 'function' &&
31
- typeof candidate.assertNoStalledDomains === 'function');
32
- }
33
26
  /** The moment a domain may be dispatched to again - whichever of its two independent clocks runs longer. */
34
27
  function throttledUntil(state) {
35
28
  return Math.max(state.backoffUntil, state.crawlDelayUntil);
@@ -59,8 +52,9 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
59
52
  *
60
53
  * {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
61
54
  * longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
62
- * remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
63
- * reports `true`, so the crawler idles instead of holding a concurrency slot open.
55
+ * remaining request belongs to a throttled domain it returns `null` and
56
+ * {@link ThrottlingRequestManager.checkReadiness|`checkReadiness()`} reports `waiting` with the moment the
57
+ * earliest of them comes due, so the crawler idles instead of holding a concurrency slot open.
64
58
  *
65
59
  * Each throttled domain runs two independent clocks, and may be dispatched to once **both** have run out:
66
60
  * - **Backoff**, set by HTTP 429 responses - honouring `Retry-After`, and otherwise doubling from `baseDelaySecs`.
@@ -75,12 +69,19 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
75
69
  * Which domains get those clocks is {@link ThrottlingRequestManagerOptions.domains|`domains`} - a list, or
76
70
  * `'all'` for every domain the crawl encounters.
77
71
  *
72
+ * Pass one as a crawler's `requestManager`; the `sameDomainDelaySecs` shorthand builds one with `domains: 'all'`
73
+ * and `throttleBy: 'registrableDomain'`. Construct it yourself to name the domains or tune the delays - one
74
+ * covering every domain also makes `sameDomainDelaySecs` land on it as a floor rather than adding a second pacer.
75
+ *
76
+ * Signals - 429s, robots.txt `Crawl-delay`, that floor - arrive through
77
+ * {@link IRequestManager.recordPacingSignal|`recordPacingSignal`}, which wrapping managers forward, so this
78
+ * works wherever it sits in a composition, including inside a {@link RequestManagerTandem}.
79
+ *
78
80
  * **Example usage:**
79
81
  *
80
82
  * ```ts
81
83
  * const crawler = new CheerioCrawler({
82
84
  * requestManager: new ThrottlingRequestManager({
83
- * inner: await RequestQueue.open(),
84
85
  * domains: ['api.example.com', 'slow-site.org'],
85
86
  * }),
86
87
  * requestHandler: async ({ request }) => { ... },
@@ -91,7 +92,9 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
91
92
  */
92
93
  export class ThrottlingRequestManager {
93
94
  config;
94
- #inner;
95
+ #innerFactory;
96
+ #innerPromise;
97
+ #resolvedInner;
95
98
  #requestManagerOpener;
96
99
  #baseDelayMs;
97
100
  #maxDelayMs;
@@ -128,8 +131,10 @@ export class ThrottlingRequestManager {
128
131
  * restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
129
132
  */
130
133
  #subManagersReady;
131
- /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
134
+ /** Batches still being added in the background; keeps {@link ThrottlingRequestManager.checkReadiness} honest. */
132
135
  #inProgressBatchCount = 0;
136
+ /** The latest {@link setExpectedRequestProcessingTimeSecs} hint, kept for a wrapped manager resolved later. */
137
+ #expectedRequestProcessingSecs;
133
138
  #warnedAbout = new Set();
134
139
  /** Whether any domain at all may end up throttled - listed up front, or discovered as the crawl runs. */
135
140
  get #throttlingEnabled() {
@@ -138,7 +143,19 @@ export class ThrottlingRequestManager {
138
143
  constructor(options, config = serviceLocator.getConfiguration()) {
139
144
  this.config = config;
140
145
  parseArgument(options, throttlingRequestManagerOptionsSchema, 'ThrottlingRequestManagerOptions');
141
- this.#inner = options.inner;
146
+ if (options.inner === undefined) {
147
+ // Deferred like any other factory, so a manager nobody fetches from opens no storage. The opener is
148
+ // assigned below and read only when this runs.
149
+ this.#innerFactory = () => this.#requestManagerOpener(null, { configuration: this.config });
150
+ }
151
+ else if (typeof options.inner === 'function') {
152
+ this.#innerFactory = options.inner;
153
+ }
154
+ else {
155
+ // Nothing to open: resolved from the start, so `innerManager` and bookkeeping see it immediately.
156
+ this.#resolvedInner = options.inner;
157
+ this.#innerFactory = () => this.#resolvedInner;
158
+ }
142
159
  this.#requestManagerOpener =
143
160
  options.requestManagerOpener ??
144
161
  ((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
@@ -180,9 +197,30 @@ export class ThrottlingRequestManager {
180
197
  // so those stay paced per hostname.
181
198
  return getDomain(normalized, { mixedInputs: false }) ?? normalized;
182
199
  }
183
- /** The wrapped manager, holding every request whose domain is not throttled. */
200
+ /**
201
+ * The wrapped manager, holding every request whose domain is not throttled. `undefined` until an `inner`
202
+ * passed as a factory is resolved - reading this never forces it, because a getter should not open a queue
203
+ * behind a caller's back.
204
+ */
184
205
  get innerManager() {
185
- return this.#inner;
206
+ return this.#resolvedInner;
207
+ }
208
+ /**
209
+ * Resolves the wrapped manager, opening a factory `inner` on first use and memoizing it.
210
+ *
211
+ * Memoized for identity as much as for cost: {@link addRequestsBatched} groups by manager identity and
212
+ * {@link reclaimRequest} compares against it, so a fresh instance per call would split batches and reclaim
213
+ * requests into a manager that never handed them out.
214
+ */
215
+ async #getInner() {
216
+ if (this.#resolvedInner === undefined) {
217
+ this.#innerPromise ??= Promise.resolve(this.#innerFactory());
218
+ this.#resolvedInner = await this.#innerPromise;
219
+ if (this.#expectedRequestProcessingSecs !== undefined) {
220
+ await this.#resolvedInner.setExpectedRequestProcessingTimeSecs?.(this.#expectedRequestProcessingSecs);
221
+ }
222
+ }
223
+ return this.#resolvedInner;
186
224
  }
187
225
  /** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
188
226
  #warnIfNotRoutable(requestLike) {
@@ -222,7 +260,7 @@ export class ThrottlingRequestManager {
222
260
  await this.#ensureSubManagers();
223
261
  const domain = this.#extractDomain(url);
224
262
  if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
225
- return this.#inner;
263
+ return this.#getInner();
226
264
  }
227
265
  if (!this.#listedDomains.has(domain) && !this.#discoveredDomains.has(domain)) {
228
266
  if (this.#discoveredDomains.size >= this.#maxThrottledDomains) {
@@ -319,11 +357,77 @@ export class ThrottlingRequestManager {
319
357
  .map((state) => state.domain);
320
358
  }
321
359
  /**
322
- * Records a 429 response and puts the URL's domain into backoff.
360
+ * Records a pacing signal: a refusal puts the URL's domain into backoff, a declared interval becomes its
361
+ * crawl delay, and a crawl-wide floor raises {@link recordEverywhereFloor|the floor under all of them}.
323
362
  *
324
- * @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
363
+ * @returns `false` if the domain the signal covers is not throttled, in which case this is a no-op.
364
+ * @throws If the signal's scope is one this manager cannot honour - see {@link assertScopeHonourable}.
365
+ * @inheritdoc
325
366
  */
326
- recordDomainDelay(url, retryAfterMs) {
367
+ recordPacingSignal(signal) {
368
+ if (signal.reason === 'minIntervalEverywhere') {
369
+ return this.#recordEverywhereFloor(signal.intervalMs, signal.scope);
370
+ }
371
+ this.#assertScopeHonourable(signal.scope);
372
+ return signal.reason === 'rateLimited'
373
+ ? this.#recordRateLimit(signal.url, signal.waitMs)
374
+ : this.#recordDeclaredInterval(signal.url, signal.intervalMs);
375
+ }
376
+ /**
377
+ * The runtime form of {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`}: raises
378
+ * the floor under every throttled domain's crawl delay.
379
+ *
380
+ * @returns `false` if this manager paces nothing at all, so there is no floor to raise.
381
+ * @throws If it paces some domains but not all - holding back only those would leave the rest of what the
382
+ * floor covers unpaced.
383
+ */
384
+ #recordEverywhereFloor(intervalMs, scope) {
385
+ // Before the scope check: a manager that paces nothing has no grouping worth objecting about, and
386
+ // `false` lets the caller pace it from outside.
387
+ if (!this.#throttlingEnabled) {
388
+ return false;
389
+ }
390
+ this.#assertScopeHonourable(scope);
391
+ if (!this.#throttlesEveryDomain) {
392
+ throw new Error(`Cannot honour a crawl-delay floor covering every domain: this manager only paces the domains ` +
393
+ `it was given (${Array.from(this.#listedDomains).join(', ')}), so everything else would run ` +
394
+ `unpaced. Set \`domains: 'all'\` to pace whatever the crawl encounters, or declare the floor ` +
395
+ `on this manager yourself via \`minCrawlDelaySecs\`.`);
396
+ }
397
+ this.#minCrawlDelayMs = Math.max(this.#minCrawlDelayMs, intervalMs);
398
+ this.log.debug(`Crawl-delay floor for every domain set to ${(this.#minCrawlDelayMs / 1000).toFixed(1)}s`);
399
+ return true;
400
+ }
401
+ /**
402
+ * Throws unless a signal scoped to `scope` can be honoured as declared or wider.
403
+ *
404
+ * Holding a domain back means holding its queue back, so
405
+ * {@link ThrottlingRequestManagerOptions.throttleBy|`throttleBy`} is the finest granularity this manager
406
+ * can express: a narrower scope is honoured by pacing the whole group, while a wider or unknown one must
407
+ * throw rather than under-apply, because pacing one group would leave the rest of what it covers unpaced.
408
+ */
409
+ #assertScopeHonourable(scope) {
410
+ // No scope means the reporter cannot tell how far the signal reaches, so our own grouping is as good
411
+ // an answer as there is.
412
+ if (scope === undefined || scope === this.#throttleBy) {
413
+ return;
414
+ }
415
+ if (scope === 'hostname' && this.#throttleBy === 'registrableDomain') {
416
+ // Only debug: grouping by registrable domain deliberately paces whole sites, subdomains included.
417
+ this.log.debug(`Applying a pacing signal scoped to "hostname" across the whole registrable domain, because that ` +
418
+ `is how this manager groups requests (\`throttleBy\`). Sibling subdomains are paced with it.`);
419
+ return;
420
+ }
421
+ throw new Error(`Cannot honour a pacing signal scoped to "${scope}": this manager groups requests by ` +
422
+ `"${this.#throttleBy}" and holds one request queue per group, so that is the widest scope it can ` +
423
+ `hold back at once. Applying the signal anyway would leave part of what it covers unpaced. ` +
424
+ (scope === 'registrableDomain'
425
+ ? 'Set `throttleBy: "registrableDomain"` to group requests that way.'
426
+ : 'This manager only understands the scopes "hostname" and "registrableDomain"; pace by ' +
427
+ `"${scope}" with a request manager that groups requests by it.`));
428
+ }
429
+ /** Puts the URL's domain into backoff after a refusal, doubling from `baseDelaySecs` if it keeps refusing. */
430
+ #recordRateLimit(url, waitMs) {
327
431
  const state = this.#getDomainState(url);
328
432
  if (!state) {
329
433
  return false;
@@ -348,10 +452,10 @@ export class ThrottlingRequestManager {
348
452
  state.consecutive429Count = 0;
349
453
  }
350
454
  state.consecutive429Count += 1;
351
- const retryAfterGiven = retryAfterMs !== undefined && retryAfterMs !== null;
352
- let delayMs = retryAfterGiven ? retryAfterMs : this.#baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
455
+ const waitGiven = waitMs !== undefined;
456
+ let delayMs = waitGiven ? waitMs : this.#baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
353
457
  if (delayMs > this.#maxDelayMs) {
354
- const source = retryAfterGiven ? 'Retry-After header' : 'exponential backoff';
458
+ const source = waitGiven ? 'requested wait' : 'exponential backoff';
355
459
  this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
356
460
  `to maxDelaySecs (${(this.#maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
357
461
  `Consider increasing maxDelaySecs if this recurs.`);
@@ -364,14 +468,14 @@ export class ThrottlingRequestManager {
364
468
  return true;
365
469
  }
366
470
  /**
367
- * Records the `Crawl-delay` a domain's robots.txt asked for, which becomes its crawl delay unless
471
+ * Records a declared minimum interval for a domain, which becomes its crawl delay unless
368
472
  * {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`} asks for longer.
369
473
  *
370
474
  * The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
371
475
  *
372
476
  * @returns `false` if the domain is not throttled, in which case this is a no-op.
373
477
  */
374
- setCrawlDelay(url, delaySeconds) {
478
+ #recordDeclaredInterval(url, intervalMs) {
375
479
  const domain = this.#extractDomain(url);
376
480
  if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
377
481
  return false;
@@ -380,45 +484,11 @@ export class ThrottlingRequestManager {
380
484
  // the sub-queue it will end up pacing.
381
485
  const state = this.#ensureDomainState(domain);
382
486
  if (state.declaredCrawlDelayMs === null) {
383
- state.declaredCrawlDelayMs = delaySeconds * 1000;
384
- this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${delaySeconds}s`);
487
+ state.declaredCrawlDelayMs = intervalMs;
488
+ this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${(intervalMs / 1000).toFixed(1)}s`);
385
489
  }
386
490
  return true;
387
491
  }
388
- /**
389
- * Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
390
- * {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
391
- * request through.
392
- *
393
- * A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
394
- * has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
395
- * `Crawl-delay` is being obeyed, not stonewalled.
396
- */
397
- async assertNoStalledDomains() {
398
- await this.#ensureSubManagers();
399
- const now = Date.now();
400
- const candidates = Array.from(this.domainStates.values()).filter(
401
- // Together: it is still turning us away, and has been doing so without a break for longer than the
402
- // window. A domain that has simply been idle starts this clock at its first 429 rather than
403
- // arriving with the idle time already on it.
404
- (state) => state.rateLimitedSince !== 0 &&
405
- now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
406
- now - state.rateLimitedSince > this.#maxDomainStallMs);
407
- const stalled = (await Promise.all(candidates.map(async (state) => {
408
- const subManager = await this.#subManagers.get(state.domain);
409
- return subManager && !(await subManager.isEmpty()) ? state : null;
410
- }))).filter((state) => state !== null);
411
- if (stalled.length === 0) {
412
- return;
413
- }
414
- const summary = stalled
415
- .map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
416
- .join(', ');
417
- throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
418
- `(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
419
- `crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
420
- `with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`);
421
- }
422
492
  /** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
423
493
  #recordProgress(url) {
424
494
  const state = this.#getDomainState(url);
@@ -511,8 +581,9 @@ export class ThrottlingRequestManager {
511
581
  */
512
582
  async #managerHolding(request) {
513
583
  const key = request.id ?? request.uniqueKey;
584
+ // Only a fetch from the wrapped manager puts a key here, so it is necessarily resolved already.
514
585
  if (this.#inFlightFromInner.delete(key)) {
515
- return this.#inner;
586
+ return this.#resolvedInner;
516
587
  }
517
588
  return this.#selectManagerOrThrow(request.url);
518
589
  }
@@ -526,39 +597,94 @@ export class ThrottlingRequestManager {
526
597
  return this.#sumOverManagers((manager) => manager.getHandledCount());
527
598
  }
528
599
  /**
529
- * Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
600
+ * Reports whether anything can be dispatched right now, and if not, when — or why never.
530
601
  *
531
- * Requests waiting on a throttled domain count as unavailable, so a crawler whose task loop is gated on
532
- * this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
602
+ * One traversal of the domain clocks answers all of it: only domains whose delays have run out are probed,
603
+ * the rest merely contribute the moment they come due. Throttled requests count as outstanding work, so a
604
+ * crawler gated on this idles for the backoff instead of concluding it is done.
605
+ *
606
+ * `ready` from anywhere else outranks a stalling domain and is returned without looking at the stall clocks,
607
+ * so one hopeless domain never ends a crawl making progress elsewhere. It cannot outrank itself, though -
608
+ * see the traversal.
533
609
  */
534
- async isEmpty() {
610
+ async checkReadiness() {
535
611
  await this.#ensureSubManagers();
536
- const fetchable = await Promise.all(this.#fetchableDomains().map(async (domain) => this.#subManagers.get(domain)));
537
- const results = await Promise.all([this.#inner, ...fetchable].map(async (manager) => manager.isEmpty()));
538
- return results.every(Boolean);
539
- }
540
- /** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
541
- async isFinished() {
542
- if (this.#inProgressBatchCount > 0) {
543
- return false;
612
+ const now = Date.now();
613
+ const dispatchable = [];
614
+ let readyAt;
615
+ let stallCandidates;
616
+ for (const state of this.domainStates.values()) {
617
+ // A `Crawl-delay` can give a domain a clock before its first request gives it a queue - nothing
618
+ // to fetch from and nothing to wait for until then.
619
+ if (!this.#subManagers.has(state.domain)) {
620
+ continue;
621
+ }
622
+ if (
623
+ // Together: still turning us away, and has been without a break for longer than the window - a
624
+ // domain that has merely been idle starts this clock at its first 429, not with idle time on it.
625
+ state.rateLimitedSince !== 0 &&
626
+ now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
627
+ now - state.rateLimitedSince > this.#maxDomainStallMs) {
628
+ // Deliberately not dispatchable: a domain that has refused every request for the whole window
629
+ // is not progress just because its backoff momentarily lapsed - that is what it does between 429s.
630
+ (stallCandidates ??= []).push(state);
631
+ continue;
632
+ }
633
+ const until = throttledUntil(state);
634
+ if (now >= until) {
635
+ dispatchable.push(this.#subManagers.get(state.domain));
636
+ }
637
+ else if (readyAt === undefined || until < readyAt) {
638
+ readyAt = until;
639
+ }
640
+ }
641
+ const probed = (await Promise.all([
642
+ (await this.#getInner()).checkReadiness(),
643
+ ...dispatchable.map(async (subManager) => (await subManager).checkReadiness()),
644
+ ])).reduce(joinRequestSourceStatuses);
645
+ // Anything dispatchable outranks a stalled or throttled domain, so we stop here without touching them.
646
+ if (probed.status === 'ready') {
647
+ return probed;
648
+ }
649
+ if (stallCandidates !== undefined) {
650
+ const stalled = (await Promise.all(stallCandidates.map(async (state) => (await (await this.#subManagers.get(state.domain)).checkReadiness()).status === 'ready'
651
+ ? state
652
+ : null))).filter((state) => state !== null);
653
+ if (stalled.length > 0) {
654
+ const summary = stalled
655
+ .map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
656
+ .join(', ');
657
+ return {
658
+ status: 'stalled',
659
+ reason: `${summary} rate-limited every request for longer than maxDomainStallSecs ` +
660
+ `(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
661
+ `crawler's concurrency, or drop these domains. Their requests are still queued, so ` +
662
+ `re-running with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`,
663
+ };
664
+ }
544
665
  }
545
- return this.#everyManager((manager) => manager.isFinished());
666
+ if (readyAt !== undefined) {
667
+ return joinRequestSourceStatuses(probed, { status: 'waiting', readyAt });
668
+ }
669
+ // Batches still landing in the background are work nobody can see in a queue yet.
670
+ if (this.#inProgressBatchCount > 0 && probed.status === 'finished') {
671
+ return { status: 'waiting' };
672
+ }
673
+ return probed;
546
674
  }
547
675
  /**
548
676
  * Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
549
677
  * site rather than of the run, so it survives.
550
678
  */
551
679
  async purge() {
552
- await this.#inner.purge?.();
553
- await this.purgeDomainQueues();
680
+ await this.#purgeDomainQueues();
681
+ await this.#resolvedInner?.purge?.();
554
682
  }
555
683
  /**
556
- * Empties the per-domain queues, leaving the wrapped manager alone.
557
- *
558
- * Those queues are this manager's own no matter who owns the one it wraps, which is what makes this safe to
559
- * call where a full {@link ThrottlingRequestManager.purge|`purge()`} would not be.
684
+ * Empties the per-domain queues, leaving the wrapped manager alone - those queues are this manager's own no
685
+ * matter who owns the one it wraps.
560
686
  */
561
- async purgeDomainQueues() {
687
+ async #purgeDomainQueues() {
562
688
  const subManagers = await this.#getSubManagers();
563
689
  await Promise.all(subManagers.map(async (manager) => manager.purge?.()));
564
690
  for (const state of this.domainStates.values()) {
@@ -571,27 +697,35 @@ export class ThrottlingRequestManager {
571
697
  }
572
698
  }
573
699
  async setExpectedRequestProcessingTimeSecs(secs) {
700
+ // Remembered so a manager opened later still gets the hint, without opening one now just to pass it along.
701
+ this.#expectedRequestProcessingSecs = secs;
574
702
  await this.#forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
575
703
  }
704
+ /**
705
+ * Runs `fn` over the sub-queues and, if it has been resolved, the wrapped manager - bookkeeping never forces
706
+ * a lazily-opened `inner`, since there is no point opening a queue purely to tell it something.
707
+ */
576
708
  async #forEachManager(fn) {
709
+ const managers = await this.#getSubManagers();
710
+ if (this.#resolvedInner !== undefined) {
711
+ managers.push(this.#resolvedInner);
712
+ }
577
713
  // `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
578
- await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(async (manager) => fn(manager)));
714
+ await Promise.all(managers.map(async (manager) => fn(manager)));
579
715
  }
580
716
  async #sumOverManagers(fn) {
581
- const counts = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
717
+ // Counts have to include the wrapped manager, so this one does resolve it.
718
+ const counts = await Promise.all([await this.#getInner(), ...(await this.#getSubManagers())].map(fn));
582
719
  return counts.reduce((a, b) => a + b, 0);
583
720
  }
584
- async #everyManager(fn) {
585
- const results = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
586
- return results.every(Boolean);
587
- }
588
721
  /**
589
722
  * Returns the next request from a domain that is not backing off, or from the inner manager.
590
723
  *
591
724
  * Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
592
725
  * out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
593
- * capacity and answers by scaling up. Callers poll instead, and {@link ThrottlingRequestManager.isEmpty}
594
- * reports `true` meanwhile so the crawler's task loop idles rather than spins.
726
+ * capacity and answers by scaling up. Callers poll instead, and
727
+ * {@link ThrottlingRequestManager.checkReadiness|`checkReadiness()`} reports `waiting` meanwhile so the
728
+ * crawler's task loop idles rather than spins.
595
729
  */
596
730
  async fetchNextRequest() {
597
731
  await this.#ensureSubManagers();
@@ -612,7 +746,7 @@ export class ThrottlingRequestManager {
612
746
  // No dispatch to pace, so the domain keeps its slot.
613
747
  state.crawlDelayUntil = crawlDelayUntilBefore;
614
748
  }
615
- const request = await this.#inner.fetchNextRequest();
749
+ const request = await (await this.#getInner()).fetchNextRequest();
616
750
  if (request !== null) {
617
751
  this.#inFlightFromInner.add(request.id ?? request.uniqueKey);
618
752
  }