@crawlee/http 4.0.0-beta.96 → 4.0.0-beta.97

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.
@@ -21,7 +21,11 @@ JSONData extends JsonValue = any, // with default to Dictionary we cant use a ty
21
21
  ContextExtension = Dictionary<never>> = ErrorHandler<CrawlingContext, HttpCrawlingContext<UserData, JSONData> & ContextExtension>;
22
22
  export interface HttpCrawlerOptions<Context extends InternalHttpCrawlingContext = InternalHttpCrawlingContext, ContextExtension = Dictionary<never>, ExtendedContext extends Context = Context & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> extends BasicCrawlerOptions<Context, ContextExtension, ExtendedContext, Routes> {
23
23
  /**
24
- * Timeout in which the HTTP request to the resource needs to finish, given in seconds.
24
+ * Timeout for the whole navigation phase, given in seconds. A single window shared by the
25
+ * `preNavigationHooks`, the navigation (the HTTP request to the resource), and the `postNavigationHooks` -
26
+ * so a slow hook eats into the same budget the navigation uses. Separate from the
27
+ * {@link BasicCrawlerOptions.requestHandlerTimeoutSecs|`requestHandlerTimeoutSecs`}, which times only the
28
+ * request handler.
25
29
  */
26
30
  navigationTimeoutSecs?: number;
27
31
  /**
@@ -344,6 +348,7 @@ export declare class HttpCrawler<Context extends InternalHttpCrawlingContext<any
344
348
  * All `HttpCrawlerOptions` parameters are passed via an options object.
345
349
  */
346
350
  constructor(options?: HttpCrawlerOptions<Context, ContextExtension, ExtendedContext> & RequireContextPipeline<InternalHttpCrawlingContext, Context>);
351
+ protected getNavigationTimeoutMillis(): number;
347
352
  /**
348
353
  * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
349
354
  * concurrency shortcuts on top. Not called for a supplied
@@ -1,13 +1,13 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import util from 'node:util';
3
- import { BasicCrawler, ContextPipeline, NavigationSkippedError, RequestState, Router, SessionError, } from '@crawlee/basic';
3
+ import { BasicCrawler, ContextPipeline, NavigationSkippedError, remainingNavigationWindowMillis, RequestState, Router, SessionError, } from '@crawlee/basic';
4
4
  import { getCookiesFromResponse } from '@crawlee/core';
5
5
  import { ResponseWithUrl } from '@crawlee/http-client';
6
6
  import { RETRY_CSS_SELECTORS } from '@crawlee/utils';
7
7
  import contentTypeParser from 'content-type';
8
8
  import iconv from 'iconv-lite';
9
9
  import ow from 'ow';
10
- import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
10
+ import { addTimeoutToPromise, storage, TimeoutError, tryCancel } from '@apify/timeout';
11
11
  import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
12
12
  /**
13
13
  * Default mime types, which HttpScraper supports.
@@ -161,6 +161,9 @@ export class HttpCrawler extends BasicCrawler {
161
161
  ];
162
162
  this.saveResponseCookies = saveResponseCookies;
163
163
  }
164
+ getNavigationTimeoutMillis() {
165
+ return this.navigationTimeoutMillis;
166
+ }
164
167
  /**
165
168
  * Folds {@link HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS} into the default system, keeping the user's
166
169
  * concurrency shortcuts on top. Not called for a supplied
@@ -179,15 +182,26 @@ export class HttpCrawler extends BasicCrawler {
179
182
  const skipGuard = (action) => ({
180
183
  action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
181
184
  });
185
+ // A single navigation window covers the pre-navigation hooks, the navigation, and the post-navigation
186
+ // hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow hook eats into the same
187
+ // window the navigation uses instead of each step being timed on its own.
188
+ const navigationTimedOut = `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`;
189
+ const windowGuard = (step) => skipGuard(async (ctx) => {
190
+ const remaining = remainingNavigationWindowMillis(ctx, this.navigationTimeoutMillis);
191
+ if (remaining <= 0) {
192
+ throw new TimeoutError(navigationTimedOut);
193
+ }
194
+ return addTimeoutToPromise(async () => step(ctx), remaining, navigationTimedOut);
195
+ });
182
196
  let pipeline = ContextPipeline.create().compose({
183
197
  action: this.prepareHttpRequest.bind(this),
184
198
  });
185
199
  for (const hook of this.preNavigationHooks) {
186
- pipeline = pipeline.compose(skipGuard(hook));
200
+ pipeline = pipeline.compose(windowGuard(hook));
187
201
  }
188
202
  let pipelineWithNavigation = pipeline.compose(skipGuard(this.makeHttpRequest.bind(this)));
189
203
  for (const hook of this.postNavigationHooks) {
190
- pipelineWithNavigation = pipelineWithNavigation.compose(skipGuard(hook));
204
+ pipelineWithNavigation = pipelineWithNavigation.compose(windowGuard(hook));
191
205
  }
192
206
  return pipelineWithNavigation
193
207
  .compose({ action: this.processHttpResponse.bind(this) })
@@ -217,7 +231,10 @@ export class HttpCrawler extends BasicCrawler {
217
231
  tryCancel();
218
232
  const { request, session } = crawlingContext;
219
233
  const proxyUrl = crawlingContext.proxyInfo?.url;
220
- const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), this.navigationTimeoutMillis, `request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
234
+ // Bound the request by whatever is left of the shared navigation window (the pre-navigation hooks may
235
+ // have already spent part of it), so it produces a clean navigation-timeout error rather than the raw
236
+ // client abort.
237
+ const httpResponse = await addTimeoutToPromise(async () => this.requestFunction({ request, session, proxyUrl }), Math.max(1, remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis)), `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
221
238
  tryCancel();
222
239
  request.loadedUrl = httpResponse?.url;
223
240
  request.state = RequestState.AFTER_NAV;
@@ -244,7 +261,14 @@ export class HttpCrawler extends BasicCrawler {
244
261
  };
245
262
  }
246
263
  tryCancel();
247
- const parsed = await this.parseResponse(crawlingContext.request, crawlingContext.response);
264
+ // Reading the body is still part of the navigation, so it draws from the same shared window: on a server
265
+ // that streams the body slowly the request completes (headers arrive) but the body read would otherwise
266
+ // run unbounded. `extendTimeout` from a post-navigation hook has already pushed this deadline out if asked.
267
+ const remaining = remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis);
268
+ if (remaining <= 0) {
269
+ throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
270
+ }
271
+ const parsed = await addTimeoutToPromise(async () => this.parseResponse(crawlingContext.request, crawlingContext.response), remaining, `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
248
272
  tryCancel();
249
273
  const response = parsed.response;
250
274
  const contentType = parsed.contentType;
@@ -474,7 +498,7 @@ export class HttpCrawler extends BasicCrawler {
474
498
  */
475
499
  handleRequestTimeout(session) {
476
500
  session.markBad();
477
- throw new Error(`request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
501
+ throw new Error(`Request timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
478
502
  }
479
503
  _abortDownloadOfBody(request, response) {
480
504
  const { status } = response;
@@ -498,6 +522,12 @@ export class HttpCrawler extends BasicCrawler {
498
522
  // session jar. Reads still go through the session (so session.setCookie() in pre-nav
499
523
  // hooks keeps working) but a per-request clone is passed in so writes are discarded.
500
524
  const cookieJar = this.saveResponseCookies ? session.cookieJar : await session.cookieJar.clone();
525
+ // Bind the request to the shared navigation window instead of a fixed per-request timeout, so
526
+ // `extendTimeout()` can push the deadline and a fixed `AbortSignal.timeout` won't fire on its own and
527
+ // kill a lazily-read body mid-extension. This aborts the socket only during the header phase; the body
528
+ // read is bounded separately at the promise level (see `processHttpResponse`), so a slow-streaming body
529
+ // still fails cleanly with a navigation timeout, though the socket is left to close on its own.
530
+ const cancelSignal = storage.getStore()?.cancelTask.signal;
501
531
  const response = await this.httpClient.sendRequest(new Request(opts.url, {
502
532
  body: opts.body ? Readable.toWeb(opts.body) : undefined,
503
533
  headers: new Headers(opts.headers),
@@ -507,7 +537,8 @@ export class HttpCrawler extends BasicCrawler {
507
537
  }), {
508
538
  session,
509
539
  cookieJar,
510
- timeoutMillis: opts.timeout,
540
+ signal: cancelSignal,
541
+ timeoutMillis: cancelSignal ? undefined : opts.timeout,
511
542
  });
512
543
  return response;
513
544
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/http",
3
- "version": "4.0.0-beta.96",
3
+ "version": "4.0.0-beta.97",
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"
@@ -47,13 +47,13 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@apify/timeout": "^0.3.2",
50
+ "@apify/timeout": "^0.4.4",
51
51
  "@apify/utilities": "^2.15.5",
52
- "@crawlee/basic": "4.0.0-beta.96",
53
- "@crawlee/core": "4.0.0-beta.96",
54
- "@crawlee/http-client": "4.0.0-beta.96",
55
- "@crawlee/types": "4.0.0-beta.96",
56
- "@crawlee/utils": "4.0.0-beta.96",
52
+ "@crawlee/basic": "4.0.0-beta.97",
53
+ "@crawlee/core": "4.0.0-beta.97",
54
+ "@crawlee/http-client": "4.0.0-beta.97",
55
+ "@crawlee/types": "4.0.0-beta.97",
56
+ "@crawlee/utils": "4.0.0-beta.97",
57
57
  "@types/content-type": "^1.1.8",
58
58
  "cheerio": "^1.0.0",
59
59
  "content-type": "^1.0.5",
@@ -70,5 +70,5 @@
70
70
  }
71
71
  }
72
72
  },
73
- "gitHead": "b5c20fe6cecad57a6e3dd26b4137776d4b62588a"
73
+ "gitHead": "43008dd43f4832d083353ba1b731c0b4607c9cfa"
74
74
  }