Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the possibility to pass custom front- and backend event exception handlers to the rx.App #3476

Closed
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions integration/test_exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Integration tests for event exception handlers."""
from __future__ import annotations

import time
from typing import Generator

import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

from reflex.testing import AppHarness


def TestApp():
"""A test app for event exception handler integration."""
import reflex as rx

class TestAppConfig(rx.Config):
"""Config for the TestApp app."""

class TestAppState(rx.State):
"""State for the TestApp app."""

def divide_by_number(self, number: int):
"""Divide by number and print the result.

Args:
number: number to divide by
"""
print(1 / number)

app = rx.App(state=rx.State)

@app.add_page
def index():
return rx.vstack(
rx.button(
"induce_frontend_error",
on_click=rx.call_script("induce_frontend_error()"),
id="induce-frontend-error-btn",
),
rx.button(
"induce_backend_error",
on_click=lambda: TestAppState.divide_by_number(0), # type: ignore
id="induce-backend-error-btn",
),
)


@pytest.fixture(scope="module")
def test_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Start TestApp app at tmp_path via AppHarness.

Args:
tmp_path_factory: pytest tmp_path_factory fixture

Yields:
running AppHarness instance
"""
with AppHarness.create(
root=tmp_path_factory.mktemp("test_app"),
app_source=TestApp, # type: ignore
) as harness:
yield harness


@pytest.fixture
def driver(test_app: AppHarness) -> Generator[WebDriver, None, None]:
"""Get an instance of the browser open to the test_app app.

Args:
test_app: harness for TestApp app

Yields:
WebDriver instance.
"""
assert test_app.app_instance is not None, "app is not running"
driver = test_app.frontend()
try:
yield driver
finally:
driver.quit()


def test_frontend_exception_handler_during_runtime(
driver: WebDriver,
capsys,
):
"""Test calling frontend exception handler during runtime.

We send an event containing a call to a non-existent function in the frontend.
This should trigger the default frontend exception handler.

Args:
driver: WebDriver instance.
capsys: pytest fixture for capturing stdout and stderr.
"""
reset_button = WebDriverWait(driver, 20).until(
EC.element_to_be_clickable((By.ID, "induce-frontend-error-btn"))
)

reset_button.click()

# Wait for the error to be logged
time.sleep(2)

captured_default_handler_output = capsys.readouterr()
assert (
"induce_frontend_error" in captured_default_handler_output.out
and "ReferenceError" in captured_default_handler_output.out
)


def test_backend_exception_handler_during_runtime(
driver: WebDriver,
capsys,
):
"""Test calling backend exception handler during runtime.

We invoke TestAppState.divide_by_zero to induce backend error.
This should trigger the default backend exception handler.

Args:
driver: WebDriver instance.
capsys: pytest fixture for capturing stdout and stderr.
"""
reset_button = WebDriverWait(driver, 20).until(
EC.element_to_be_clickable((By.ID, "induce-backend-error-btn"))
)

reset_button.click()

# Wait for the error to be logged
time.sleep(2)

captured_default_handler_output = capsys.readouterr()
assert (
"divide_by_number" in captured_default_handler_output.out
and "ZeroDivisionError" in captured_default_handler_output.out
)
26 changes: 26 additions & 0 deletions reflex/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export const applyEvent = async (event, socket) => {
}
} catch (e) {
console.log("_call_script", e);
if (window && window?.onerror) {
window.onerror(e.message, null, null, null, e)
}
}
return false;
}
Expand Down Expand Up @@ -603,6 +606,29 @@ export const useEventLoop = (
}
};

// Handle frontend errors and send them to the backend via websocket.
useEffect(() => {

if (typeof window === 'undefined') {
return;
}

window.onerror = function (msg, url, lineNo, columnNo, error) {
addEvents([Event("frontend_event_exception_state.handle_frontend_exception", {
stack: error.stack,
})])
return false;
}

window.onunhandledrejection = function (event) {
addEvents([Event("frontend_event_exception_state.handle_frontend_exception", {
stack: event.reason.stack,
})])
return false;
}

},[])

const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
useEffect(() => {
if (router.isReady && !sentHydrate.current) {
Expand Down
139 changes: 138 additions & 1 deletion reflex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

from __future__ import annotations

import functools
import importlib
import inspect
import os
import sys
import traceback
import urllib.parse
from typing import Any, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, Optional, Set, Union

try:
import pydantic.v1 as pydantic
Expand All @@ -17,9 +20,36 @@

from reflex import constants
from reflex.base import Base
from reflex.event import EventSpec, window_alert
from reflex.utils import console


def default_frontend_exception_handler(exception: Exception) -> None:
"""Default frontend exception handler function.

Args:
exception: The exception.

"""
console.error(f"[Reflex Frontend Exception]\n {exception}\n")


def default_backend_exception_handler(exception: Exception) -> EventSpec:
"""Default backend exception handler function.

Args:
exception: The exception.

Returns:
EventSpec: The window alert event.
"""
error = traceback.format_exc()

console.error(f"[Reflex Backend Exception]\n {error}\n")

return window_alert("An error occurred. See logs for details.")


class DBConfig(Base):
"""Database config."""

Expand Down Expand Up @@ -222,6 +252,16 @@ class Config:
# Attributes that were explicitly set by the user.
_non_default_attributes: Set[str] = pydantic.PrivateAttr(set())

# Frontend Error Handler Function
frontend_exception_handler: Callable[
[Exception], None
] = default_frontend_exception_handler

# Backend Error Handler Function
backend_exception_handler: Callable[
[Exception], Union[EventSpec, List[EventSpec], None]
] = default_backend_exception_handler

def __init__(self, *args, **kwargs):
"""Initialize the config values.

Expand All @@ -241,6 +281,9 @@ def __init__(self, *args, **kwargs):
self._non_default_attributes.update(kwargs)
self._replace_defaults(**kwargs)

# Check the exception handlers
self._validate_exception_handlers()

@property
def module(self) -> str:
"""Get the module name of the app.
Expand Down Expand Up @@ -346,6 +389,100 @@ def _set_persistent(self, **kwargs):
self._non_default_attributes.update(kwargs)
self._replace_defaults(**kwargs)

def _validate_exception_handlers(self):
"""Validate the custom event exception handlers for front- and backend.

Raises:
ValueError: If the custom exception handlers are invalid.

"""
FRONTEND_ARG_SPEC = {
"exception": Exception,
}

BACKEND_ARG_SPEC = {
"exception": Exception,
}

for handler_domain, handler_fn, handler_spec in zip(
["frontend", "backend"],
[self.frontend_exception_handler, self.backend_exception_handler],
[
FRONTEND_ARG_SPEC,
BACKEND_ARG_SPEC,
],
):
if hasattr(handler_fn, "__name__"):
_fn_name = handler_fn.__name__
else:
_fn_name = handler_fn.__class__.__name__

if isinstance(handler_fn, functools.partial):
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` is a partial function. Please provide a named function instead."
)

if not callable(handler_fn):
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` is not a function."
)

# Allow named functions only as lambda functions cannot be introspected
if _fn_name == "<lambda>":
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` is a lambda function. Please use a named function instead."
)

# Check if the function has the necessary annotations and types in the right order
argspec = inspect.getfullargspec(handler_fn)
arg_annotations = {
k: eval(v) if isinstance(v, str) else v
for k, v in argspec.annotations.items()
if k not in ["args", "kwargs", "return"]
}

for required_arg_index, required_arg in enumerate(handler_spec):
if required_arg not in arg_annotations:
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` does not take the required argument `{required_arg}`"
)
elif (
not list(arg_annotations.keys())[required_arg_index] == required_arg
):
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong argument order."
f"Expected `{required_arg}` as the {required_arg_index+1} argument but got `{list(arg_annotations.keys())[required_arg_index]}`"
)

if not issubclass(arg_annotations[required_arg], Exception):
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {required_arg} argument."
f"Expected to be `Exception` but got `{arg_annotations[required_arg]}`"
)

# Check if the return type is valid for backend exception handler
if handler_domain == "backend":
sig = inspect.signature(self.backend_exception_handler)
return_type = (
eval(sig.return_annotation)
if isinstance(sig.return_annotation, str)
else sig.return_annotation
)

valid = bool(
return_type == EventSpec
or return_type == Optional[EventSpec]
or return_type == List[EventSpec]
or return_type == inspect.Signature.empty
or return_type is None
)

if not valid:
raise ValueError(
f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong return type."
f"Expected `Union[EventSpec, List[EventSpec], None]` but got `{return_type}`"
)


def get_config(reload: bool = False) -> Config:
"""Get the app config.
Expand Down
Loading
Loading