From 0eb1897932b621c00ff5e207cb142c21867da03d Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Fri, 26 Apr 2024 17:53:44 +0200 Subject: [PATCH 01/15] Add the possibility to define and pass custom front- and backend exception handlers to the rx.Config --- .coveragerc | 40 ---------- reflex-examples | 1 + reflex/.templates/web/utils/state.js | 24 ++++++ reflex/config.py | 64 +++++++++++++++- reflex/constants/__init__.py | 3 +- reflex/constants/event.py | 14 ++++ reflex/state.py | 52 ++++++++++--- reflex/utils/exceptions.py | 30 +++++++- tests/test_config.py | 105 +++++++++++++++++++++++++++ 9 files changed, 281 insertions(+), 52 deletions(-) delete mode 100644 .coveragerc create mode 160000 reflex-examples diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d505ff27ef..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,40 +0,0 @@ -[run] -source = reflex -branch = true -omit = - */pyi_generator.py - reflex/__main__.py - reflex/app_module_for_backend.py - reflex/components/chakra/* - reflex/experimental/* - -[report] -show_missing = true -# TODO bump back to 79 -fail_under = 70 -precision = 2 - -# Regexes for lines to exclude from consideration -exclude_also = - # Don't complain about missing debug-only code: - def __repr__ - if self\.debug - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if 0: - if __name__ == .__main__.: - - # Don't complain about abstract methods, they aren't run: - @(abc\.)?abstractmethod - - # Don't complain about overloaded methods: - @overload - -ignore_errors = True - -[html] -directory = coverage_html_report diff --git a/reflex-examples b/reflex-examples new file mode 160000 index 0000000000..c5ae35fc55 --- /dev/null +++ b/reflex-examples @@ -0,0 +1 @@ +Subproject commit c5ae35fc5542ec9104ea02293891de617f4ade73 diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 7ff8256c4a..37a45e7c77 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -574,6 +574,30 @@ export const useEventLoop = ( queueEvents(events, socket); }; + // Handle frontend errors and send them to the backend via websocket. + useEffect(() => { + + if (typeof window !== 'undefined') { + + window.onerror = function (msg, url, lineNo, columnNo, error) { + addEvents([Event("state.handle_frontend_exception", { + message: error.message, + stack: error.stack, + })]) + return false; + } + + window.onunhandledrejection = function (event) { + addEvents([Event("state.handle_frontend_exception", { + message: event.reason.message, + stack: event.reason.stack, + })]) + return false; + } +} + +},[]) + const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { if (router.isReady && !sentHydrate.current) { diff --git a/reflex/config.py b/reflex/config.py index f10b38e965..2633c61030 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -5,8 +5,9 @@ import importlib import os import sys +import inspect import urllib.parse -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Callable try: # TODO The type checking guard can be removed once @@ -207,6 +208,12 @@ 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: Optional[Callable[[str, str], Any]] = None + + # Backend Error Handler Function + backend_exception_handler: Optional[Callable[[str, str], Any]] = None + def __init__(self, *args, **kwargs): """Initialize the config values. @@ -229,6 +236,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. @@ -357,6 +367,58 @@ 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. + + If none are passed, the default handlers will be used. + + Raises: + ValueError: If the custom exception handlers are invalid. + + """ + for handler_domain, handler_fn, handler_spec in zip( + ["frontend", "backend"], + [self.frontend_exception_handler, self.backend_exception_handler], + [ + constants.EventExceptionHandlers.FRONTEND_ARG_SPEC, + constants.EventExceptionHandlers.BACKEND_ARG_SPEC, + ], + ): + if handler_fn is not None: + _fn_name = handler_fn.__name__ + + if not inspect.isfunction(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 == "": + 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 + arg_annotations = inspect.get_annotations(handler_fn) + + for required_arg in handler_spec: + if required_arg not in arg_annotations: + raise ValueError( + f"Provided custom {handler_domain} exception handler `{_fn_name}` does not have the required argument `{required_arg}`" + ) + + for arg, arg_type in arg_annotations.items(): + + # Skip the return annotation + if arg == "return": + continue + + if arg_type != handler_spec[arg]: + raise ValueError( + f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {arg} argument." + f"Expected `{handler_spec[arg]}` but got `{arg_type}`" + ) + def get_config(reload: bool = False) -> Config: """Get the app config. diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index c5d3586cea..cb554fa7c5 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -45,7 +45,7 @@ from .custom_components import ( CustomComponents, ) -from .event import Endpoint, EventTriggers, SocketEvent +from .event import Endpoint, EventTriggers, SocketEvent, EventExceptionHandlers from .installer import ( Bun, Fnm, @@ -78,6 +78,7 @@ Endpoint, Env, EventTriggers, + EventExceptionHandlers, Expiration, Ext, Fnm, diff --git a/reflex/constants/event.py b/reflex/constants/event.py index aa6f8c713a..cb64144f47 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -93,3 +93,17 @@ class EventTriggers(SimpleNamespace): ON_CLEAR_SERVER_ERRORS = "on_clear_server_errors" ON_VALUE_COMMIT = "on_value_commit" ON_SELECT = "on_select" + + +class EventExceptionHandlers(SimpleNamespace): + """Front- and Backend Event exception handlers.""" + + FRONTEND_ARG_SPEC = { + "message": str, + "stack": str, + } + + BACKEND_ARG_SPEC = { + "message": str, + "stack": str, + } diff --git a/reflex/state.py b/reflex/state.py index 7545aed542..57fbb03356 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -53,12 +53,19 @@ fix_events, window_alert, ) -from reflex.utils import console, format, prerequisites, types +from reflex.utils import console, format, prerequisites, types, exceptions from reflex.utils.exceptions import ImmutableStateError, LockExpiredError from reflex.utils.exec import is_testing_env from reflex.utils.serializers import SerializedType, serialize, serializer from reflex.vars import BaseVar, ComputedVar, Var, computed_var +from reflex.config import get_config +from reflex.utils.exceptions import ( + default_frontend_exception_handler, + default_backend_exception_handler, +) + + if TYPE_CHECKING: from reflex.components.component import Component @@ -1532,14 +1539,22 @@ async def _process_event( yield state._as_state_update(handler, events, final=True) # If an error occurs, throw a window alert. - except Exception: - error = traceback.format_exc() - print(error) - yield state._as_state_update( - handler, - window_alert("An error occurred. See logs for details."), - final=True, - ) + except Exception as e: + stack_trace = traceback.format_exc() + + config = get_config() + + # Call the custom backend exception handler if specified. If not, fallback to the default handler. + if config.backend_exception_handler: + config.backend_exception_handler(message=str(e), stack=stack_trace) + else: + default_backend_exception_handler(message=str(e), stack=stack_trace) + + yield state._as_state_update( + handler, + window_alert("An error occurred. See logs for details."), + final=True, + ) def _mark_dirty_computed_vars(self) -> None: """Mark ComputedVars that need to be recalculated based on dirty_vars.""" @@ -1806,6 +1821,25 @@ class State(BaseState): # The hydrated bool. is_hydrated: bool = False + def handle_frontend_exception(self, message: str, stack: str) -> None: + """Handle frontend exceptions. + + If a frontend exception handler is defined for the exception name, it will be called. + Otherwise, the default frontend exception handler will be called. + + Args: + message: The message of the exception. + stack: The stack trace of the exception. + + """ + config = get_config() + + # Call the custom frontend exception handler if specified. If not, fallback to the default handler. + if config.frontend_exception_handler: + config.frontend_exception_handler(message, stack) + else: + default_frontend_exception_handler(message, stack) + class UpdateVarsInternalState(State): """Substate for handling internal state var updates.""" diff --git a/reflex/utils/exceptions.py b/reflex/utils/exceptions.py index e4a9a6e6c9..ea08a0c25b 100644 --- a/reflex/utils/exceptions.py +++ b/reflex/utils/exceptions.py @@ -1,4 +1,6 @@ -"""Custom Exceptions.""" +"""Custom Exceptions and Exception Handlers.""" + +import reflex.utils.console as console class InvalidStylePropError(TypeError): @@ -19,3 +21,29 @@ class MatchTypeError(TypeError): """Raised when the return types of match cases are different.""" pass + + +def default_frontend_exception_handler(message: str, stack: str) -> None: + """Default frontend exception handler function. + + Args: + message: The error message. + stack: The stack trace. + + """ + console.error( + f"[Reflex Frontend Exception]\n - Message: {message}\n - Stack: {stack}\n" + ) + + +def default_backend_exception_handler(message: str, stack: str) -> None: + """Default backend exception handler function. + + Args: + message: The error message. + stack: The stack trace. + + """ + console.error( + f"[Reflex Backend Exception]\n - Message: {message}\n - Stack: {stack}\n" + ) diff --git a/tests/test_config.py b/tests/test_config.py index 1ba2f548de..31e4940cae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -219,3 +219,108 @@ def test_reflex_dir_env_var(monkeypatch, tmp_path): mp_ctx = multiprocessing.get_context(method="spawn") with mp_ctx.Pool(processes=1) as pool: assert pool.apply(reflex_dir_constant) == str(tmp_path) + + +def test_custom_frontend_exception_handler(): + """Test that the custom frontend exception handler is set.""" + + def custom_exception_handler(message: str, stack: str): + print("Custom Frontend Exception") + print(stack) + + config = rx.Config( + app_name="a", frontend_exception_handler=custom_exception_handler + ) + assert config.frontend_exception_handler is not None + + +def test_custom_backend_exception_handler(): + """Test that the custom backend exception handler is set.""" + + def custom_exception_handler(message: str, stack: str): + print("Custom Backend Exception") + print(stack) + + config = rx.Config(app_name="a", backend_exception_handler=custom_exception_handler) + assert config.backend_exception_handler is not None + + +def valid_custom_handler(message: str, stack: str): + print("Custom Backend Exception") + print(stack) + + +def custom_exception_handler_with_wrong_argspec( + message: int, stack: str # Should be str +): + print("Custom Backend Exception") + print(stack) + + +custom_exception_handlers = { + "lambda": lambda message, stack: print("Custom Exception Handler", message, stack), + "wrong_argspec": custom_exception_handler_with_wrong_argspec, + "valid": valid_custom_handler, +} + + +@pytest.mark.parametrize( + ("handler_obj"), + [ + # Should throw error that the handler is not a function + ({"fn": "", "fn_is_valid": False}), + # Should throw error that the named function must be provided, lambdas not allowed + ({"fn": custom_exception_handlers["lambda"], "fn_is_valid": False}), + # Should throw error that the `message`` arg is of type int but should be str + ({"fn": custom_exception_handlers["wrong_argspec"], "fn_is_valid": False}), + # Should pass + ({"fn": custom_exception_handlers["valid"], "fn_is_valid": True}), + ], +) +def test_frontend_exception_handler_validation(handler_obj: dict): + """Test that the custom frontend exception handler is properly validated. + + Args: + handler_obj: A dictionary containing the function and whether it is valid or not. + + """ + fn_is_valid = None + + try: + rx.Config(app_name="a", frontend_exception_handler=handler_obj["fn"]) + fn_is_valid = True + except Exception as _: + fn_is_valid = False + + assert fn_is_valid == handler_obj["fn_is_valid"] + + +@pytest.mark.parametrize( + ("handler_obj"), + [ + # Should throw error that the handler is not a function + ({"fn": "", "fn_is_valid": False}), + # Should throw error that the named function must be provided, lambdas not allowed + ({"fn": custom_exception_handlers["lambda"], "fn_is_valid": False}), + # Should throw error that the `message`` arg is of type int but should be str + ({"fn": custom_exception_handlers["wrong_argspec"], "fn_is_valid": False}), + # Should pass + ({"fn": custom_exception_handlers["valid"], "fn_is_valid": True}), + ], +) +def test_backend_exception_handler_validation(handler_obj: dict): + """Test that the custom backend exception handler is properly validated. + + Args: + handler_obj: A dictionary containing the function and whether it is valid or not. + + """ + fn_is_valid = None + + try: + rx.Config(app_name="a", backend_exception_handler=handler_obj["fn"]) + fn_is_valid = True + except Exception as _: + fn_is_valid = False + + assert fn_is_valid == handler_obj["fn_is_valid"] From af543634d74bd2c0658af56daa89cf04c39bb605 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Fri, 26 Apr 2024 19:31:12 +0200 Subject: [PATCH 02/15] added possibility to add extra variables to the event exception handlers --- reflex/config.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 2633c61030..2a6993e2e9 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -385,7 +385,10 @@ def _validate_exception_handlers(self): ], ): if handler_fn is not None: - _fn_name = handler_fn.__name__ + if not hasattr(handler_fn, '__name__'): + _fn_name = handler_fn.__class__.__name__ + else: + _fn_name = handler_fn.__name__ if not inspect.isfunction(handler_fn): raise ValueError( @@ -407,16 +410,10 @@ def _validate_exception_handlers(self): f"Provided custom {handler_domain} exception handler `{_fn_name}` does not have the required argument `{required_arg}`" ) - for arg, arg_type in arg_annotations.items(): - - # Skip the return annotation - if arg == "return": - continue - - if arg_type != handler_spec[arg]: + if arg_annotations[required_arg] != handler_spec[required_arg]: raise ValueError( - f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {arg} argument." - f"Expected `{handler_spec[arg]}` but got `{arg_type}`" + f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {required_arg} argument." + f"Expected `{handler_spec[required_arg]}` but got `{arg_annotations[required_arg]}`" ) From aed02c811f498eb7c1aef76afdd3b1566c776af8 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Sat, 27 Apr 2024 09:05:16 +0200 Subject: [PATCH 03/15] disallow passing partial functions & allow passing class methods as event exception handler --- reflex/config.py | 8 +++++++- tests/test_config.py | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 2a6993e2e9..0a89e68a34 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -6,6 +6,7 @@ import os import sys import inspect +import functools import urllib.parse from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Callable @@ -390,7 +391,12 @@ def _validate_exception_handlers(self): else: _fn_name = handler_fn.__name__ - if not inspect.isfunction(handler_fn): + 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 inspect.isfunction(handler_fn) and not inspect.ismethod(handler_fn): raise ValueError( f"Provided custom {handler_domain} exception handler `{_fn_name}` is not a function." ) diff --git a/tests/test_config.py b/tests/test_config.py index 31e4940cae..3299875412 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,7 @@ from typing import Any, Dict import pytest - +import functools import reflex as rx import reflex.config from reflex.constants import Endpoint @@ -245,7 +245,7 @@ def custom_exception_handler(message: str, stack: str): assert config.backend_exception_handler is not None -def valid_custom_handler(message: str, stack: str): +def valid_custom_handler(message: str, stack: str, logger: str = "test"): print("Custom Backend Exception") print(stack) @@ -256,11 +256,19 @@ def custom_exception_handler_with_wrong_argspec( print("Custom Backend Exception") print(stack) +class SomeHandler: + + def handle(self, message:str, stack:str): + print("Custom Backend Exception") + print(stack) + custom_exception_handlers = { "lambda": lambda message, stack: print("Custom Exception Handler", message, stack), "wrong_argspec": custom_exception_handler_with_wrong_argspec, "valid": valid_custom_handler, + "partial": functools.partial(valid_custom_handler, logger="test"), + "method": SomeHandler().handle } @@ -275,6 +283,8 @@ def custom_exception_handler_with_wrong_argspec( ({"fn": custom_exception_handlers["wrong_argspec"], "fn_is_valid": False}), # Should pass ({"fn": custom_exception_handlers["valid"], "fn_is_valid": True}), + # Should pass + ({"fn": custom_exception_handlers["method"], "fn_is_valid": True}), ], ) def test_frontend_exception_handler_validation(handler_obj: dict): @@ -298,6 +308,8 @@ def test_frontend_exception_handler_validation(handler_obj: dict): @pytest.mark.parametrize( ("handler_obj"), [ + # Should throw error that the handler should not be a partial function + ({"fn": custom_exception_handlers["partial"], "fn_is_valid": False}), # Should throw error that the handler is not a function ({"fn": "", "fn_is_valid": False}), # Should throw error that the named function must be provided, lambdas not allowed From d9ba2414a58948c8031faec78e22c1aedaf7d4ed Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Sat, 27 Apr 2024 10:06:27 +0200 Subject: [PATCH 04/15] added catching call_script errors with window.onerror --- reflex/.templates/web/utils/state.js | 3 +++ reflex/config.py | 6 ++++-- tests/test_config.py | 6 +++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 37a45e7c77..535dd16a69 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -202,6 +202,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; } diff --git a/reflex/config.py b/reflex/config.py index 0a89e68a34..72c225aa71 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -386,7 +386,7 @@ def _validate_exception_handlers(self): ], ): if handler_fn is not None: - if not hasattr(handler_fn, '__name__'): + if not hasattr(handler_fn, "__name__"): _fn_name = handler_fn.__class__.__name__ else: _fn_name = handler_fn.__name__ @@ -396,7 +396,9 @@ def _validate_exception_handlers(self): f"Provided custom {handler_domain} exception handler `{_fn_name}` is a partial function. Please provide a named function instead." ) - if not inspect.isfunction(handler_fn) and not inspect.ismethod(handler_fn): + if not inspect.isfunction(handler_fn) and not inspect.ismethod( + handler_fn + ): raise ValueError( f"Provided custom {handler_domain} exception handler `{_fn_name}` is not a function." ) diff --git a/tests/test_config.py b/tests/test_config.py index 3299875412..ab5fcc5861 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -256,9 +256,9 @@ def custom_exception_handler_with_wrong_argspec( print("Custom Backend Exception") print(stack) -class SomeHandler: - def handle(self, message:str, stack:str): +class SomeHandler: + def handle(self, message: str, stack: str): print("Custom Backend Exception") print(stack) @@ -268,7 +268,7 @@ def handle(self, message:str, stack:str): "wrong_argspec": custom_exception_handler_with_wrong_argspec, "valid": valid_custom_handler, "partial": functools.partial(valid_custom_handler, logger="test"), - "method": SomeHandler().handle + "method": SomeHandler().handle, } From 7e7bc4ce4b57ae7aa7cec1a9a5be44b48ce95375 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Sat, 27 Apr 2024 10:22:54 +0200 Subject: [PATCH 05/15] passing named args to frontend exception handler --- reflex/state.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 57fbb03356..c1a824f220 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1824,7 +1824,7 @@ class State(BaseState): def handle_frontend_exception(self, message: str, stack: str) -> None: """Handle frontend exceptions. - If a frontend exception handler is defined for the exception name, it will be called. + If a frontend exception handler is provided, it will be called. Otherwise, the default frontend exception handler will be called. Args: @@ -1836,9 +1836,9 @@ def handle_frontend_exception(self, message: str, stack: str) -> None: # Call the custom frontend exception handler if specified. If not, fallback to the default handler. if config.frontend_exception_handler: - config.frontend_exception_handler(message, stack) + config.frontend_exception_handler(message=message, stack=stack) else: - default_frontend_exception_handler(message, stack) + default_frontend_exception_handler(message=message, stack=stack) class UpdateVarsInternalState(State): From ea6c3eb5f373eadad74911578babe15e512ff1d9 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 16:06:14 +0200 Subject: [PATCH 06/15] reinstated .coveragerc --- .coveragerc | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..59da024909 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,40 @@ +[run] +source = reflex +branch = true +omit = + */pyi_generator.py + reflex/__main__.py + reflex/app_module_for_backend.py + reflex/components/chakra/* + reflex/experimental/* + +[report] +show_missing = true +# TODO bump back to 79 +fail_under = 70 +precision = 2 + +# Regexes for lines to exclude from consideration +exclude_also = + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + + # Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + + # Don't complain about overloaded methods: + @overload + +ignore_errors = True + +[html] +directory = coverage_html_report \ No newline at end of file From 19118ce35da61580b7bfd1641851a96f992de13f Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 16:06:36 +0200 Subject: [PATCH 07/15] deleted reflex_examples submodule --- reflex-examples | 1 - 1 file changed, 1 deletion(-) delete mode 160000 reflex-examples diff --git a/reflex-examples b/reflex-examples deleted file mode 160000 index c5ae35fc55..0000000000 --- a/reflex-examples +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c5ae35fc5542ec9104ea02293891de617f4ade73 From af43b25f0a7d507aebdfe4a54a650d5ef4ab5a30 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 16:09:04 +0200 Subject: [PATCH 08/15] early return pattern for registering frontend exception handlers --- reflex/.templates/web/utils/state.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 535dd16a69..dc467ef1b9 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -580,14 +580,17 @@ export const useEventLoop = ( // Handle frontend errors and send them to the backend via websocket. useEffect(() => { - if (typeof window !== 'undefined') { - + if (typeof window === 'undefined') { + return; + + } + window.onerror = function (msg, url, lineNo, columnNo, error) { - addEvents([Event("state.handle_frontend_exception", { - message: error.message, - stack: error.stack, - })]) - return false; + addEvents([Event("state.handle_frontend_exception", { + message: error.message, + stack: error.stack, + })]) + return false; } window.onunhandledrejection = function (event) { @@ -597,9 +600,8 @@ export const useEventLoop = ( })]) return false; } -} -},[]) + },[]) const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode useEffect(() => { From 57e72c2202b9b504d2c97f34aaea35c49fcdfc04 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 16:15:35 +0200 Subject: [PATCH 09/15] replaced redundant function checks with callable() --- reflex/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 72c225aa71..e8a9697094 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -396,9 +396,7 @@ def _validate_exception_handlers(self): f"Provided custom {handler_domain} exception handler `{_fn_name}` is a partial function. Please provide a named function instead." ) - if not inspect.isfunction(handler_fn) and not inspect.ismethod( - handler_fn - ): + if not callable(handler_fn): raise ValueError( f"Provided custom {handler_domain} exception handler `{_fn_name}` is not a function." ) From 0bbb86aa86697b02fbfea8f415744fa6497aadd6 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 16:16:23 +0200 Subject: [PATCH 10/15] removed unused exceptions import from state --- reflex/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/state.py b/reflex/state.py index c1a824f220..87a435a261 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -53,7 +53,7 @@ fix_events, window_alert, ) -from reflex.utils import console, format, prerequisites, types, exceptions +from reflex.utils import console, format, prerequisites, types from reflex.utils.exceptions import ImmutableStateError, LockExpiredError from reflex.utils.exec import is_testing_env from reflex.utils.serializers import SerializedType, serialize, serializer From f950e5632dd3bc278d4cca0784b7d2751968190e Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 17:48:31 +0200 Subject: [PATCH 11/15] simplified default event exception handler execution logic --- reflex/config.py | 68 ++++++++++++++++++++++++------------------------ reflex/state.py | 26 +++++------------- 2 files changed, 41 insertions(+), 53 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index e8a9697094..7fc90c0266 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -26,6 +26,10 @@ from reflex import constants from reflex.base import Base from reflex.utils import console +from reflex.utils.exceptions import ( + default_frontend_exception_handler, + default_backend_exception_handler +) class DBConfig(Base): @@ -210,10 +214,10 @@ class Config: _non_default_attributes: Set[str] = pydantic.PrivateAttr(set()) # Frontend Error Handler Function - frontend_exception_handler: Optional[Callable[[str, str], Any]] = None + frontend_exception_handler: Optional[Callable[[str, str], None]] = default_frontend_exception_handler # Backend Error Handler Function - backend_exception_handler: Optional[Callable[[str, str], Any]] = None + backend_exception_handler: Optional[Callable[[str, str], None]] = default_backend_exception_handler def __init__(self, *args, **kwargs): """Initialize the config values. @@ -371,8 +375,6 @@ def _set_persistent(self, **kwargs): def _validate_exception_handlers(self): """Validate the custom event exception handlers for front- and backend. - If none are passed, the default handlers will be used. - Raises: ValueError: If the custom exception handlers are invalid. @@ -385,44 +387,42 @@ def _validate_exception_handlers(self): constants.EventExceptionHandlers.BACKEND_ARG_SPEC, ], ): - if handler_fn is not None: - if not hasattr(handler_fn, "__name__"): - _fn_name = handler_fn.__class__.__name__ - else: - _fn_name = handler_fn.__name__ + 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 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 == "": + 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 + arg_annotations = inspect.get_annotations(handler_fn) - if not callable(handler_fn): + for required_arg in handler_spec: + if required_arg not in arg_annotations: raise ValueError( - f"Provided custom {handler_domain} exception handler `{_fn_name}` is not a function." + f"Provided custom {handler_domain} exception handler `{_fn_name}` does not take the required argument `{required_arg}`" ) - # Allow named functions only as lambda functions cannot be introspected - if _fn_name == "": + if arg_annotations[required_arg] != handler_spec[required_arg]: raise ValueError( - f"Provided custom {handler_domain} exception handler `{_fn_name}` is a lambda function. Please use a named function instead." + f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {required_arg} argument." + f"Expected `{handler_spec[required_arg]}` but got `{arg_annotations[required_arg]}`" ) - # Check if the function has the necessary annotations and types - arg_annotations = inspect.get_annotations(handler_fn) - - for required_arg in handler_spec: - if required_arg not in arg_annotations: - raise ValueError( - f"Provided custom {handler_domain} exception handler `{_fn_name}` does not have the required argument `{required_arg}`" - ) - - if arg_annotations[required_arg] != handler_spec[required_arg]: - raise ValueError( - f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong type for {required_arg} argument." - f"Expected `{handler_spec[required_arg]}` but got `{arg_annotations[required_arg]}`" - ) - - def get_config(reload: bool = False) -> Config: """Get the app config. diff --git a/reflex/state.py b/reflex/state.py index 87a435a261..4b3531e995 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -60,10 +60,6 @@ from reflex.vars import BaseVar, ComputedVar, Var, computed_var from reflex.config import get_config -from reflex.utils.exceptions import ( - default_frontend_exception_handler, - default_backend_exception_handler, -) if TYPE_CHECKING: @@ -1544,17 +1540,13 @@ async def _process_event( config = get_config() - # Call the custom backend exception handler if specified. If not, fallback to the default handler. - if config.backend_exception_handler: - config.backend_exception_handler(message=str(e), stack=stack_trace) - else: - default_backend_exception_handler(message=str(e), stack=stack_trace) + config.backend_exception_handler(message=str(e), stack=stack_trace) - yield state._as_state_update( - handler, - window_alert("An error occurred. See logs for details."), - final=True, - ) + # yield state._as_state_update( + # handler, + # window_alert("An error occurred. See logs for details."), + # final=True, + # ) def _mark_dirty_computed_vars(self) -> None: """Mark ComputedVars that need to be recalculated based on dirty_vars.""" @@ -1834,11 +1826,7 @@ def handle_frontend_exception(self, message: str, stack: str) -> None: """ config = get_config() - # Call the custom frontend exception handler if specified. If not, fallback to the default handler. - if config.frontend_exception_handler: - config.frontend_exception_handler(message=message, stack=stack) - else: - default_frontend_exception_handler(message=message, stack=stack) + config.frontend_exception_handler(message=message, stack=stack) class UpdateVarsInternalState(State): From fbbdbcdac0b6e1caf754abba1b41df616fb3c568 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 17:50:46 +0200 Subject: [PATCH 12/15] removed useless unit tests --- tests/test_config.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index ab5fcc5861..aacf307496 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -221,30 +221,6 @@ def test_reflex_dir_env_var(monkeypatch, tmp_path): assert pool.apply(reflex_dir_constant) == str(tmp_path) -def test_custom_frontend_exception_handler(): - """Test that the custom frontend exception handler is set.""" - - def custom_exception_handler(message: str, stack: str): - print("Custom Frontend Exception") - print(stack) - - config = rx.Config( - app_name="a", frontend_exception_handler=custom_exception_handler - ) - assert config.frontend_exception_handler is not None - - -def test_custom_backend_exception_handler(): - """Test that the custom backend exception handler is set.""" - - def custom_exception_handler(message: str, stack: str): - print("Custom Backend Exception") - print(stack) - - config = rx.Config(app_name="a", backend_exception_handler=custom_exception_handler) - assert config.backend_exception_handler is not None - - def valid_custom_handler(message: str, stack: str, logger: str = "test"): print("Custom Backend Exception") print(stack) @@ -332,7 +308,7 @@ def test_backend_exception_handler_validation(handler_obj: dict): try: rx.Config(app_name="a", backend_exception_handler=handler_obj["fn"]) fn_is_valid = True - except Exception as _: + except Exception as e: fn_is_valid = False assert fn_is_valid == handler_obj["fn_is_valid"] From 1c7fa0966719634b8ac55cd9c758b58644c70439 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Mon, 29 Apr 2024 21:20:50 +0200 Subject: [PATCH 13/15] added integration tests and formatted the unit tests --- integration/test_event_exception_handlers.py | 223 +++++++++++++++++++ reflex/config.py | 13 +- reflex/state.py | 10 +- tests/test_config.py | 107 +++++---- 4 files changed, 300 insertions(+), 53 deletions(-) create mode 100644 integration/test_event_exception_handlers.py diff --git a/integration/test_event_exception_handlers.py b/integration/test_event_exception_handlers.py new file mode 100644 index 0000000000..904a25e0ab --- /dev/null +++ b/integration/test_event_exception_handlers.py @@ -0,0 +1,223 @@ +"""Integration tests for event exception handlers.""" +from __future__ import annotations + +from typing import Generator +from unittest.mock import AsyncMock + +import pytest + +import time + +from reflex.app import process +from reflex.event import Event +from reflex.state import StateManagerRedis + +from reflex.testing import AppHarness +from reflex.config import get_config + +from typing import Generator + +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +def TestApp(): + """A test app for event exception handler integration.""" + + import reflex as rx + + def frontend_exception_handler(message: str, stack: str): + print(f"[Fasfadgasdg] {message} {stack}") + + class TestAppConfig(rx.Config): + """Config for the TestApp app.""" + + class TestAppState(rx.State): + """State for the TestApp app.""" + + value: int + + def go(self, c: int): + """Increment the value c times and update each time. + + Args: + c: The number of times to increment. + + Yields: + After each increment. + """ + for _ in range(c): + self.value += 1 + yield + + 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", + ), + ) + + +@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")) + ) + + # 1. Test the default frontend exception handler + + reset_button.click() + + # Wait for the error to be logged + time.sleep(2) + + captured_default_handler_output = capsys.readouterr() + assert ( + "[Reflex Frontend Exception]" in captured_default_handler_output.out + and "induce_frontend_error" in captured_default_handler_output.out + and "ReferenceError" in captured_default_handler_output.out + ) + + # 2. Test the custom frontend exception handler + + def custom_frontend_exception_handler(message: str, stack: str) -> None: + print(f"[Custom Frontend Exception] {message} {stack}") + + # Set the custom frontend exception handler + config = get_config() + config.frontend_exception_handler = custom_frontend_exception_handler + + reset_button.click() + + # Wait for the error to be logged + time.sleep(2) + + captured_custom_handler_output = capsys.readouterr() + assert ( + "[Custom Frontend Exception]" in captured_custom_handler_output.out + and "induce_frontend_error" in captured_custom_handler_output.out + and "ReferenceError" in captured_custom_handler_output.out + ) + + +@pytest.mark.asyncio +async def test_backend_exception_handler_during_runtime(mocker, capsys, test_app): + """Test calling backend exception handler during runtime. + + Args: + mocker: mocker object. + capsys: capsys fixture. + test_app: harness for CallScript app. + driver: WebDriver instance. + + """ + token = "mock_token" + + router_data = { + "pathname": "/", + "query": {}, + "token": token, + "sid": "mock_sid", + "headers": {}, + "ip": "127.0.0.1", + } + + app = test_app.app_instance + mocker.patch.object(app, "postprocess", AsyncMock()) + + payload = {"c": "5"} # should be an int + + # 1. Test the default backend exception handler + + event = Event( + token=token, name="test_app_state.go", payload=payload, router_data=router_data + ) + + async for _update in process(app, event, "mock_sid", {}, "127.0.0.1"): + pass + + captured_default_handler_output = capsys.readouterr() + assert ( + "[Reflex Backend Exception]" in captured_default_handler_output.out + and "'str' object cannot be interpreted as an integer" + in captured_default_handler_output.out + ) + + # 2. Test the custom backend exception handler + + def custom_backend_exception_handler(message: str, stack: str) -> None: + print(f"[Custom Backend Exception] {message} {stack}") + + config = get_config() + config.backend_exception_handler = custom_backend_exception_handler + + event = Event( + token=token, name="test_app_state.go", payload=payload, router_data=router_data + ) + + async for _update in process(app, event, "mock_sid", {}, "127.0.0.1"): + pass + + captured_custom_handler_output = capsys.readouterr() + assert ( + "[Custom Backend Exception]" in captured_custom_handler_output.out + and "'str' object cannot be interpreted as an integer" + in captured_custom_handler_output.out + ) + + if isinstance(app.state_manager, StateManagerRedis): + await app.state_manager.close() diff --git a/reflex/config.py b/reflex/config.py index 7fc90c0266..2e33f4bcb3 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -27,8 +27,8 @@ from reflex.base import Base from reflex.utils import console from reflex.utils.exceptions import ( - default_frontend_exception_handler, - default_backend_exception_handler + default_frontend_exception_handler, + default_backend_exception_handler, ) @@ -214,10 +214,14 @@ class Config: _non_default_attributes: Set[str] = pydantic.PrivateAttr(set()) # Frontend Error Handler Function - frontend_exception_handler: Optional[Callable[[str, str], None]] = default_frontend_exception_handler + frontend_exception_handler: Optional[ + Callable[[str, str], None] + ] = default_frontend_exception_handler # Backend Error Handler Function - backend_exception_handler: Optional[Callable[[str, str], None]] = default_backend_exception_handler + backend_exception_handler: Optional[ + Callable[[str, str], None] + ] = default_backend_exception_handler def __init__(self, *args, **kwargs): """Initialize the config values. @@ -423,6 +427,7 @@ def _validate_exception_handlers(self): f"Expected `{handler_spec[required_arg]}` but got `{arg_annotations[required_arg]}`" ) + def get_config(reload: bool = False) -> Config: """Get the app config. diff --git a/reflex/state.py b/reflex/state.py index 4b3531e995..9e601c779d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1542,11 +1542,11 @@ async def _process_event( config.backend_exception_handler(message=str(e), stack=stack_trace) - # yield state._as_state_update( - # handler, - # window_alert("An error occurred. See logs for details."), - # final=True, - # ) + yield state._as_state_update( + handler, + window_alert("An error occurred. See logs for details."), + final=True, + ) def _mark_dirty_computed_vars(self) -> None: """Mark ComputedVars that need to be recalculated based on dirty_vars.""" diff --git a/tests/test_config.py b/tests/test_config.py index aacf307496..566c9bdf4e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,7 @@ import reflex as rx import reflex.config from reflex.constants import Endpoint +from contextlib import nullcontext as does_not_raise def test_requires_app_name(): @@ -249,66 +250,84 @@ def handle(self, message: str, stack: str): @pytest.mark.parametrize( - ("handler_obj"), + "handler_fn, expected", [ - # Should throw error that the handler is not a function - ({"fn": "", "fn_is_valid": False}), - # Should throw error that the named function must be provided, lambdas not allowed - ({"fn": custom_exception_handlers["lambda"], "fn_is_valid": False}), - # Should throw error that the `message`` arg is of type int but should be str - ({"fn": custom_exception_handlers["wrong_argspec"], "fn_is_valid": False}), - # Should pass - ({"fn": custom_exception_handlers["valid"], "fn_is_valid": True}), - # Should pass - ({"fn": custom_exception_handlers["method"], "fn_is_valid": True}), + pytest.param( + custom_exception_handlers["partial"], + pytest.raises(ValueError), + id="partial", + ), + pytest.param( + custom_exception_handlers["lambda"], + pytest.raises(ValueError), + id="lambda", + ), + pytest.param( + custom_exception_handlers["wrong_argspec"], + pytest.raises(ValueError), + id="wrong_argspec", + ), + pytest.param( + custom_exception_handlers["valid"], + does_not_raise(), + id="valid_handler", + ), + pytest.param( + custom_exception_handlers["method"], + does_not_raise(), + id="valid_class_method", + ), ], ) -def test_frontend_exception_handler_validation(handler_obj: dict): +def test_frontend_exception_handler_validation(handler_fn, expected): """Test that the custom frontend exception handler is properly validated. Args: - handler_obj: A dictionary containing the function and whether it is valid or not. + handler_fn: The handler function. + expected: The expected result. """ - fn_is_valid = None - - try: - rx.Config(app_name="a", frontend_exception_handler=handler_obj["fn"]) - fn_is_valid = True - except Exception as _: - fn_is_valid = False - - assert fn_is_valid == handler_obj["fn_is_valid"] + with expected: + rx.Config(app_name="a", frontend_exception_handler=handler_fn) @pytest.mark.parametrize( - ("handler_obj"), + "handler_fn, expected", [ - # Should throw error that the handler should not be a partial function - ({"fn": custom_exception_handlers["partial"], "fn_is_valid": False}), - # Should throw error that the handler is not a function - ({"fn": "", "fn_is_valid": False}), - # Should throw error that the named function must be provided, lambdas not allowed - ({"fn": custom_exception_handlers["lambda"], "fn_is_valid": False}), - # Should throw error that the `message`` arg is of type int but should be str - ({"fn": custom_exception_handlers["wrong_argspec"], "fn_is_valid": False}), - # Should pass - ({"fn": custom_exception_handlers["valid"], "fn_is_valid": True}), + pytest.param( + custom_exception_handlers["partial"], + pytest.raises(ValueError), + id="partial", + ), + pytest.param( + custom_exception_handlers["lambda"], + pytest.raises(ValueError), + id="lambda", + ), + pytest.param( + custom_exception_handlers["wrong_argspec"], + pytest.raises(ValueError), + id="wrong_argspec", + ), + pytest.param( + custom_exception_handlers["valid"], + does_not_raise(), + id="valid_handler", + ), + pytest.param( + custom_exception_handlers["method"], + does_not_raise(), + id="valid_class_method", + ), ], ) -def test_backend_exception_handler_validation(handler_obj: dict): +def test_backend_exception_handler_validation(handler_fn, expected): """Test that the custom backend exception handler is properly validated. Args: - handler_obj: A dictionary containing the function and whether it is valid or not. + handler_fn: The handler function. + expected: The expected result. """ - fn_is_valid = None - - try: - rx.Config(app_name="a", backend_exception_handler=handler_obj["fn"]) - fn_is_valid = True - except Exception as e: - fn_is_valid = False - - assert fn_is_valid == handler_obj["fn_is_valid"] + with expected: + rx.Config(app_name="a", backend_exception_handler=handler_fn) From f593724f9645ceafc8d01b65878f4a1f4b4090d8 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Tue, 30 Apr 2024 07:54:39 +0200 Subject: [PATCH 14/15] fixed pyright not seeing exception handlers --- reflex/config.pyi | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/reflex/config.pyi b/reflex/config.pyi index 57ce1123de..ce8cb14ea1 100644 --- a/reflex/config.pyi +++ b/reflex/config.pyi @@ -3,7 +3,7 @@ from reflex import constants as constants from reflex.base import Base as Base from reflex.utils import console as console -from typing import Any, Dict, List, Optional, overload +from typing import Any, Dict, List, Optional, Callable, overload class DBConfig(Base): engine: str @@ -70,6 +70,8 @@ class Config(Base): cp_web_url: str username: Optional[str] gunicorn_worker_class: str + frontend_exception_handler: Optional[Callable[[str, str], None]] + backend_exception_handler: Optional[Callable[[str, str], None]] def __init__( self, From 793b3bfa7bcfa17430ad42987d0d677d5bfa2143 Mon Sep 17 00:00:00 2001 From: Maxim Vlah Date: Tue, 30 Apr 2024 09:12:53 +0200 Subject: [PATCH 15/15] changed return type for backend event exception handler and added checks --- reflex/config.py | 36 +++++++++++++++++++++++++++++++++--- reflex/config.pyi | 5 ++++- reflex/constants/__init__.py | 3 +-- reflex/constants/event.py | 14 -------------- reflex/state.py | 4 ++-- reflex/utils/exceptions.py | 7 ++++++- tests/test_config.py | 21 +++++++++++++++++++++ 7 files changed, 67 insertions(+), 23 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 2e33f4bcb3..e3b7222b90 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -30,6 +30,7 @@ default_frontend_exception_handler, default_backend_exception_handler, ) +from reflex.event import EventSpec class DBConfig(Base): @@ -220,7 +221,7 @@ class Config: # Backend Error Handler Function backend_exception_handler: Optional[ - Callable[[str, str], None] + Callable[[str, str], EventSpec | list[EventSpec] | None] ] = default_backend_exception_handler def __init__(self, *args, **kwargs): @@ -383,12 +384,22 @@ def _validate_exception_handlers(self): ValueError: If the custom exception handlers are invalid. """ + FRONTEND_ARG_SPEC = { + "message": str, + "stack": str, + } + + BACKEND_ARG_SPEC = { + "message": str, + "stack": str, + } + for handler_domain, handler_fn, handler_spec in zip( ["frontend", "backend"], [self.frontend_exception_handler, self.backend_exception_handler], [ - constants.EventExceptionHandlers.FRONTEND_ARG_SPEC, - constants.EventExceptionHandlers.BACKEND_ARG_SPEC, + FRONTEND_ARG_SPEC, + BACKEND_ARG_SPEC, ], ): if hasattr(handler_fn, "__name__"): @@ -416,6 +427,7 @@ def _validate_exception_handlers(self): arg_annotations = inspect.get_annotations(handler_fn) for required_arg in 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}`" @@ -427,6 +439,24 @@ def _validate_exception_handlers(self): f"Expected `{handler_spec[required_arg]}` 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 = sig.return_annotation + + valid = bool( + return_type == EventSpec + or return_type == Optional[EventSpec] + or return_type == list[EventSpec] + or return_type == inspect.Signature.empty + ) + + if not valid: + raise ValueError( + f"Provided custom {handler_domain} exception handler `{_fn_name}` has the wrong return type." + f"Expected `EventSpec | list[EventSpec] | None` but got `{return_type}`" + ) + def get_config(reload: bool = False) -> Config: """Get the app config. diff --git a/reflex/config.pyi b/reflex/config.pyi index ce8cb14ea1..5458bb4098 100644 --- a/reflex/config.pyi +++ b/reflex/config.pyi @@ -3,6 +3,7 @@ from reflex import constants as constants from reflex.base import Base as Base from reflex.utils import console as console +from reflex.event import EventSpec as EventSpec from typing import Any, Dict, List, Optional, Callable, overload class DBConfig(Base): @@ -71,7 +72,9 @@ class Config(Base): username: Optional[str] gunicorn_worker_class: str frontend_exception_handler: Optional[Callable[[str, str], None]] - backend_exception_handler: Optional[Callable[[str, str], None]] + backend_exception_handler: Optional[ + Callable[[str, str], EventSpec | list[EventSpec] | None] + ] def __init__( self, diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index cb554fa7c5..c5d3586cea 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -45,7 +45,7 @@ from .custom_components import ( CustomComponents, ) -from .event import Endpoint, EventTriggers, SocketEvent, EventExceptionHandlers +from .event import Endpoint, EventTriggers, SocketEvent from .installer import ( Bun, Fnm, @@ -78,7 +78,6 @@ Endpoint, Env, EventTriggers, - EventExceptionHandlers, Expiration, Ext, Fnm, diff --git a/reflex/constants/event.py b/reflex/constants/event.py index cb64144f47..aa6f8c713a 100644 --- a/reflex/constants/event.py +++ b/reflex/constants/event.py @@ -93,17 +93,3 @@ class EventTriggers(SimpleNamespace): ON_CLEAR_SERVER_ERRORS = "on_clear_server_errors" ON_VALUE_COMMIT = "on_value_commit" ON_SELECT = "on_select" - - -class EventExceptionHandlers(SimpleNamespace): - """Front- and Backend Event exception handlers.""" - - FRONTEND_ARG_SPEC = { - "message": str, - "stack": str, - } - - BACKEND_ARG_SPEC = { - "message": str, - "stack": str, - } diff --git a/reflex/state.py b/reflex/state.py index 9e601c779d..8c2969361a 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1540,11 +1540,11 @@ async def _process_event( config = get_config() - config.backend_exception_handler(message=str(e), stack=stack_trace) + events = config.backend_exception_handler(message=str(e), stack=stack_trace) yield state._as_state_update( handler, - window_alert("An error occurred. See logs for details."), + events, final=True, ) diff --git a/reflex/utils/exceptions.py b/reflex/utils/exceptions.py index ea08a0c25b..7c00f1ee2a 100644 --- a/reflex/utils/exceptions.py +++ b/reflex/utils/exceptions.py @@ -1,6 +1,7 @@ """Custom Exceptions and Exception Handlers.""" import reflex.utils.console as console +from reflex.event import EventSpec, window_alert class InvalidStylePropError(TypeError): @@ -36,14 +37,18 @@ def default_frontend_exception_handler(message: str, stack: str) -> None: ) -def default_backend_exception_handler(message: str, stack: str) -> None: +def default_backend_exception_handler(message: str, stack: str) -> EventSpec: """Default backend exception handler function. Args: message: The error message. stack: The stack trace. + Returns: + EventSpec: The window alert event. """ console.error( f"[Reflex Backend Exception]\n - Message: {message}\n - Stack: {stack}\n" ) + + return window_alert("An error occurred. See logs for details.") diff --git a/tests/test_config.py b/tests/test_config.py index 566c9bdf4e..92fda23787 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -291,9 +291,30 @@ def test_frontend_exception_handler_validation(handler_fn, expected): rx.Config(app_name="a", frontend_exception_handler=handler_fn) +def backend_exception_handler_with_wrong_return_type(message: str, stack: str) -> int: + """Custom backend exception handler with wrong return type. + + Args: + message: The error message. + stack: The stack trace. + + Returns: + int: The wrong return type. + """ + print("Custom Backend Exception") + print(stack) + + return 5 + + @pytest.mark.parametrize( "handler_fn, expected", [ + pytest.param( + backend_exception_handler_with_wrong_return_type, + pytest.raises(ValueError), + id="wrong_return_type", + ), pytest.param( custom_exception_handlers["partial"], pytest.raises(ValueError),