Skip to content

Commit 37bce20

Browse files
mxschmittdgozman
andauthored
fix: report route handler errors to the user after they completed (microsoft#2321)
Issue: Exceptions which will get thrown in route handlers after route.{fulfill,continue,abort} got called, won't get surfaced to the user in the sync and async version of Playwright for Python. Scope: Event handlers which use asyncio.create_task - {BrowserContext,Page}.on("route") - {Page}.on({"route", "locatorHandlerTriggered"}) There were multiple issues in the implementation: 1. `playwright/_impl/_helper.py` (sync only): we were only waiting until a handler's {continue,fulfill,abort} got called, and did not wait until the actual callback completed. Fix: Wait until the handler completed. 2. `playwright/_impl/_connection.py:423` (sync only): We call event listeners manually, this means that `pyee`'s `"error"` event is not working there. Fix: attach a done callback manually, like pyee is doing inside their library. 3. `playwright/_impl/_connection.py:56` (async): attach an `"error"` event handler for the async error reporting 4. `playwright/_impl/_connection.py:90` if we report an error to the user, we should cancel the underlying future otherwise a warning gets emitted, that there was a future exception never received. --------- Co-authored-by: Dmitry Gozman <[email protected]>
1 parent a4d5fcc commit 37bce20

6 files changed

+64
-11
lines changed

playwright/_impl/_browser_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def __init__(
123123
)
124124
self._channel.on(
125125
"route",
126-
lambda params: asyncio.create_task(
126+
lambda params: self._loop.create_task(
127127
self._on_route(
128128
from_channel(params.get("route")),
129129
)

playwright/_impl/_connection.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(self, connection: "Connection", object: "ChannelOwner") -> None:
5353
self._connection = connection
5454
self._guid = object._guid
5555
self._object = object
56+
self.on("error", lambda exc: self._connection._on_event_listener_error(exc))
5657

5758
async def send(self, method: str, params: Dict = None) -> Any:
5859
return await self._connection.wrap_api_call(
@@ -77,13 +78,13 @@ async def inner_send(
7778
) -> Any:
7879
if params is None:
7980
params = {}
80-
callback = self._connection._send_message_to_server(
81-
self._object, method, _filter_none(params)
82-
)
8381
if self._connection._error:
8482
error = self._connection._error
8583
self._connection._error = None
8684
raise error
85+
callback = self._connection._send_message_to_server(
86+
self._object, method, _filter_none(params)
87+
)
8788
done, _ = await asyncio.wait(
8889
{
8990
self._connection._transport.on_error_future,
@@ -416,10 +417,22 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
416417
try:
417418
if self._is_sync:
418419
for listener in object._channel.listeners(method):
420+
# Event handlers like route/locatorHandlerTriggered require us to perform async work.
421+
# In order to report their potential errors to the user, we need to catch it and store it in the connection
422+
def _done_callback(future: asyncio.Future) -> None:
423+
exc = future.exception()
424+
if exc:
425+
self._on_event_listener_error(exc)
426+
427+
def _listener_with_error_handler_attached(params: Any) -> None:
428+
potential_future = listener(params)
429+
if asyncio.isfuture(potential_future):
430+
potential_future.add_done_callback(_done_callback)
431+
419432
# Each event handler is a potentilly blocking context, create a fiber for each
420433
# and switch to them in order, until they block inside and pass control to each
421434
# other and then eventually back to dispatcher as listener functions return.
422-
g = EventGreenlet(listener)
435+
g = EventGreenlet(_listener_with_error_handler_attached)
423436
if should_replace_guids_with_channels:
424437
g.switch(self._replace_guids_with_channels(params))
425438
else:
@@ -432,9 +445,13 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
432445
else:
433446
object._channel.emit(method, params)
434447
except BaseException as exc:
435-
print("Error occurred in event listener", file=sys.stderr)
436-
traceback.print_exc()
437-
self._error = exc
448+
self._on_event_listener_error(exc)
449+
450+
def _on_event_listener_error(self, exc: BaseException) -> None:
451+
print("Error occurred in event listener", file=sys.stderr)
452+
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
453+
# Save the error to throw at the next API call. This "replicates" unhandled rejection in Node.js.
454+
self._error = exc
438455

439456
def _create_remote_object(
440457
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict

playwright/_impl/_helper.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,20 @@ async def _handle_internal(self, route: "Route") -> bool:
299299

300300
self._handled_count += 1
301301
if self._is_sync:
302+
handler_finished_future = route._loop.create_future()
303+
304+
def _handler() -> None:
305+
try:
306+
self.handler(route, route.request) # type: ignore
307+
handler_finished_future.set_result(None)
308+
except Exception as e:
309+
handler_finished_future.set_exception(e)
310+
302311
# As with event handlers, each route handler is a potentially blocking context
303312
# so it needs a fiber.
304-
g = RouteGreenlet(lambda: self.handler(route, route.request)) # type: ignore
313+
g = RouteGreenlet(_handler)
305314
g.switch()
315+
await handler_finished_future
306316
else:
307317
coro_or_future = self.handler(route, route.request) # type: ignore
308318
if coro_or_future:

playwright/_impl/_page.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,13 @@ def __init__(
180180
)
181181
self._channel.on(
182182
"locatorHandlerTriggered",
183-
lambda params: asyncio.create_task(
183+
lambda params: self._loop.create_task(
184184
self._on_locator_handler_triggered(params["uid"])
185185
),
186186
)
187187
self._channel.on(
188188
"route",
189-
lambda params: asyncio.create_task(
189+
lambda params: self._loop.create_task(
190190
self._on_route(from_channel(params["route"]))
191191
),
192192
)

tests/async/test_browsercontext_request_intercept.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import asyncio
1616
from pathlib import Path
1717

18+
import pytest
1819
from twisted.web import http
1920

2021
from playwright.async_api import BrowserContext, Page, Route
@@ -179,3 +180,15 @@ async def test_should_give_access_to_the_intercepted_response_body(
179180
route.fulfill(response=response),
180181
eval_task,
181182
)
183+
184+
185+
async def test_should_show_exception_after_fulfill(page: Page, server: Server) -> None:
186+
async def _handle(route: Route) -> None:
187+
await route.continue_()
188+
raise Exception("Exception text!?")
189+
190+
await page.route("*/**", _handle)
191+
await page.goto(server.EMPTY_PAGE)
192+
# Any next API call should throw because handler did throw during previous goto()
193+
with pytest.raises(Exception, match="Exception text!?"):
194+
await page.goto(server.EMPTY_PAGE)

tests/sync/test_browsercontext_request_intercept.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from pathlib import Path
1616

17+
import pytest
1718
from twisted.web import http
1819

1920
from playwright.sync_api import BrowserContext, Page, Route
@@ -121,3 +122,15 @@ def handle_route(route: Route) -> None:
121122
assert request.uri.decode() == "/title.html"
122123
original = (assetdir / "title.html").read_text()
123124
assert response.text() == original
125+
126+
127+
def test_should_show_exception_after_fulfill(page: Page, server: Server) -> None:
128+
def _handle(route: Route) -> None:
129+
route.continue_()
130+
raise Exception("Exception text!?")
131+
132+
page.route("*/**", _handle)
133+
page.goto(server.EMPTY_PAGE)
134+
# Any next API call should throw because handler did throw during previous goto()
135+
with pytest.raises(Exception, match="Exception text!?"):
136+
page.goto(server.EMPTY_PAGE)

0 commit comments

Comments
 (0)