@crawlee/basic 4.0.0-beta.207 → 4.0.0-beta.209

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.
@@ -684,8 +684,8 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
684
684
  protected runRequestHandler(crawlingContext: ExtendedContext): Promise<void>;
685
685
  /**
686
686
  * Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
687
- * Deliberately does **not** commit on return - `handleRequest` swallows request handler failures, so
688
- * a normal return says nothing about success. `handleRequest` owns the outcome.
687
+ * Deliberately does **not** commit on return - the pipeline hands failures to `handleRequestFailure`, so a
688
+ * normal return says nothing about success. `handleRequest` commits on success.
689
689
  */
690
690
  private runInStorageTransaction;
691
691
  /**
@@ -710,7 +710,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
710
710
  * Fetches the next request to process from the underlying request provider.
711
711
  */
712
712
  private fetchNextRequest;
713
- /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
713
+ /** Runs the request handler and marks the request as handled. Failures are left to `handleRequestFailure`. */
714
714
  private handleRequest;
715
715
  /**
716
716
  * Generator function that yields requests injected with the given crawl depth.
@@ -729,7 +729,7 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
729
729
  private isTaskReadyFunction;
730
730
  /**
731
731
  * Unwraps errors thrown by the context pipeline to get the actual user error.
732
- * RequestHandlerError and ContextPipelineCleanupError wrap the actual error.
732
+ * RequestHandlerError, ContextPipelineInitializationError and ContextPipelineCleanupError wrap the actual error.
733
733
  */
734
734
  private unwrapError;
735
735
  /**
@@ -13,12 +13,12 @@ import { ConcurrencySystem } from './autoscaling/concurrency_system.js';
13
13
  import { mergeCookies } from './cookie_utils.js';
14
14
  import { ContextPipeline } from './crawlers/context_pipeline.js';
15
15
  import { CrawlingRequest, RequestState } from './crawling_request.js';
16
- import { ContextPipelineCleanupError, ContextPipelineInterruptedError, MissingSessionError, NavigationSkippedError, PersistentRateLimitError, RequestHandlerError, RequestThrottledError, RetryRequestError, } from './errors.js';
16
+ import { ContextPipelineCleanupError, ContextPipelineInterruptedError, MissingSessionError, NavigationSkippedError, PersistentRateLimitError, ContextPipelineInitializationError, RequestHandlerError, RequestThrottledError, RetryRequestError, } from './errors.js';
17
17
  import { Statistics } from './crawlers/statistics.js';
18
18
  import { applyRequestTransform, buildEnqueueStrategyPatterns, constructUrlPatternObjects, createRequestOptions, createSkippedRequestArgs, filterRequestOptionsByPatterns, } from './enqueue_links/index.js';
19
19
  import { parseRetryAfterHeader } from './http.js';
20
20
  import { CrawlerRun } from './crawler-run.js';
21
- import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, timeoutExpiredKey, } from './request-timeout.js';
21
+ import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, suspendTimeoutKey, timeoutExpiredKey, } from './request-timeout.js';
22
22
  import { createSendRequest } from './send-request.js';
23
23
  import { Router, validateUserData } from './router.js';
24
24
  import { BLOCKED_STATUS_CODES } from './session_pool/consts.js';
@@ -462,44 +462,19 @@ export class BasicCrawler {
462
462
  // matching `recordRequestStart`.
463
463
  this.statistics.recordRequestStart(request.id || request.uniqueKey);
464
464
  const crawlingContext = { request };
465
- // Runs inside the pipeline call, so the deferred cleanups registered by earlier middlewares
466
- // (e.g. closing the page) only fire after the error handler has seen the partial context.
467
- const handleInitializationError = async (rawError) => {
468
- // Roll back *before* the error handler runs, for the same reason `handleRequest` does.
469
- currentStorageTransaction()?.rollback();
470
- // ContextPipelineInterruptedError means the request was intentionally skipped
471
- // (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
472
- if (rawError instanceof ContextPipelineInterruptedError) {
473
- this.statistics.discardRequestRecord(request.id || request.uniqueKey);
474
- await this.timeoutAndRetry(async () => this.requestManager?.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${crawlingContext.request.url} (${crawlingContext.request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
475
- return;
476
- }
477
- // An error during pipeline initialization (e.g., navigation timeout, session/proxy error,
478
- // i.e. not in user's requestHandler) goes through the normal error flow.
479
- const error = this.unwrapError(rawError);
480
- await this.requestFunctionErrorHandler(error, crawlingContext, request, this.requestManager);
481
- // SessionError already retired the session in `requestFunctionErrorHandler`;
482
- // skip `markBad` to avoid double-counting usage/error score.
483
- if (!this.errorAbsolvesSession(error)) {
484
- crawlingContext.session?.markBad();
485
- }
486
- };
487
465
  try {
488
466
  // The transaction spans the whole pipeline call, covering the navigation hooks
489
- // and `extendContext` too; `handleRequest` drives its outcome explicitly.
467
+ // and `extendContext` too; `handleRequest` commits it and `handleRequestFailure` rolls it back.
490
468
  await this.runInStorageTransaction(async () =>
491
469
  // Navigation, the navigation hooks and the request handler are timed individually, but the
492
470
  // phases between them are not, so a request could still get stuck indefinitely. This is the
493
471
  // catch-all for that - see `raceWithTimeout` for why it is a bare timer, not a timeout frame.
494
- await this.withRequestTimeout(crawlingContext, this.#basicPipeline
495
- .chain(this.contextPipeline)
496
- .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request), handleInitializationError)));
472
+ await this.withRequestTimeout(crawlingContext, this.#basicPipeline.chain(this.contextPipeline).call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request), (error) => this.#handleRequestFailure(error, crawlingContext, source, request))));
497
473
  }
498
474
  catch (error) {
499
- // A bare `TimeoutError` is the internal timeout above firing; a `SessionError` can only be a
500
- // secondary error from `handleRequest` - both take the normal error flow. Anything else thrown
501
- // inside the pipeline arrives wrapped.
502
- if (error instanceof SessionError || error instanceof TimeoutError) {
475
+ // The internal timeout above fired, or `handleRequestFailure` threw a `SessionError` - both
476
+ // take the normal error flow. Anything else is fatal.
477
+ if (error instanceof SessionError || crawlingContext[timeoutExpiredKey]?.()) {
503
478
  const unwrappedError = this.unwrapError(error);
504
479
  await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
505
480
  if (!this.errorAbsolvesSession(unwrappedError)) {
@@ -1373,8 +1348,8 @@ export class BasicCrawler {
1373
1348
  }
1374
1349
  /**
1375
1350
  * Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
1376
- * Deliberately does **not** commit on return - `handleRequest` swallows request handler failures, so
1377
- * a normal return says nothing about success. `handleRequest` owns the outcome.
1351
+ * Deliberately does **not** commit on return - the pipeline hands failures to `handleRequestFailure`, so a
1352
+ * normal return says nothing about success. `handleRequest` commits on success.
1378
1353
  */
1379
1354
  async runInStorageTransaction(callback) {
1380
1355
  if (!this.#transactionalStorageEnabled) {
@@ -1392,7 +1367,7 @@ export class BasicCrawler {
1392
1367
  }
1393
1368
  finally {
1394
1369
  if (transaction.state === 'open') {
1395
- // `handleRequest` commits or rolls back on every normal path, so an open transaction on
1370
+ // `handleRequest` commits or `handleRequestFailure` rolls back on every normal path, so an open transaction on
1396
1371
  // a normal return is a wiring bug; on a propagating throw (a pipeline-level failure) it is
1397
1372
  // expected. Either way, discard the unvalidated writes; only the former is worth flagging.
1398
1373
  if (!threw) {
@@ -1515,7 +1490,7 @@ export class BasicCrawler {
1515
1490
  }
1516
1491
  return this.requestManager.fetchNextRequest();
1517
1492
  }
1518
- /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
1493
+ /** Runs the request handler and marks the request as handled. Failures are left to `handleRequestFailure`. */
1519
1494
  // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
1520
1495
  async handleRequest(crawlingContext, requestSource, request) {
1521
1496
  // An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
@@ -1524,68 +1499,84 @@ export class BasicCrawler {
1524
1499
  if (crawlingContext[timeoutExpiredKey]?.()) {
1525
1500
  return;
1526
1501
  }
1527
- const statisticsId = request.id || request.uniqueKey;
1502
+ request.state = RequestState.REQUEST_HANDLER;
1503
+ await this.runRequestHandler(crawlingContext);
1504
+ // Commit *before* marking the request as handled, so a commit failure fails the request and
1505
+ // it is retried. This also closes the transaction, so everything below passes through.
1528
1506
  // Opened by `runInStorageTransaction`; absent when disabled or when the subclass opens its own.
1529
- const transaction = currentStorageTransaction();
1530
- let isRequestLocked = true;
1507
+ await currentStorageTransaction()?.commit();
1508
+ await this.timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1509
+ this.statistics.recordRequestSuccess(request.id || request.uniqueKey, request.retryCount);
1510
+ // reclaim session if request finishes successfully
1511
+ request.state = RequestState.DONE;
1512
+ crawlingContext.session.markGood();
1513
+ }
1514
+ /**
1515
+ * Handles a request that failed anywhere in the context pipeline - a middleware (e.g. navigation or its hooks)
1516
+ * or `handleRequest`. Runs inside the pipeline call, so the cleanups (e.g. closing the page) only fire after the
1517
+ * error handler has seen the context.
1518
+ */
1519
+ async #handleRequestFailure(rawError, crawlingContext, requestSource, request) {
1520
+ // Roll back *before* any error handler runs - error handlers write to real storage precisely
1521
+ // because the transaction is already closed. A no-op when the commit in `handleRequest` succeeded.
1522
+ currentStorageTransaction()?.rollback();
1523
+ const skipped = rawError instanceof ContextPipelineInitializationError &&
1524
+ rawError.cause instanceof ContextPipelineInterruptedError;
1525
+ const err = this.unwrapError(rawError);
1526
+ const context = crawlingContext;
1527
+ const retryCountBefore = request.retryCount;
1528
+ const resumeRequestTimeout = crawlingContext[suspendTimeoutKey]?.();
1531
1529
  try {
1532
- request.state = RequestState.REQUEST_HANDLER;
1533
- await this.runRequestHandler(crawlingContext);
1534
- // Commit *before* marking the request as handled, so a commit failure fails the request and
1535
- // it is retried. This also closes the transaction, so everything below passes through.
1536
- await transaction?.commit();
1537
- await this.timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1538
- isRequestLocked = false; // markRequestAsHandled succeeded and unlocked the request
1539
- this.statistics.recordRequestSuccess(statisticsId, request.retryCount);
1540
- // reclaim session if request finishes successfully
1530
+ // ContextPipelineInterruptedError means the request was intentionally skipped
1531
+ // (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
1532
+ if (skipped) {
1533
+ this.statistics.discardRequestRecord(request.id || request.uniqueKey);
1534
+ await this.timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1535
+ return;
1536
+ }
1537
+ request.state = RequestState.ERROR_HANDLER;
1538
+ await addTimeoutToPromise(async () => this.requestFunctionErrorHandler(err, context, request, requestSource), this.internalTimeoutMillis, `Handling request failure of ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1541
1539
  request.state = RequestState.DONE;
1542
- crawlingContext.session.markGood();
1543
1540
  }
1544
- catch (rawError) {
1545
- // Roll back *before* any error handler runs - error handlers write to real storage precisely
1546
- // because the transaction is already closed. A no-op when the commit above succeeded.
1547
- transaction?.rollback();
1548
- const err = this.unwrapError(rawError);
1549
- try {
1550
- request.state = RequestState.ERROR_HANDLER;
1551
- await addTimeoutToPromise(async () => this.requestFunctionErrorHandler(err, crawlingContext, request, requestSource), this.internalTimeoutMillis, `Handling request failure of ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1552
- if (!(err instanceof CriticalError)) {
1553
- isRequestLocked = false; // requestFunctionErrorHandler calls either markRequestAsHandled or reclaimRequest
1541
+ catch (secondaryError) {
1542
+ const unwrappedSecondaryError = this.unwrapError(secondaryError);
1543
+ const timedOut = unwrappedSecondaryError instanceof TimeoutError;
1544
+ request.state = RequestState.ERROR;
1545
+ if (timedOut) {
1546
+ // Not worth crashing the crawler over. The request's state is unknown, so it gets retried - and the
1547
+ // retry is counted, so that an error handler that always times out does not retry it forever.
1548
+ this.log.warning(unwrappedSecondaryError.message);
1549
+ if (!skipped && request.retryCount === retryCountBefore) {
1550
+ request.retryCount++;
1554
1551
  }
1555
- request.state = RequestState.DONE;
1556
1552
  }
1557
- catch (secondaryError) {
1558
- const unwrappedSecondaryError = this.unwrapError(secondaryError);
1559
- if (!unwrappedSecondaryError.triggeredFromUserHandler &&
1560
- // avoid reprinting the same critical error multiple times, as it will be printed by Nodejs at the end anyway
1561
- !(unwrappedSecondaryError instanceof CriticalError)) {
1562
- const apifySpecific = process.env.APIFY_IS_AT_HOME
1563
- ? `This may have happened due to an internal error of Apify's API or due to a misconfigured crawler.`
1564
- : '';
1565
- this.log.exception(unwrappedSecondaryError, 'An exception occurred during handling of failed request. ' +
1566
- `This places the crawler and its underlying storages into an unknown state and crawling will be terminated. ${apifySpecific}`);
1567
- }
1568
- request.state = RequestState.ERROR;
1569
- throw unwrappedSecondaryError;
1553
+ else if (!(unwrappedSecondaryError instanceof CriticalError)) {
1554
+ // avoid reprinting the same critical error multiple times, as it will be printed by Nodejs at the end anyway
1555
+ const apifySpecific = process.env.APIFY_IS_AT_HOME
1556
+ ? `This may have happened due to an internal error of Apify's API or due to a misconfigured crawler.`
1557
+ : '';
1558
+ this.log.exception(unwrappedSecondaryError, 'An exception occurred during handling of failed request. ' +
1559
+ `This places the crawler and its underlying storages into an unknown state and crawling will be terminated. ${apifySpecific}`);
1560
+ }
1561
+ // Safety net - the error handler neither marked the request as handled nor reclaimed it (e.g. after a
1562
+ // CriticalError). Reclaiming a request that is no longer in progress is a harmless no-op on the storage backend.
1563
+ try {
1564
+ await addTimeoutToPromise(async () => requestSource.reclaimRequest(request), this.internalTimeoutMillis, `Reclaiming request ${request.url} (${request.id}) timed out.`);
1570
1565
  }
1571
- // decrease the session score if the request fails (but the error handler did not throw);
1572
- // skip when the error is a SessionError, which already retired the session
1573
- if (!this.errorAbsolvesSession(err)) {
1574
- crawlingContext.session.markBad();
1566
+ catch {
1567
+ // The request was never in progress, or could not be reclaimed. Either way it's fine.
1568
+ }
1569
+ if (!timedOut) {
1570
+ throw unwrappedSecondaryError;
1575
1571
  }
1576
1572
  }
1577
1573
  finally {
1578
- // Safety net - return the request to the queue if nobody managed to mark it as handled
1579
- // or reclaim it before (e.g. after a CriticalError). Reclaiming a request that is no longer
1580
- // in progress is a harmless no-op on the storage backend.
1581
- if (isRequestLocked && requestSource instanceof RequestQueue) {
1582
- try {
1583
- await requestSource.reclaimRequest(request);
1584
- }
1585
- catch {
1586
- // The request was never in progress, or could not be reclaimed. Either way it's fine.
1587
- }
1588
- }
1574
+ resumeRequestTimeout?.();
1575
+ }
1576
+ // decrease the session score if the request fails (but the error handler did not throw);
1577
+ // skip when the error is a SessionError, which already retired the session
1578
+ if (!skipped && !this.errorAbsolvesSession(err)) {
1579
+ context.session?.markBad();
1589
1580
  }
1590
1581
  }
1591
1582
  /**
@@ -1641,10 +1632,12 @@ export class BasicCrawler {
1641
1632
  }
1642
1633
  /**
1643
1634
  * Unwraps errors thrown by the context pipeline to get the actual user error.
1644
- * RequestHandlerError and ContextPipelineCleanupError wrap the actual error.
1635
+ * RequestHandlerError, ContextPipelineInitializationError and ContextPipelineCleanupError wrap the actual error.
1645
1636
  */
1646
1637
  unwrapError(error) {
1647
- if (error instanceof RequestHandlerError || error instanceof ContextPipelineCleanupError) {
1638
+ if (error instanceof RequestHandlerError ||
1639
+ error instanceof ContextPipelineInitializationError ||
1640
+ error instanceof ContextPipelineCleanupError) {
1648
1641
  return this.unwrapError(error.cause);
1649
1642
  }
1650
1643
  return error;
@@ -1,7 +1,10 @@
1
1
  import type { Awaitable } from '@crawlee/types';
2
2
  /**
3
- * Registers a callback that runs after the final context consumer finishes. `error` is the consumer's failure, if any;
4
- * failures of downstream middlewares are not reported. Cleanups run in reverse registration order.
3
+ * Registers a callback that runs after the final context consumer finishes. Cleanups run in reverse registration
4
+ * order and receive the same `error` as the `onError` parameter of {@link ContextPipeline.call}: `undefined` on
5
+ * success, otherwise a {@link ContextPipelineInitializationError} (a middleware failed) or a
6
+ * {@link RequestHandlerError} (the consumer failed), with the original in `cause`. A cleanup that throws fails the
7
+ * whole call with a {@link ContextPipelineCleanupError}, which is critical.
5
8
  */
6
9
  export type CleanupRegistrar = (cleanup: (error?: unknown) => Awaitable<void>) => void;
7
10
  /**
@@ -60,12 +63,13 @@ export declare abstract class ContextPipeline<TContextBase, TCrawlingContext ext
60
63
  *
61
64
  * @param crawlingContext - The initial context to process through the pipeline
62
65
  * @param finalContextConsumer - The function that will receive the final enhanced context
63
- * @param onInitializationError - Receives a middleware failure as-is; the consumer is then skipped. Runs before
64
- * the cleanups, so it still sees whatever the failed middleware's predecessors set up.
66
+ * @param onError - Receives a middleware failure wrapped in a {@link ContextPipelineInitializationError}, or a
67
+ * consumer failure wrapped in a {@link RequestHandlerError}, with the original in `cause`. After a middleware
68
+ * failure, the rest of the chain and the consumer are skipped. Runs before the cleanups, so it still sees
69
+ * whatever the middlewares set up.
65
70
  *
66
- * @throws {RequestHandlerError} When the final context consumer throws an exception
67
71
  * @throws {ContextPipelineCleanupError} When cleanup operations fail
68
- * @throws {SessionError} Session errors are re-thrown as-is for special handling
72
+ * @throws Whatever `onError` throws
69
73
  */
70
- abstract call(crawlingContext: TContextBase, finalContextConsumer: (finalContext: TCrawlingContext) => Awaitable<unknown>, onInitializationError: (error: unknown) => Awaitable<void>): Promise<void>;
74
+ abstract call(crawlingContext: TContextBase, finalContextConsumer: (finalContext: TCrawlingContext) => Awaitable<unknown>, onError: (error: unknown) => Awaitable<void>): Promise<void>;
71
75
  }
@@ -1,5 +1,5 @@
1
- import { serviceLocator, SessionError } from '@crawlee/core';
2
- import { ContextPipelineCleanupError, RequestHandlerError } from '../errors.js';
1
+ import { serviceLocator } from '@crawlee/core';
2
+ import { ContextPipelineCleanupError, ContextPipelineInitializationError, RequestHandlerError } from '../errors.js';
3
3
  /**
4
4
  * Encapsulates the logic of gradually enhancing the crawling context with additional information and utilities.
5
5
  *
@@ -57,55 +57,47 @@ class ContextPipelineImpl extends ContextPipeline {
57
57
  /**
58
58
  * @inheritdoc
59
59
  */
60
- async call(crawlingContext, finalContextConsumer, onInitializationError) {
60
+ async call(crawlingContext, finalContextConsumer, onError) {
61
61
  const middlewares = Array.from(this.middlewareChain()).reverse();
62
62
  const cleanupStack = [];
63
63
  const onCleanup = (cleanup) => {
64
64
  cleanupStack.push(cleanup);
65
65
  };
66
- let consumerException;
66
+ let failure;
67
+ let consumerStarted = false;
67
68
  try {
68
69
  for (const middleware of middlewares) {
69
- try {
70
- const contextExtension = await middleware(crawlingContext, onCleanup);
71
- const extensionNames = [
72
- ...Object.getOwnPropertyNames(contextExtension),
73
- ...Object.getOwnPropertySymbols(contextExtension),
74
- ];
75
- for (const key of extensionNames) {
76
- try {
77
- if (Object.getOwnPropertyDescriptor(crawlingContext, key)?.configurable !== false) {
78
- Object.defineProperty(crawlingContext, key, Object.getOwnPropertyDescriptor(contextExtension, key));
79
- }
80
- }
81
- catch (error) {
82
- serviceLocator
83
- .getLogger()
84
- .debug(`Context pipeline failed to define property ${key.toString()}:`, error);
70
+ const contextExtension = await middleware(crawlingContext, onCleanup);
71
+ const extensionNames = [
72
+ ...Object.getOwnPropertyNames(contextExtension),
73
+ ...Object.getOwnPropertySymbols(contextExtension),
74
+ ];
75
+ for (const key of extensionNames) {
76
+ try {
77
+ if (Object.getOwnPropertyDescriptor(crawlingContext, key)?.configurable !== false) {
78
+ Object.defineProperty(crawlingContext, key, Object.getOwnPropertyDescriptor(contextExtension, key));
85
79
  }
86
80
  }
81
+ catch (error) {
82
+ serviceLocator
83
+ .getLogger()
84
+ .debug(`Context pipeline failed to define property ${key.toString()}:`, error);
85
+ }
87
86
  }
88
- catch (exception) {
89
- await onInitializationError(exception);
90
- return;
91
- }
92
- }
93
- try {
94
- await finalContextConsumer(crawlingContext);
95
- }
96
- catch (exception) {
97
- if (exception instanceof SessionError) {
98
- consumerException = exception;
99
- throw exception; // Session errors are re-thrown as-is
100
- }
101
- consumerException = exception;
102
- throw new RequestHandlerError(exception);
103
87
  }
88
+ consumerStarted = true;
89
+ await finalContextConsumer(crawlingContext);
90
+ }
91
+ catch (exception) {
92
+ failure = consumerStarted
93
+ ? new RequestHandlerError(exception)
94
+ : new ContextPipelineInitializationError(exception);
95
+ await onError(failure);
104
96
  }
105
97
  finally {
106
98
  try {
107
99
  for (const cleanup of cleanupStack.reverse()) {
108
- await cleanup(consumerException);
100
+ await cleanup(failure);
109
101
  }
110
102
  }
111
103
  catch (exception) {
@@ -44,6 +44,11 @@ export declare class ContextPipelineInterruptedError extends Error {
44
44
  export declare class ContextPipelineCleanupError extends CriticalError {
45
45
  constructor(error: unknown, options?: ErrorOptions);
46
46
  }
47
+ /** Wraps a failure of a context pipeline middleware, telling it apart from a {@link RequestHandlerError}. */
48
+ export declare class ContextPipelineInitializationError extends Error {
49
+ constructor(error: unknown, options?: ErrorOptions);
50
+ }
51
+ /** Wraps a failure of the context pipeline's final consumer (the request handler), telling it apart from a middleware failure. */
47
52
  export declare class RequestHandlerError extends Error {
48
53
  constructor(error: unknown, options?: ErrorOptions);
49
54
  }
@@ -54,6 +54,13 @@ export class ContextPipelineCleanupError extends CriticalError {
54
54
  super(undefined, { cause: error, ...options });
55
55
  }
56
56
  }
57
+ /** Wraps a failure of a context pipeline middleware, telling it apart from a {@link RequestHandlerError}. */
58
+ export class ContextPipelineInitializationError extends Error {
59
+ constructor(error, options) {
60
+ super(undefined, { cause: error, ...options });
61
+ }
62
+ }
63
+ /** Wraps a failure of the context pipeline's final consumer (the request handler), telling it apart from a middleware failure. */
57
64
  export class RequestHandlerError extends Error {
58
65
  constructor(error, options) {
59
66
  super(undefined, { cause: error, ...options });
@@ -16,10 +16,16 @@ export declare const extendTimeoutKey: unique symbol;
16
16
  * bail, so a request whose timeout already elapsed does not carry on after the crawler moved past it.
17
17
  */
18
18
  export declare const timeoutExpiredKey: unique symbol;
19
+ /**
20
+ * Stops the internal request timeout's clock until the returned callback is called. Error handling has a timeout
21
+ * of its own, and must not be cut short (and handled again) by the request timeout.
22
+ */
23
+ export declare const suspendTimeoutKey: unique symbol;
19
24
  /** The slots the internal request timeout hangs on the in-flight crawling context. */
20
25
  export interface RequestTimeoutContext {
21
26
  [extendTimeoutKey]?: (extraMillis: number) => void;
22
27
  [timeoutExpiredKey]?: () => boolean;
28
+ [suspendTimeoutKey]?: () => () => void;
23
29
  [navigationDeadlineKey]?: number;
24
30
  }
25
31
  /**
@@ -17,6 +17,11 @@ export const extendTimeoutKey = Symbol('extendTimeout');
17
17
  * bail, so a request whose timeout already elapsed does not carry on after the crawler moved past it.
18
18
  */
19
19
  export const timeoutExpiredKey = Symbol('timeoutExpired');
20
+ /**
21
+ * Stops the internal request timeout's clock until the returned callback is called. Error handling has a timeout
22
+ * of its own, and must not be cut short (and handled again) by the request timeout.
23
+ */
24
+ export const suspendTimeoutKey = Symbol('suspendTimeout');
20
25
  /**
21
26
  * Milliseconds left in the shared navigation window for `ctx`, lazily starting the window on first use.
22
27
  * @internal
@@ -46,21 +51,41 @@ export async function raceWithTimeout(context, work, { timeoutMillis, requestId
46
51
  let deadline = Date.now() + timeoutMillis;
47
52
  let settled = false;
48
53
  let firedByTimeout = false;
54
+ let suspendedAt;
49
55
  const timeout = new Promise((_, reject) => {
50
56
  const fire = () => {
51
57
  settled = true;
52
58
  firedByTimeout = true;
53
59
  reject(new TimeoutError(`Request timed out after ${timeoutMillis / 1e3} seconds (${requestId}).`));
54
60
  };
61
+ const schedule = () => {
62
+ clearTimeout(timer);
63
+ timer = setTimeout(fire, Math.max(deadline - Date.now(), 0));
64
+ };
55
65
  timer = setTimeout(fire, timeoutMillis);
56
66
  context[timeoutExpiredKey] = () => firedByTimeout;
57
67
  context[extendTimeoutKey] = (extraMillis) => {
58
68
  if (settled) {
59
69
  return;
60
70
  }
61
- clearTimeout(timer);
62
71
  deadline += extraMillis;
63
- timer = setTimeout(fire, Math.max(deadline - Date.now(), 0));
72
+ if (suspendedAt === undefined) {
73
+ schedule();
74
+ }
75
+ };
76
+ context[suspendTimeoutKey] = () => {
77
+ if (settled || suspendedAt !== undefined) {
78
+ return () => { };
79
+ }
80
+ clearTimeout(timer);
81
+ suspendedAt = Date.now();
82
+ return () => {
83
+ deadline += Date.now() - suspendedAt;
84
+ suspendedAt = undefined;
85
+ if (!settled) {
86
+ schedule();
87
+ }
88
+ };
64
89
  };
65
90
  });
66
91
  try {
@@ -149,7 +149,8 @@ export declare class Session implements ISession {
149
149
  */
150
150
  setCookie(rawCookie: string, url: string): Promise<void>;
151
151
  /**
152
- * Checks if session is not usable. if it is not retires the session.
152
+ * Makes an unusable session's state terminal. Unlike `retire()`, this leaves the error score alone so that
153
+ * `isBlocked()` keeps telling a burned identity from one that merely ran out of uses.
153
154
  */
154
155
  private maybeSelfRetire;
155
156
  }
@@ -140,6 +140,8 @@ export class Session {
140
140
  * It increases `usageCount` and potentially lowers the `errorScore` by the `errorScoreDecrement`.
141
141
  */
142
142
  markGood() {
143
+ if (this.#retired)
144
+ return;
143
145
  this.#usageCount += 1;
144
146
  if (this.#errorScore > 0) {
145
147
  this.#errorScore -= this.#errorScoreDecrement;
@@ -176,7 +178,7 @@ export class Session {
176
178
  * For transient external failures (such as `5XX` responses), use `markBad()` instead.
177
179
  */
178
180
  retire() {
179
- if (this.#retired)
181
+ if (this.#retired && this.isBlocked())
180
182
  return;
181
183
  this.#errorScore += this.#maxErrorScore;
182
184
  this.#usageCount += 1;
@@ -212,11 +214,12 @@ export class Session {
212
214
  }
213
215
  }
214
216
  /**
215
- * Checks if session is not usable. if it is not retires the session.
217
+ * Makes an unusable session's state terminal. Unlike `retire()`, this leaves the error score alone so that
218
+ * `isBlocked()` keeps telling a burned identity from one that merely ran out of uses.
216
219
  */
217
220
  maybeSelfRetire() {
218
221
  if (!this.isUsable()) {
219
- this.retire();
222
+ this.#retired = true;
220
223
  }
221
224
  }
222
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.207",
3
+ "version": "4.0.0-beta.209",
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"
@@ -42,10 +42,10 @@
42
42
  "@apify/datastructures": "^3.0.1",
43
43
  "@apify/timeout": "^1.0.1",
44
44
  "@apify/utilities": "^3.0.1",
45
- "@crawlee/core": "4.0.0-beta.207",
46
- "@crawlee/http-client": "4.0.0-beta.207",
47
- "@crawlee/types": "4.0.0-beta.207",
48
- "@crawlee/utils": "4.0.0-beta.207",
45
+ "@crawlee/core": "4.0.0-beta.209",
46
+ "@crawlee/http-client": "4.0.0-beta.209",
47
+ "@crawlee/types": "4.0.0-beta.209",
48
+ "@crawlee/utils": "4.0.0-beta.209",
49
49
  "@sapphire/async-queue": "^1.5.5",
50
50
  "@standard-schema/spec": "^1.0.0",
51
51
  "csv-stringify": "^6.5.2",
@@ -57,7 +57,7 @@
57
57
  "zod": "^4.5.4"
58
58
  },
59
59
  "optionalDependencies": {
60
- "@crawlee/impit-client": "4.0.0-beta.207"
60
+ "@crawlee/impit-client": "4.0.0-beta.209"
61
61
  },
62
62
  "lerna": {
63
63
  "command": {
@@ -66,5 +66,5 @@
66
66
  }
67
67
  }
68
68
  },
69
- "gitHead": "8d4e54dfe0343e33f40a90d12abfb1840340c520"
69
+ "gitHead": "ccf82c59e230033a34bddcbecb1f4a8e803eaa11"
70
70
  }