Skip to content

Commit c395b87

Browse files
committed
fix(crawler): bound the Playwright calls that carry no protocol timeout
`page.content()` and `page.evaluate()` are sent to the driver with no `timeout` field. The Python client only injects one when a timeout calculator is passed (_connection.py `_augment_params`), and the driver arms a timer only for a truthy timeout (`progress.js`: `const deadline = timeout ? ... : 0`). So for these two, no timer exists at all — they can end only when they get a reply or the target closes. What they wait on is the frame's execution-context promise, and `frames.js _setContext(world, null)` discards the resolved promise and installs a fresh unresolved `ManualPromise` on every `_contextDestroyed`. A page that keeps committing navigations therefore wedges them forever. Two consequences, both observed in production on a WordPress + Cloudflare Turnstile site: * `page_timeout` does not help. It reaches `page.goto` and the `wait_*` family only. Lowering it from 80s to 30s changed nothing. * Nothing is logged. Both call sites sit inside swallow-all `try/except`, so the request simply stopped for 172 seconds until an external deadline fired. Same race, two outcomes from the same line: if the context dies *during* the call Playwright raises "Unable to retrieve content because the page is navigating and changing the content"; if it is already gone *at* the call, it stalls instead. `page.close()` is unbounded for the same reason, which matters because an outer deadline cancels into the cleanup path. Fix, three bounds, outermost last: * `browser_adapter.bounded_evaluate()` + a `timeout` kwarg on all three adapters — 30s default, tighter (10s) for the optional cosmetic DOM steps (image dimensions, consent/overlay removal) that already degrade gracefully, generous (300s) for the virtual-scroll loop that is legitimately slow. It raises Playwright's own `TimeoutError`, so existing `except Error` / `except Exception` handlers keep working unchanged. * `AsyncPlaywrightCrawlerStrategy._capture_html()` — bounded `page.content()` with settle-and-retry: on the navigation error, wait for the new document to reach domcontentloaded and capture again, which is the documented remedy and far cheaper than re-running the crawl. Retries share a group budget (25s) so a recoverable race stays nearly free while a wedged page pays once, and it bails out early if the page cannot even reach domcontentloaded. * `CrawlerRunConfig.total_timeout` (ms, default None) — one budget shared by every attempt and proxy in `arun()`'s fetch loop. Complements #1923, which adds a dispatcher-level bound: that covers `arun_many` only, so a direct `arun()` call (e.g. the Docker /crawl path) gets no protection from it. Measured against a local fixture origin that reproduces the race (`max_retries: 1`, `delay_before_return_html: 2.0`, `page_timeout: 80s`): a request that previously hung until an external 180s deadline now returns 200 with full content in 5.0s, and a permanently wedged page fails in bounded time with the exact reason in the log instead of consuming the whole budget silently. Tests: tests/async/test_render_call_bounds.py — 14 tests, no browser, no network.
1 parent 2d8f673 commit c395b87

7 files changed

Lines changed: 524 additions & 29 deletions

File tree

crawl4ai/async_configs.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,6 +1436,14 @@ class CrawlerRunConfig():
14361436
Default: "domcontentloaded".
14371437
page_timeout (int): Timeout in ms for page operations like navigation.
14381438
Default: 60000 (60 seconds).
1439+
total_timeout (int or None): Hard ceiling in ms for the whole fetch phase of a
1440+
single arun() — every anti-bot attempt and proxy retry
1441+
share this one budget. page_timeout only bounds
1442+
navigation and the wait_* family; a page that keeps
1443+
renavigating can wedge an untimed protocol call and
1444+
burn an unbounded amount of wall clock. Set this when
1445+
the caller has a real deadline. None disables it.
1446+
Default: None.
14391447
wait_for (str or None): A CSS selector or JS condition to wait for before extracting content.
14401448
Default: None.
14411449
wait_for_timeout (int or None): Specific timeout in ms for the wait_for condition.
@@ -1627,6 +1635,7 @@ def __init__(
16271635
# Page Navigation and Timing Parameters
16281636
wait_until: str = "domcontentloaded",
16291637
page_timeout: int = PAGE_TIMEOUT,
1638+
total_timeout: Optional[int] = None,
16301639
wait_for: str = None,
16311640
wait_for_timeout: int = None,
16321641
wait_for_images: bool = False,
@@ -1756,6 +1765,7 @@ def __init__(
17561765
# Page Navigation and Timing Parameters
17571766
self.wait_until = wait_until
17581767
self.page_timeout = page_timeout
1768+
self.total_timeout = total_timeout
17591769
self.wait_for = wait_for
17601770
self.wait_for_timeout = wait_for_timeout
17611771
self.wait_for_images = wait_for_images
@@ -2126,6 +2136,7 @@ def to_dict(self):
21262136
"shared_data": self.shared_data,
21272137
"wait_until": self.wait_until,
21282138
"page_timeout": self.page_timeout,
2139+
"total_timeout": self.total_timeout,
21292140
"wait_for": self.wait_for,
21302141
"wait_for_timeout": self.wait_for_timeout,
21312142
"wait_for_images": self.wait_for_images,

crawl4ai/async_crawler_strategy.py

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from .ssl_certificate import SSLCertificate
2323
from .user_agent_generator import ValidUAGenerator, UAGen
2424
from .browser_manager import BrowserManager
25-
from .browser_adapter import BrowserAdapter, PlaywrightAdapter, UndetectedAdapter
25+
from .browser_adapter import (
26+
EVALUATE_TIMEOUT_S,
27+
BrowserAdapter,
28+
PlaywrightAdapter,
29+
UndetectedAdapter,
30+
)
2631

2732
import aiofiles
2833
import aiohttp
@@ -33,6 +38,22 @@
3338
import contextlib
3439
from functools import partial
3540

41+
# --- Bounds for Playwright calls that carry no protocol timeout ------------
42+
# page.content() and page.evaluate() are sent without a `timeout` field, so the
43+
# driver arms no timer and they can only end when the target replies or closes.
44+
# See AsyncPlaywrightCrawlerStrategy._capture_html and browser_adapter.
45+
HTML_CAPTURE_TIMEOUT_S: Final[float] = 15.0 # per page.content() attempt
46+
HTML_CAPTURE_TOTAL_TIMEOUT_S: Final[float] = 25.0 # across all attempts
47+
HTML_CAPTURE_SETTLE_TIMEOUT_S: Final[float] = 5.0 # wait for the next document
48+
HTML_CAPTURE_ATTEMPTS: Final[int] = 3
49+
PAGE_CLOSE_TIMEOUT_S: Final[float] = 10.0 # page.close() is unbounded too
50+
VIRTUAL_SCROLL_TIMEOUT_S: Final[float] = 300.0 # in-page scroll loop, legitimately slow
51+
# Cosmetic DOM steps that already degrade gracefully (image dimensions, consent
52+
# and overlay removal). They are worth a few seconds, never worth a minute, so
53+
# they get a tighter ceiling than the adapter default.
54+
OPTIONAL_DOM_STEP_TIMEOUT_S: Final[float] = 10.0
55+
56+
3657
class AsyncCrawlerStrategy(ABC):
3758
"""
3859
Abstract base class for crawler strategies.
@@ -333,7 +354,12 @@ async def csp_compliant_wait(
333354
"""
334355

335356
try:
336-
result = await self.adapter.evaluate(page, wrapper_js)
357+
# The polling loop above enforces `timeout` itself; the adapter
358+
# bound only has to catch a page whose execution context never
359+
# settles, so give it headroom rather than racing the JS.
360+
result = await self.adapter.evaluate(
361+
page, wrapper_js, timeout=timeout / 1000.0 + EVALUATE_TIMEOUT_S
362+
)
337363
return result
338364
except Exception as e:
339365
if "Error evaluating condition" in str(e):
@@ -511,6 +537,66 @@ async def crawl(
511537
"URL must start with 'http://', 'https://', 'file://', or 'raw:'"
512538
)
513539

540+
async def _capture_html(self, page: Page, attempts: int = None) -> str:
541+
"""Capture the page HTML, tolerating a page that is still navigating.
542+
543+
`page.content()` carries no protocol timeout — the Python client sends
544+
no timeout field, so the driver arms no timer and the call waits on the
545+
frame's execution-context promise. On a page that keeps committing
546+
navigations that promise is repeatedly replaced, giving two failure
547+
modes for the same race:
548+
549+
* the context dies *during* the call -> Playwright raises
550+
"Unable to retrieve content because the page is navigating and
551+
changing the content";
552+
* the context is already gone *at* the call -> it blocks forever
553+
(`page_timeout` does not cover this; only an external deadline does).
554+
555+
Both are transient. Wait for the new document to reach
556+
domcontentloaded and capture again, which is the documented remedy and
557+
is far cheaper than re-running the whole crawl.
558+
"""
559+
attempts = attempts or HTML_CAPTURE_ATTEMPTS
560+
# A recoverable race raises immediately, so retries are nearly free; a
561+
# wedged page burns a full timeout each time. Bound the retries as a
562+
# group so only the wedged case pays, and it pays once.
563+
deadline = time.perf_counter() + HTML_CAPTURE_TOTAL_TIMEOUT_S
564+
last_err: Optional[BaseException] = None
565+
for _i in range(attempts):
566+
budget = min(HTML_CAPTURE_TIMEOUT_S, deadline - time.perf_counter())
567+
if budget <= 0:
568+
break
569+
try:
570+
return await asyncio.wait_for(page.content(), budget)
571+
except asyncio.TimeoutError:
572+
last_err = PlaywrightTimeoutError(
573+
f"page.content() did not return within {budget:.0f}s "
574+
f"— the page never stopped navigating"
575+
)
576+
except Error as e:
577+
last_err = e
578+
if _i >= attempts - 1:
579+
break
580+
self.logger.debug(
581+
message="HTML capture attempt {n} failed ({err}) — letting the page settle",
582+
tag="SCRAPE",
583+
params={"n": _i + 1, "err": str(last_err)[:120]},
584+
)
585+
try:
586+
await page.wait_for_load_state(
587+
"domcontentloaded",
588+
timeout=HTML_CAPTURE_SETTLE_TIMEOUT_S * 1000,
589+
)
590+
except Exception:
591+
# The page cannot even reach domcontentloaded, so it is not
592+
# between documents — it is stuck. Another capture attempt
593+
# would only buy another full timeout. Give up now.
594+
break
595+
raise last_err or PlaywrightTimeoutError(
596+
f"page.content() could not be captured within "
597+
f"{HTML_CAPTURE_TOTAL_TIMEOUT_S:g}s"
598+
)
599+
514600
async def _crawl_web(
515601
self, url: str, config: CrawlerRunConfig
516602
) -> AsyncCrawlResponse:
@@ -1029,7 +1115,10 @@ async def handle_request_failed_capture(request):
10291115
await page.wait_for_load_state("domcontentloaded", timeout=5)
10301116
except PlaywrightTimeoutError:
10311117
pass
1032-
await self.adapter.evaluate(page, update_image_dimensions_js)
1118+
await self.adapter.evaluate(
1119+
page, update_image_dimensions_js,
1120+
timeout=OPTIONAL_DOM_STEP_TIMEOUT_S,
1121+
)
10331122
except Exception as e:
10341123
self.logger.error(
10351124
message="Error updating image dimensions: {error}",
@@ -1061,7 +1150,7 @@ async def handle_request_failed_capture(request):
10611150
message="Shadow DOM flattening returned no content, falling back to page.content()",
10621151
tag="SCRAPE",
10631152
)
1064-
html = await page.content()
1153+
html = await self._capture_html(page)
10651154
elif config.css_selector:
10661155
try:
10671156
selectors = [s.strip() for s in config.css_selector.split(',')]
@@ -1082,7 +1171,7 @@ async def handle_request_failed_capture(request):
10821171
except Error as e:
10831172
raise RuntimeError(f"Failed to extract HTML content: {str(e)}")
10841173
else:
1085-
html = await page.content()
1174+
html = await self._capture_html(page)
10861175

10871176
await self.execute_hook(
10881177
"before_return_html", page=page, html=html, context=context, config=config
@@ -1126,7 +1215,7 @@ async def get_delayed_content(delay: float = 5.0) -> str:
11261215
params={"delay": delay, "url": url},
11271216
)
11281217
await asyncio.sleep(delay)
1129-
return await page.content()
1218+
return await self._capture_html(page)
11301219

11311220
# For undetected browsers, retrieve console messages before returning
11321221
if config.capture_console_messages and hasattr(self.adapter, 'retrieve_console_messages'):
@@ -1196,7 +1285,11 @@ async def get_delayed_content(delay: float = 5.0) -> str:
11961285
all_contexts = page.context.browser.contexts
11971286
total_pages = sum(len(context.pages) for context in all_contexts)
11981287
if not (total_pages <= 1 and (self.browser_config.use_managed_browser or self.browser_config.headless)):
1199-
await page.close()
1288+
# page.close() is also sent without a timeout and waits
1289+
# on the target's closed-promise, so a wedged renderer
1290+
# can block cleanup indefinitely — including while this
1291+
# coroutine is being cancelled by an outer deadline.
1292+
await asyncio.wait_for(page.close(), PAGE_CLOSE_TIMEOUT_S)
12001293
except Exception:
12011294
pass
12021295

@@ -1428,8 +1521,13 @@ async def _handle_virtual_scroll(self, page: Page, config: "VirtualScrollConfig"
14281521
}
14291522
"""
14301523

1431-
# Execute virtual scroll capture
1432-
result = await self.adapter.evaluate(page, virtual_scroll_js, config.to_dict())
1524+
# Execute virtual scroll capture. Unlike the other evaluates this
1525+
# one legitimately runs a long scroll loop inside the page, so it
1526+
# gets its own generous ceiling rather than the adapter default.
1527+
result = await self.adapter.evaluate(
1528+
page, virtual_scroll_js, config.to_dict(),
1529+
timeout=VIRTUAL_SCROLL_TIMEOUT_S,
1530+
)
14331531

14341532
if result.get("replaced", False):
14351533
self.logger.success(
@@ -1532,7 +1630,8 @@ async def remove_overlay_elements(self, page: Page) -> None:
15321630
}};
15331631
}}
15341632
}})()
1535-
"""
1633+
""",
1634+
timeout=OPTIONAL_DOM_STEP_TIMEOUT_S,
15361635
)
15371636
await page.wait_for_timeout(500) # Wait for any animations to complete
15381637
except Exception as e:
@@ -1576,7 +1675,8 @@ async def remove_consent_popups(self, page: Page) -> None:
15761675
}};
15771676
}}
15781677
}})()
1579-
"""
1678+
""",
1679+
timeout=OPTIONAL_DOM_STEP_TIMEOUT_S,
15801680
)
15811681
await page.wait_for_timeout(500) # Wait for any animations to complete
15821682
except Exception as e:

crawl4ai/async_webcrawler.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,18 @@ async def arun(
403403
_is_raw_url = url.startswith("raw:") or url.startswith("raw://")
404404

405405
_max_attempts = 1 + getattr(config, "max_retries", 0)
406+
# One shared budget for the whole fetch phase (every attempt
407+
# and every proxy). page_timeout only bounds navigation and
408+
# the wait_* family; Playwright calls sent without a timeout
409+
# (page.content, page.evaluate, page.close) are not covered by
410+
# anything, so without this a single wedged page can consume
411+
# the caller's entire deadline in silence.
412+
_total_timeout = getattr(config, "total_timeout", None)
413+
_fetch_deadline = (
414+
time.perf_counter() + _total_timeout / 1000.0
415+
if _total_timeout
416+
else None
417+
)
406418
_proxy_list = config._get_proxy_list()
407419
_original_proxy_config = config.proxy_config
408420
_block_reason = ""
@@ -456,8 +468,25 @@ async def arun(
456468
self.crawler_strategy.update_user_agent(
457469
config.user_agent)
458470

459-
async_response = await self.crawler_strategy.crawl(
460-
url, config=config)
471+
_remaining = None
472+
if _fetch_deadline is not None:
473+
_remaining = _fetch_deadline - time.perf_counter()
474+
if _remaining <= 0:
475+
raise TimeoutError(
476+
f"Fetch budget of {_total_timeout} ms exhausted "
477+
f"before attempt {_attempt + 1}/{_max_attempts}")
478+
try:
479+
async_response = await asyncio.wait_for(
480+
self.crawler_strategy.crawl(url, config=config),
481+
timeout=_remaining,
482+
)
483+
except asyncio.TimeoutError:
484+
# asyncio.TimeoutError carries no message —
485+
# name the budget so the failure is attributable.
486+
raise TimeoutError(
487+
f"Crawl attempt exceeded the {_total_timeout} ms "
488+
f"fetch budget ({_remaining:.1f}s remained for "
489+
f"attempt {_attempt + 1}/{_max_attempts})") from None
461490

462491
html = sanitize_input_encode(async_response.html)
463492
screenshot_data = async_response.screenshot

0 commit comments

Comments
 (0)