|
@@ -7,11 +7,13 @@ import concurrent.futures
|
|
|
import contextlib
|
|
|
import copy
|
|
|
import functools
|
|
|
+import inspect
|
|
|
import io
|
|
|
import multiprocessing
|
|
|
import os
|
|
|
import platform
|
|
|
import sys
|
|
|
+import traceback
|
|
|
from datetime import datetime
|
|
|
from typing import (
|
|
|
Any,
|
|
@@ -45,6 +47,7 @@ from reflex.compiler import compiler
|
|
|
from reflex.compiler import utils as compiler_utils
|
|
|
from reflex.compiler.compiler import ExecutorSafeFunctions
|
|
|
from reflex.components.base.app_wrap import AppWrap
|
|
|
+from reflex.components.base.error_boundary import ErrorBoundary
|
|
|
from reflex.components.base.fragment import Fragment
|
|
|
from reflex.components.component import (
|
|
|
Component,
|
|
@@ -60,7 +63,7 @@ from reflex.components.core.client_side_routing import (
|
|
|
from reflex.components.core.upload import Upload, get_upload_dir
|
|
|
from reflex.components.radix import themes
|
|
|
from reflex.config import get_config
|
|
|
-from reflex.event import Event, EventHandler, EventSpec
|
|
|
+from reflex.event import Event, EventHandler, EventSpec, window_alert
|
|
|
from reflex.model import Model
|
|
|
from reflex.page import (
|
|
|
DECORATED_PAGES,
|
|
@@ -88,6 +91,33 @@ ComponentCallable = Callable[[], Component]
|
|
|
Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]]
|
|
|
|
|
|
|
|
|
+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.")
|
|
|
+
|
|
|
+
|
|
|
def default_overlay_component() -> Component:
|
|
|
"""Default overlay_component attribute for App.
|
|
|
|
|
@@ -101,6 +131,16 @@ def default_overlay_component() -> Component:
|
|
|
)
|
|
|
|
|
|
|
|
|
+def default_error_boundary() -> Component:
|
|
|
+ """Default error_boundary attribute for App.
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ The default error_boundary, which is an ErrorBoundary.
|
|
|
+
|
|
|
+ """
|
|
|
+ return ErrorBoundary.create()
|
|
|
+
|
|
|
+
|
|
|
class OverlayFragment(Fragment):
|
|
|
"""Alias for Fragment, used to wrap the overlay_component."""
|
|
|
|
|
@@ -142,6 +182,11 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
default_overlay_component
|
|
|
)
|
|
|
|
|
|
+ # Error boundary component to wrap the app with.
|
|
|
+ error_boundary: Optional[Union[Component, ComponentCallable]] = (
|
|
|
+ default_error_boundary
|
|
|
+ )
|
|
|
+
|
|
|
# Components to add to the head of every page.
|
|
|
head_components: List[Component] = []
|
|
|
|
|
@@ -178,6 +223,16 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
# Background tasks that are currently running. PRIVATE.
|
|
|
background_tasks: Set[asyncio.Task] = 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, **kwargs):
|
|
|
"""Initialize the app.
|
|
|
|
|
@@ -279,6 +334,9 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
# Mount the socket app with the API.
|
|
|
self.api.mount(str(constants.Endpoint.EVENT), socket_app)
|
|
|
|
|
|
+ # Check the exception handlers
|
|
|
+ self._validate_exception_handlers()
|
|
|
+
|
|
|
def __repr__(self) -> str:
|
|
|
"""Get the string representation of the app.
|
|
|
|
|
@@ -688,6 +746,25 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
for k, component in self.pages.items():
|
|
|
self.pages[k] = self._add_overlay_to_component(component)
|
|
|
|
|
|
+ def _add_error_boundary_to_component(self, component: Component) -> Component:
|
|
|
+ if self.error_boundary is None:
|
|
|
+ return component
|
|
|
+
|
|
|
+ component = ErrorBoundary.create(*component.children)
|
|
|
+
|
|
|
+ return component
|
|
|
+
|
|
|
+ def _setup_error_boundary(self):
|
|
|
+ """If a State is not used and no error_boundary is specified, do not render the error boundary."""
|
|
|
+ if self.state is None and self.error_boundary is default_error_boundary:
|
|
|
+ self.error_boundary = None
|
|
|
+
|
|
|
+ for k, component in self.pages.items():
|
|
|
+ # Skip the 404 page
|
|
|
+ if k == constants.Page404.SLUG:
|
|
|
+ continue
|
|
|
+ self.pages[k] = self._add_error_boundary_to_component(component)
|
|
|
+
|
|
|
def _apply_decorated_pages(self):
|
|
|
"""Add @rx.page decorated pages to the app.
|
|
|
|
|
@@ -757,6 +834,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
|
|
|
self._validate_var_dependencies()
|
|
|
self._setup_overlay_component()
|
|
|
+ self._setup_error_boundary()
|
|
|
|
|
|
# Create a progress bar.
|
|
|
progress = Progress(
|
|
@@ -1036,6 +1114,100 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
|
|
task.add_done_callback(self.background_tasks.discard)
|
|
|
return task
|
|
|
|
|
|
+ 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}`"
|
|
|
+ )
|
|
|
+
|
|
|
|
|
|
async def process(
|
|
|
app: App, event: Event, sid: str, headers: Dict, client_ip: str
|
|
@@ -1101,6 +1273,8 @@ async def process(
|
|
|
yield update
|
|
|
except Exception as ex:
|
|
|
telemetry.send_error(ex, context="backend")
|
|
|
+
|
|
|
+ app.backend_exception_handler(ex)
|
|
|
raise
|
|
|
|
|
|
|