fix: catch invisible_playwright's own error classes

invisible_playwright drives Firefox through a vendored copy of Playwright
(invisible_playwright._pw), and its Error/TimeoutError are not subclasses
of playwright.async_api's classes or of the builtin TimeoutError. Every
except and suppress in Byparr named the playwright.async_api classes, so
in production none of them matched:

- a Turnstile widget read that outlasted its 1s timeout escaped the
  suppress() in the solve loop and turned the whole request into a 500
  (the Locator.bounding_box traceback in #419), even with budget left
- an unreachable host was a 500 instead of a 502
- a goto/domcontentloaded timeout was a 500 instead of a 408
- a page that never reaches networkidle was a 500 instead of returning
  the loaded page, and /load returned empty content for it

Nothing in Byparr runs the real playwright package at runtime any more;
it is only used for type hints. So the handlers now import the classes
invisible_playwright re-exports from its public async_api, and nothing
else. That module exports them since 0.15.0, hence the raised floor.

The unit-test fakes raised the playwright.async_api classes too, which
is why the suite stayed green. They now raise the vendored classes, and
a new test covers a widget read timing out mid-solve.

Refs #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ThePhaselessandClaude Opus 5.5 committed 2026-10-07 23:51:21 +02:00
1 parent 838704ea23
commit 39bdeedecd
7 files changed
+33 -11

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ description = "API for getting cookies for Cloudflare challenges"
readme = "README.md"
dependencies = [
"fastapi[standard]==0.142.*",
"invisible-playwright>=0.6.1",
"invisible-playwright>=0.15.0",
"playwright==1.63.*",
"playwright-captcha==0.1.*",
"pydantic==2.*",
+2 -2
View File
@@ -2,9 +2,9 @@ import time
from asyncio import sleep
from contextlib import suppress
from playwright.async_api import Error as PlaywrightError
from invisible_playwright.async_api import Error as PlaywrightError
from invisible_playwright.async_api import TimeoutError as PlaywrightTimeoutError
from playwright.async_api import FloatRect, Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from playwright_captcha.solvers.click.cloudflare.utils.detection import (
CF_INTERSTITIAL_INDICATORS_SELECTORS,
detect_cloudflare_challenge,
+2 -2
View File
@@ -5,8 +5,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from playwright.async_api import Error as PlaywrightError
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from invisible_playwright.async_api import Error as PlaywrightError
from invisible_playwright.async_api import TimeoutError as PlaywrightTimeoutError
from src.challenge import challenge_present, solve_challenge
from src.content import build_response_content
+1 -1
View File
@@ -7,8 +7,8 @@ from typing import Annotated
import trafilatura
from fastapi import APIRouter, Depends, Header, HTTPException
from invisible_playwright.async_api import TimeoutError as PlaywrightTimeoutError
from playwright.async_api import Page
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from pydantic import BaseModel
from src.consts import OWUI_API_KEY
+25 -3
View File
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, MagicMock
import httpx2
import pytest
from fastapi import HTTPException
from playwright.async_api import Error as PlaywrightError
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from invisible_playwright.async_api import Error as PlaywrightError
from invisible_playwright.async_api import TimeoutError as PlaywrightTimeoutError
from starlette.testclient import TestClient
from main import app
@@ -148,6 +148,7 @@ def fake_dep(
challenged: bool = False,
marker_counts: list[int] | None = None,
widget_box: dict[str, float] | None = None,
widget_error: Exception | None = None,
user_agent: str | None = "UnitTestBrowser/1.0",
) -> BrowserDepClass:
"""Build a browser dependency pair backed by mocks."""
@@ -171,7 +172,9 @@ def fake_dep(
def locator(selector: str) -> MagicMock:
handle = MagicMock()
handle.count = AsyncMock(side_effect=lambda: count_for(selector))
handle.first.bounding_box = AsyncMock(return_value=widget_box)
handle.first.bounding_box = AsyncMock(
return_value=widget_box, side_effect=widget_error
)
handle.first.input_value = AsyncMock(return_value="")
return handle
@@ -298,6 +301,25 @@ async def test_challenge_that_clears_on_its_own_is_never_clicked():
dep.page.mouse.down.assert_not_awaited()
@pytest.mark.asyncio
async def test_slow_widget_read_does_not_abort_the_solve():
"""One widget read outlasting its short timeout is retried, never a 500 (#419)."""
dep = fake_dep(
challenged=True,
marker_counts=[1, 1, 1, 0],
widget_error=PlaywrightTimeoutError(
"Locator.bounding_box: Timeout 1000ms exceeded."
),
)
response = await read_item(
LinkRequest(url="https://example.test/login", max_timeout=5), dep
)
assert response.status == "ok"
dep.page.mouse.down.assert_not_awaited()
@pytest.mark.asyncio
async def test_challenge_that_never_clears_returns_408():
"""A challenge still up when the budget runs out is a timeout, not a 500."""
+1 -1
View File
@@ -2,7 +2,7 @@ from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from invisible_playwright.async_api import TimeoutError as PlaywrightTimeoutError
from starlette.testclient import TestClient
from main import app
Generated
+1 -1
View File
@@ -105,7 +105,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = "==0.142.*" },
{ name = "invisible-playwright", specifier = ">=0.6.1" },
{ name = "invisible-playwright", specifier = ">=0.15.0" },
{ name = "playwright", specifier = "==1.63.*" },
{ name = "playwright-captcha", specifier = "==0.1.*" },
{ name = "pydantic", specifier = "==2.*" },