Skip to content

fix(playwright): poll for late-rendered Cloudflare challenge markup - #4031

Open
B4nan wants to merge 2 commits into
v4from
fix/cloudflare-challenge-hook-v4
Open

fix(playwright): poll for late-rendered Cloudflare challenge markup#4031
B4nan wants to merge 2 commits into
v4from
fix/cloudflare-challenge-hook-v4

Conversation

@B4nan

@B4nan B4nan commented Aug 13, 2026

Copy link
Copy Markdown
Member

Fixes two ways handleCloudflareChallengeHook() lost track of challenges after the first attempt, found while stabilizing the camoufox-cloudflare E2E fixture.

The first is a stale page pinned in reused options. handleCloudflareChallenge() memoized its default isChallengeCallback/isBlockedCallback onto the caller's options object, with the current page captured in the closure. The pre-wrapped hook passes one options object to every call, so after the first attempt the detection kept querying the first (by then closed) page: the evaluate rejected, the catch turned that into "no challenge", and retries silently ran the request handler on challenge pages. Unit tests never see this because they construct fresh options per call; it took platform runs with debug instrumentation to find (the retry pages had the full challenge markup, yet detection saw nothing). The defaults are now kept local and bound to the page argument. Verified on the platform (run m6QgmOqsSQ5wBvb1p): every retry detects the challenge again and the solver runs each time.

The second is single-shot detection. The current Cloudflare template can finish rendering the challenge markup after the load event. When the navigation response signals a likely challenge (a 403 status, or Cloudflare's documented cf-mitigated: challenge header), the helper now polls for the markup for up to 5 seconds instead of checking once, and bails out early when the page is a hard block. The crawling-context util passes the response through to make that possible. Pages with other status codes keep the single immediate check and pay no extra cost.

This also answers the follow-up question from 99d465d: the hook should not remove 403 from blockedStatusCodes automatically the way v3 did. v3 spliced 403 out of the session pool because it never reloaded the page, so even a solved challenge kept the original 403 response. v4 reloads and propagates the fresh response, so a solved challenge passes the blocked-status check on its own. Keeping 403 blocked is a safety net: an unsolved or undetected challenge gets retried with a fresh session instead of reaching the request handler. The E2E fixture drops its blockedStatusCodes workaround, and the hook docs now spell out the 403 interplay.

The fixture still does not pass, because the click-solver currently cannot clear the challenge at all. Master's nightly fails the same fixture the same way (run from 2026-08-13 06:14 UTC), so that part is environmental and independent of this change.

The default challenge detection in handleCloudflareChallenge() ran a single
querySelector right after navigation settled. The current Cloudflare template
can finish rendering the challenge markup (or arrive via an intermediate
redirect hop) only after the load event, so on retries the hook regularly
concluded 'no challenge' while the request handler later landed on a fully
rendered challenge page (observed in platform run rnKxeF0phWeTnlzdx).

When the navigation response signals a likely challenge - a 403 status or
Cloudflare's documented cf-mitigated: challenge header - the helper now polls
for the markup for up to 5 seconds instead of trusting a single immediate
check, failing fast when the page is a hard block. The crawling-context util
passes the navigation response through to enable this.

With detection reliable, v4 needs no automatic 403 removal from
blockedStatusCodes (v3 spliced it out of the session pool because it never
reloaded the page, so even a solved challenge kept the original 403 response;
v4 reloads and propagates the fresh response instead). Keeping 403 blocked
acts as a safety net: an unsolved or undetected challenge is retried with a
fresh session rather than feeding the challenge page to the request handler.
The camoufox-cloudflare fixture drops its blockedStatusCodes workaround
accordingly, and the hook docs now spell out the 403 interplay.
@B4nan B4nan added t-tooling Issues with this label are in the ownership of the tooling team. adhoc Ad-hoc unplanned task added during the sprint. labels Aug 13, 2026
…llenge options

handleCloudflareChallenge() memoized its default isChallengeCallback and
isBlockedCallback onto the caller's options object, with the current page
captured in the closure. The pre-wrapped handleCloudflareChallengeHook()
passes one options object to every call, so after the first attempt the
detection kept querying that first (by then closed) page - the evaluate
rejected, the catch turned it into 'no challenge', and retries silently
ran the request handler on challenge pages. Verified on the Apify platform
(run m6QgmOqsSQ5wBvb1p): with the defaults kept local and bound to the
page argument, every retry now detects the challenge again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants