Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions news/6827.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Report state deltas the frontend cannot process back to the backend via a new `client_error` socket event, logging an actionable error in the terminal instead of failing silently. A frontend/backend state mismatch is fatal for the session: further events stop until the page is reloaded after the frontend is rebuilt or `api_url` is corrected.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6827.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Validate incoming state deltas in the frontend before dispatching and report unprocessable updates to the backend via a new `client_error` socket event instead of failing silently in the browser console. Values reported by a client are escaped and bounded before reaching the backend logs.
101 changes: 86 additions & 15 deletions packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import { uploadFiles } from "$/utils/helpers/upload";
// Endpoint URLs.
const EVENTURL = env.EVENT;

// Socket event names (must match reflex_base/constants/event.py SocketEvent)
const CLIENT_ERROR_EVENT = "client_error";

// Client error types (must match reflex_base/constants/event.py ClientErrorType)
const ERROR_TYPE_DISPATCH_MISSING = "dispatch_function_missing";
const ERROR_TYPE_STATE_UPDATE = "state_update_processing_error";

// These hostnames indicate that the backend and frontend are reachable via the same domain.
const SAME_DOMAIN_HOSTNAMES = ["localhost", "0.0.0.0", "::", "0:0:0:0:0:0:0:0"];

Expand All @@ -39,6 +46,10 @@ const cookies = new Cookies();
// Dictionary holding component references.
export const refs = {};

// Set when the backend sends a delta the frontend cannot process. A mismatch
// between frontend and backend state definitions is fatal (#6019): no further
// events are sent until the frontend is rebuilt/reloaded.
let backend_state_mismatch = false;
// Array holding pending events to be processed.
const event_queue = [];

Expand Down Expand Up @@ -519,6 +530,14 @@ export const processEvent = async (socket, navigate, params) => {
return;
}

// A backend/frontend state mismatch is fatal; do not send further events.
// Drop pending events too: callers drain the queue in while-loops that
// would otherwise spin forever on an early return.
if (backend_state_mismatch) {
event_queue.length = 0;
return;
}
Comment thread
FarhanAliRaza marked this conversation as resolved.

// Only proceed if we're not already processing an event.
if (event_queue.length === 0) {
return;
Expand Down Expand Up @@ -693,24 +712,76 @@ export const connect = async (
}
});

// Report a failure to process a state update to the backend, so it surfaces
// in the terminal logs instead of only in the browser console.
const reportStateUpdateError = (error) => {
console.error("Error processing state update:", error);
socket.current?.emit(CLIENT_ERROR_EVENT, {
message: error?.message || String(error),
error_type: ERROR_TYPE_STATE_UPDATE,
});
};

// On each received message, queue the updates and events.
socket.current.on("event", async (update) => {
if (update.delta && Object.keys(update.delta).length > 0) {
for (const substate in update.delta) {
dispatch[substate](update.delta[substate]);
// handle events waiting for `is_hydrated`
if (
substate === state_name &&
update.delta[substate]?.is_hydrated_rx_state_
) {
queueEvents(on_hydrated_queue, socket, false, navigate, params);
on_hydrated_queue.length = 0;
}
socket.current.on("event", (update) => {
if (backend_state_mismatch) {
// A fatal state mismatch was already detected; drop further updates.
return;
}
// Validate the whole delta before dispatching anything, so a bad substate
// does not result in a partially applied state update. Walk the delta once
// and only allocate when a substate is actually missing.
let missing_substates;
for (const substate in update.delta) {
Comment thread
FarhanAliRaza marked this conversation as resolved.
if (typeof dispatch[substate] !== "function") {
(missing_substates ??= []).push(substate);
}
applyClientStorageDelta(client_storage, update.delta);
}
if (update.events && update.events.length > 0) {
queueEvents(update.events, socket, false, navigate, params);
if (missing_substates !== undefined) {
const errorMsg = `Cannot process state update: no dispatch function for substate(s) "${missing_substates.join(
'", "',
)}". Try refreshing the page or clearing your browser cache. This error usually indicates a mismatch between frontend and backend state definitions. If you are the developer of this app, rebuild the frontend and check that api_url is correct.`;
console.error(errorMsg);
// Surface the error in the backend terminal logs.
socket.current.emit(CLIENT_ERROR_EVENT, {
message: errorMsg,
substate: missing_substates.join(", "),
error_type: ERROR_TYPE_DISPATCH_MISSING,
});
backend_state_mismatch = true;
return;
}
try {
if (update.delta) {
for (const substate in update.delta) {
dispatch[substate](update.delta[substate]);
// handle events waiting for `is_hydrated`
if (
substate === state_name &&
update.delta[substate]?.is_hydrated_rx_state_
) {
// Deliberately not awaited: the rest of the delta and the client
// storage below must be applied before this handler yields, or a
// later update can interleave and apply its delta first.
queueEvents(
on_hydrated_queue,
socket,
false,
navigate,
params,
).catch(reportStateUpdateError);
on_hydrated_queue.length = 0;
}
}
applyClientStorageDelta(client_storage, update.delta);
}
if (update.events && update.events.length > 0) {
queueEvents(update.events, socket, false, navigate, params).catch(
reportStateUpdateError,
);
}
} catch (error) {
reportStateUpdateError(error);
}
Comment thread
FarhanAliRaza marked this conversation as resolved.
});
socket.current.on("new_token", async (new_token) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
UvLock,
)
from .custom_components import CustomComponents
from .event import Endpoint, EventTriggers, SocketEvent
from .event import ClientErrorType, Endpoint, EventTriggers, SocketEvent
from .installer import Bun, Node, PackageJson
from .route import (
ROUTE_NOT_FOUND,
Expand Down Expand Up @@ -92,6 +92,7 @@
"SYSTEM_COLOR_MODE",
"AgentsMd",
"Bun",
"ClientErrorType",
"ColorMode",
"CompileContext",
"CompileVars",
Expand Down
11 changes: 11 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class SocketEvent(SimpleNamespace):

PING = "ping"
EVENT = "event"
CLIENT_ERROR = "client_error"
Comment thread
FarhanAliRaza marked this conversation as resolved.

def __str__(self) -> str:
"""Get the string representation of the event name.
Expand All @@ -59,6 +60,16 @@ def __str__(self) -> str:
return str(self.value)


class ClientErrorType(SimpleNamespace):
"""Error types reported by the frontend via the client_error socket event.

Must match the ERROR_TYPE_* constants in .templates/web/utils/state.js.
"""

DISPATCH_MISSING = "dispatch_function_missing"
STATE_UPDATE = "state_update_processing_error"


class EventTriggers(SimpleNamespace):
"""All trigger names used in Reflex."""

Expand Down
31 changes: 31 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from functools import lru_cache
from typing import TYPE_CHECKING, Any

from rich.markup import escape as escape_markup

from reflex_base import constants
from reflex_base.utils import exceptions

Expand Down Expand Up @@ -601,6 +603,35 @@ def format_query_params(router_data: dict[str, Any]) -> dict[str, str]:
return {k.replace("-", "_"): v for k, v in params.items()}


def sanitize_client_log_value(value: Any, max_length: int = 500) -> str:
"""Make a client-supplied value safe to write to backend logs.

Args:
value: The client-supplied value.
max_length: Maximum length of the returned string.

Returns:
The value as a printable, length-bounded string with control characters
(newlines, ANSI escapes) replaced by spaces and rich markup escaped, so
a client cannot forge log lines, style backend output, or raise
``MarkupError`` when the value is printed through the console helpers.
"""
text = value if isinstance(value, str) else str(value)
# Slice before the per-character walk: clients can send arbitrarily long
# values, and everything past max_length is discarded anyway.
truncated = len(text) > max_length
text = escape_markup(
"".join(char if char.isprintable() else " " for char in text[:max_length])
)
if len(text) > max_length:
# Escaping markup can push a value that just fit over the limit.
truncated = True
if truncated:
suffix = "... (truncated)"
text = text[: max_length - len(suffix)] + suffix
Comment thread
FarhanAliRaza marked this conversation as resolved.
return text


def format_state_name(state_name: str) -> str:
"""Format a state name, replacing dots with double underscore.

Expand Down
95 changes: 95 additions & 0 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,16 @@ class EventNamespace(AsyncNamespace):
# The application object.
app: App

# Maximum error-level log entries a single session may produce via the
# client_error event before further reports from it are dropped.
_MAX_CLIENT_ERRORS_PER_SID = 5

# Process-wide bound on error-level client_error log entries per time
# window; per-SID budgets alone reset on reconnect, so scripted
# reconnects could otherwise flood the logs.
_CLIENT_ERROR_WINDOW_SECONDS = 60.0
_MAX_CLIENT_ERRORS_PER_WINDOW = 20

def __init__(self, namespace: str, app: App):
"""Initialize the event namespace.

Expand All @@ -1921,6 +1931,13 @@ def __init__(self, namespace: str, app: App):
# Use TokenManager for distributed duplicate tab prevention
self._token_manager = TokenManager.create()

# Number of client_error reports logged per SID, for rate limiting.
self._client_error_counts: dict[str, int] = {}

# Start time and count of the current process-wide client_error window.
self._client_error_window_start = 0.0
self._client_error_window_count = 0

@property
def token_to_sid(self) -> Mapping[str, str]:
"""Get token to SID mapping for backward compatibility.
Expand Down Expand Up @@ -1975,6 +1992,7 @@ def on_disconnect(self, sid: str) -> asyncio.Task | None:
Returns:
An asyncio Task for cleaning up the token, or None.
"""
self._client_error_counts.pop(sid, None)
Comment thread
FarhanAliRaza marked this conversation as resolved.
# Get token before cleaning up
disconnect_token = self.sid_to_token.get(sid)
if disconnect_token:
Expand Down Expand Up @@ -2119,6 +2137,83 @@ async def on_ping(self, sid: str):
# Emit the test event.
await self.emit(str(constants.SocketEvent.PING), "pong", to=sid)

async def on_client_error(self, sid: str, data: Any):
"""Handle errors reported by the frontend.

This is a dedicated socket event rather than a state event
(``FrontendEventExceptionState.handle_frontend_exception``) because a
state event is addressed by a handler name the frontend derives from
its own state definitions. When those definitions are what disagree
with the backend -- the case this handler exists to report -- the name
may not resolve and the report is lost. A fixed socket event name
cannot drift, and it still gets through after the frontend has stopped
sending events on detecting the mismatch.

Reports are routed through the app's ``frontend_exception_handler``,
so frontend errors (especially state update processing errors) are
visible in backend logs and reach custom exception handlers.

Args:
sid: The Socket.IO session id.
data: The error data from the client.
"""
if not isinstance(data, dict):
console.debug(f"Ignoring malformed client_error payload from SID {sid}.")
return

# Check the sender and the rate limits before sanitizing: sanitizing is
# linear in the size of the client-supplied values, and reports that are
# dropped here must not cost more than the check itself.
if sid not in self.sid_to_token:
Comment thread
FarhanAliRaza marked this conversation as resolved.
Comment thread
FarhanAliRaza marked this conversation as resolved.
# Sockets without a linked token are not known clients; don't let
# them write error-level entries into the backend logs.
console.debug(f"Ignoring client_error report from unknown SID {sid}.")
return

# Rate limit per session so a client cannot flood the backend logs.
error_count = self._client_error_counts.get(sid, 0)
if error_count >= self._MAX_CLIENT_ERRORS_PER_SID:
return

# Also bound total entries per time window: per-SID budgets reset on
# reconnect, so they alone do not stop scripted reconnect loops.
now = time.monotonic()
Comment thread
FarhanAliRaza marked this conversation as resolved.
if now - self._client_error_window_start > self._CLIENT_ERROR_WINDOW_SECONDS:
self._client_error_window_start = now
self._client_error_window_count = 0
if self._client_error_window_count >= self._MAX_CLIENT_ERRORS_PER_WINDOW:
if self._client_error_window_count == self._MAX_CLIENT_ERRORS_PER_WINDOW:
# Warn once per window so suppression is visible in the logs
# and a flooding client cannot silently starve reports from
# other sessions.
self._client_error_window_count += 1
console.warn(
f"Received more than {self._MAX_CLIENT_ERRORS_PER_WINDOW} "
f"client_error reports in {self._CLIENT_ERROR_WINDOW_SECONDS:.0f}s; "
"suppressing further reports for this window."
)
return
Comment thread
FarhanAliRaza marked this conversation as resolved.
self._client_error_window_count += 1
self._client_error_counts[sid] = error_count + 1

error_type = format.sanitize_client_log_value(data.get("error_type", "unknown"))
if error_type == constants.ClientErrorType.DISPATCH_MISSING:
substate = format.sanitize_client_log_value(data.get("substate", ""))
report = (
f"[SID: {sid}] State update failed: "
f"no dispatch function for substate(s) '{substate}'. "
"This indicates a frontend/backend state mismatch. "
"Rebuild the frontend or check that api_url points to the matching backend."
)
else:
message = format.sanitize_client_log_value(
data.get("message", "No error message provided")
)
report = f"[SID: {sid}] {error_type}: {message}"
# Route through the app's frontend exception handler so custom
# handlers (e.g. error trackers) receive client errors too.
self.app.frontend_exception_handler(Exception(report))

async def link_token_to_sid(self, sid: str, token: str):
"""Link a token to a session id.

Expand Down
3 changes: 2 additions & 1 deletion reflex/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
UvLock,
)
from .custom_components import CustomComponents
from .event import Endpoint, EventTriggers, SocketEvent
from .event import ClientErrorType, Endpoint, EventTriggers, SocketEvent
from .installer import Bun, Node, PackageJson
from .route import (
ROUTE_NOT_FOUND,
Expand Down Expand Up @@ -81,6 +81,7 @@
"SESSION_STORAGE",
"SETTER_PREFIX",
"Bun",
"ClientErrorType",
"ColorMode",
"CompileContext",
"CompileVars",
Expand Down
15 changes: 13 additions & 2 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2417,8 +2417,13 @@ def handle_frontend_exception(
"window.location.reload();"
"}"
)
# Escape rich markup so a JS error message containing square brackets
# (e.g. "x[/bold]y is not a function") cannot style backend logs or
# raise MarkupError when printed through the console helpers. The text
# is not otherwise sanitized: stack traces are multi-line by nature and
# truncating them would lose the information this handler exists for.
prerequisites.get_and_validate_app().app.frontend_exception_handler(
Exception(info)
Exception(escape(info))
)


Expand Down Expand Up @@ -2605,7 +2610,13 @@ def create(cls, *children, **props) -> Component:
frozen=True,
)
class StateUpdate:
"""A state update sent to the frontend."""
"""A state update sent to the frontend.

Each substate key in the delta must have a dispatch function registered in
the frontend; otherwise the frontend reports a fatal ``client_error`` back
to the backend (see ``EventNamespace.on_client_error``), since this
indicates mismatched frontend and backend state definitions.
"""

# The state delta.
delta: DeltaMapping = dataclasses.field(default_factory=dict)
Expand Down
Loading
Loading