inchang-ing 9731bcf28a
fix(browserless): construct the fetch and screenshot clients off the event loop (#7913)
* fix(browserless): construct the fetch and screenshot clients off the event loop

* fix(browserless): close the client a cancelled construction still builds

Keeping the constructor off the loop is right, but awaiting
`asyncio.to_thread(httpx.AsyncClient, ...)` directly is not cancellation-safe:
the await can be cancelled while the worker thread is still inside the
constructor. The await is abandoned, the thread runs on and hands back a live
client, and execution never enters the `async with` that would have closed it,
so `is_closed` stays False and the connection pool leaks on that cancellation.

Own the construction as a task instead. When the caller is cancelled, wait for
the task with the shield loop this package already uses for cleanup, close
whatever client it produced, and only then let the cancellation propagate. A
failure while closing is logged rather than raised, so it cannot mask the
cancellation the caller asked for.

The new regression test gates the constructor on a thread event, cancels the
operation while it is parked there, and releases the worker afterwards. Against
the previous commit it fails for both fetch_html_with_status and
capture_screenshot with "the client built after the cancellation was leaked".

---------

Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
2026-11-05 16:04:45 +08:00

322 lines
13 KiB
Python

import asyncio
import logging
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class BrowserlessFetchResult:
html: str
target_status_code: str
target_status: str
@dataclass(frozen=True)
class BrowserlessScreenshotResult:
content: bytes
content_type: str
target_status_code: str
target_status: str
final_url: str
def _get_header(headers: Any, name: str) -> str:
value = headers.get(name)
if value:
return str(value)
return str(headers.get(name.lower(), ""))
async def _settle(future: "asyncio.Future[Any]") -> Any:
"""Await a future that owns a resource, even if this task is cancelled again.
Cleanup must not be the thing that gets abandoned: the caller remembers its
own cancellation and re-raises it once the resource is released.
"""
while True:
try:
return await asyncio.shield(future)
except asyncio.CancelledError:
if future.cancelled():
raise
async def _open_client(**kwargs: Any) -> httpx.AsyncClient:
"""Build an ``httpx.AsyncClient`` without loading TLS trust on the event loop.
The constructor resolves the system trust store synchronously, so it has to
run in a thread. That alone is not enough: a cancellation can arrive while
the worker is still inside the constructor, and then the await is abandoned
while the thread goes on to hand back a live client that nobody closes. So
own the construction as a task and, once cancelled, wait for it and close
whatever it produced before the cancellation propagates.
"""
construction: asyncio.Task[httpx.AsyncClient] = asyncio.create_task(asyncio.to_thread(httpx.AsyncClient, **kwargs))
try:
return await asyncio.shield(construction)
except asyncio.CancelledError:
try:
late_client = await _settle(construction)
except Exception as error:
logger.error(f"Browserless HTTPX client construction failed after cancellation: {error!s}")
else:
try:
await _settle(asyncio.create_task(late_client.aclose()))
except Exception as error:
logger.error(f"Closing a Browserless HTTPX client built after cancellation failed: {error!s}")
raise
class BrowserlessClient:
"""Client for Browserless headless Chrome API."""
def __init__(self, base_url: str, token: str = "", timeout_s: float = 30) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_s = timeout_s
def _auth_params(self) -> dict[str, str] | None:
"""Query parameters that authenticate a request to Browserless.
Browserless reads the token from the ``token`` query parameter or the
``Authorization`` header only, and its request-body schemas reject
unknown keys, so the token must never be sent in the JSON body.
"""
return {"token": self.token} if self.token else None
async def fetch_html(
self,
url: str,
wait_for_event: str = "",
wait_for_timeout_ms: int = 0,
wait_for_selector: str = "",
wait_for_selector_timeout_ms: int = 5000,
reject_resource_types: list[str] | None = None,
reject_request_pattern: list[str] | None = None,
) -> str:
"""Fetch the rendered HTML of a page using Browserless.
Public string contract: this always returns either the rendered HTML or
an "Error: ..." string, never a richer result object. Callers that also
need the target page's real status (Browserless returns HTTP 200 for the
render request itself even when the target page responded with a 4xx/5xx
or an anti-bot block page) should use fetch_html_with_status() instead.
Args:
url: The URL to fetch.
wait_for_event: Wait for a page event (e.g. "networkidle", "load").
wait_for_timeout_ms: Extra wait after page load.
wait_for_selector: CSS selector to wait for.
wait_for_selector_timeout_ms: Timeout for selector wait.
reject_resource_types: Resource types to block (e.g. ["image"]).
reject_request_pattern: URL patterns to block.
Returns:
Rendered HTML content, or an "Error: ..." string on failure.
"""
result = await self.fetch_html_with_status(
url=url,
wait_for_event=wait_for_event,
wait_for_timeout_ms=wait_for_timeout_ms,
wait_for_selector=wait_for_selector,
wait_for_selector_timeout_ms=wait_for_selector_timeout_ms,
reject_resource_types=reject_resource_types,
reject_request_pattern=reject_request_pattern,
)
return result.html if isinstance(result, BrowserlessFetchResult) else result
async def fetch_html_with_status(
self,
url: str,
wait_for_event: str = "",
wait_for_timeout_ms: int = 0,
wait_for_selector: str = "",
wait_for_selector_timeout_ms: int = 5000,
reject_resource_types: list[str] | None = None,
reject_request_pattern: list[str] | None = None,
) -> BrowserlessFetchResult | str:
"""Fetch the rendered HTML of a page using Browserless, with target status.
Same request/response handling as fetch_html(), except a successful fetch
returns a BrowserlessFetchResult carrying the target page's real status
headers instead of a bare string, so a caller can tell a genuine 200 apart
from a render-succeeded-but-target-errored (or anti-bot blocked) response.
Use fetch_html() instead when only the HTML/error string is needed.
Only sends accepted parameters for the current Browserless API version.
Sets a default navigation timeout (30s) via query param.
Args:
url: The URL to fetch.
wait_for_event: Wait for a page event (e.g. "networkidle", "load").
wait_for_timeout_ms: Extra wait after page load.
wait_for_selector: CSS selector to wait for.
wait_for_selector_timeout_ms: Timeout for selector wait.
reject_resource_types: Resource types to block (e.g. ["image"]).
reject_request_pattern: URL patterns to block.
Returns:
Fetch result with the rendered HTML and target-page status headers,
or an "Error: ..." string on failure.
"""
payload: dict[str, Any] = {
"url": url,
}
if wait_for_event:
payload["waitForEvent"] = wait_for_event
if wait_for_timeout_ms > 0:
payload["waitForTimeout"] = wait_for_timeout_ms
if wait_for_selector:
payload["waitForSelector"] = {
"selector": wait_for_selector,
"timeout": wait_for_selector_timeout_ms,
}
if reject_resource_types:
payload["rejectResourceTypes"] = reject_resource_types
if reject_request_pattern:
payload["rejectRequestPattern"] = reject_request_pattern
logger.debug(f"Fetching URL via Browserless: {url}")
try:
# Construction loads TLS trust synchronously, off the loop, and owns the
# client even when this coroutine is cancelled mid-construction.
async with await _open_client(timeout=self.timeout_s) as client:
resp = await client.post(
f"{self.base_url}/content",
json=payload,
params=self._auth_params(),
headers={
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
)
code = resp.status_code
target_code = resp.headers.get("X-Response-Code", "")
target_status = resp.headers.get("X-Response-Status", "")
logger.debug(f"Browserless response: code={code}, target_code={target_code}, target_status={target_status}")
if code != 200:
return f"Error: Browserless HTTP {code}: {resp.text[:200]}"
html = resp.text
if not html or not html.strip():
return "Error: Browserless returned empty response"
return BrowserlessFetchResult(
html=html,
target_status_code=_get_header(resp.headers, "X-Response-Code"),
target_status=_get_header(resp.headers, "X-Response-Status"),
)
except httpx.TimeoutException:
return f"Error: Browserless request timed out after {self.timeout_s}s"
except httpx.RequestError as e:
logger.error(f"Browserless request failed: {e}")
return f"Error: Browserless request failed: {e!s}"
except Exception as e:
logger.error(f"Browserless fetch failed: {e}")
return f"Error: Browserless fetch failed: {e!s}"
async def capture_screenshot(
self,
url: str,
full_page: bool = True,
output_format: str = "png",
quality: int | None = None,
viewport: dict[str, int] | None = None,
wait_for_selector: str = "",
wait_for_selector_timeout_ms: int = 5000,
wait_for_timeout_ms: int = 0,
best_attempt: bool = False,
) -> BrowserlessScreenshotResult | str:
"""Capture a rendered screenshot of a URL using Browserless.
Args:
url: URL to render.
full_page: Capture the full page instead of just the viewport.
output_format: Image format: png, jpeg, or webp.
quality: Optional quality for jpeg/webp outputs.
viewport: Optional browser viewport dictionary.
wait_for_selector: CSS selector to wait for before capture.
wait_for_selector_timeout_ms: Timeout for selector wait.
wait_for_timeout_ms: Extra wait after navigation.
best_attempt: Continue when waits time out.
Returns:
Screenshot result with binary content, or an error string.
"""
payload: dict[str, Any] = {
"url": url,
"options": {
"fullPage": full_page,
"type": output_format,
},
}
if quality is not None:
payload["options"]["quality"] = quality
if viewport:
payload["viewport"] = viewport
if wait_for_selector:
payload["waitForSelector"] = {
"selector": wait_for_selector,
"timeout": wait_for_selector_timeout_ms,
}
if wait_for_timeout_ms > 0:
payload["waitForTimeout"] = wait_for_timeout_ms
if best_attempt:
payload["bestAttempt"] = True
logger.debug(f"Capturing URL screenshot via Browserless: {url}")
try:
# Construction loads TLS trust synchronously, off the loop, and owns the
# client even when this coroutine is cancelled mid-construction.
async with await _open_client(timeout=self.timeout_s) as client:
resp = await client.post(
f"{self.base_url}/screenshot",
json=payload,
params=self._auth_params(),
headers={
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
)
code = resp.status_code
logger.debug(
"Browserless screenshot response: code=%s, target_code=%s, target_status=%s",
code,
resp.headers.get("X-Response-Code", ""),
resp.headers.get("X-Response-Status", ""),
)
if code != 200:
return f"Error: Browserless HTTP {code}: {resp.text[:200]}"
content = resp.content
if not content:
return "Error: Browserless returned empty screenshot response"
return BrowserlessScreenshotResult(
content=content,
content_type=_get_header(resp.headers, "Content-Type"),
target_status_code=_get_header(resp.headers, "X-Response-Code"),
target_status=_get_header(resp.headers, "X-Response-Status"),
final_url=_get_header(resp.headers, "X-Response-URL"),
)
except httpx.TimeoutException:
return f"Error: Browserless screenshot request timed out after {self.timeout_s}s"
except httpx.RequestError as e:
logger.error(f"Browserless screenshot request failed: {e}")
return f"Error: Browserless screenshot request failed: {e!s}"
except Exception as e:
logger.error(f"Browserless screenshot failed: {e}")
return f"Error: Browserless screenshot failed: {e!s}"