From 9431d0501e9c9df08329bcb181acc4a147b90a16 Mon Sep 17 00:00:00 2001 From: Farhan Date: Mon, 19 Oct 2026 23:32:07 +0500 Subject: [PATCH 1/3] fix(state): back off when the connection closes before connecting A transport closed before the namespace connected (for example, a CONNECT packet over the server or a proxy message size limit) emitted "disconnect", and the handler reconnected at once with no error. Oversized browser storage made the page reconnect hundreds of times per second with no toast (#7486). Such a close now counts as a failed attempt: it records a connection error and retries with the incremental backoff. The backoff read its attempt count from inside the setConnectErrors updater. React runs that updater lazily when the component has pending work, so the delay was often 0 ms. The count now lives in the connect() closure. Fixes #7486 --- .../news/+reconnect-backoff.bugfix.md | 1 + .../reflex_base/.templates/web/utils/state.js | 29 +++++++---- .../test_hydration_storage.py | 49 +++++++++++++++++++ tests/units/compiler/state_js.test.mjs | 48 +++++++++++++++++- 4 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 packages/reflex-base/news/+reconnect-backoff.bugfix.md diff --git a/packages/reflex-base/news/+reconnect-backoff.bugfix.md b/packages/reflex-base/news/+reconnect-backoff.bugfix.md new file mode 100644 index 00000000000..d45093006e5 --- /dev/null +++ b/packages/reflex-base/news/+reconnect-backoff.bugfix.md @@ -0,0 +1 @@ +Retry with backoff and show the connection error when the server or a proxy closes the connection before it is established, for example when browser storage makes the hydrate message too large, instead of reconnecting in a tight loop. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index c4f212de66c..9894580879f 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1001,11 +1001,14 @@ export const connect = async ( // Unknown substates already reported on this connection. const reported_missing_substates = new Set(); + // Failed connection attempts since the last connect, for the retry backoff. + let failed_attempts = 0; // Once the socket is open, hydrate the page. socket.current.on("connect", async () => { socket.current.wait_connect = false; reported_missing_substates.clear(); + failed_attempts = 0; setConnectErrors([]); window.__reflex_otel?.onSocketConnect?.(); window.addEventListener("pagehide", pagehideHandler); @@ -1016,23 +1019,29 @@ export const connect = async ( } }); - socket.current.on("connect_error", (error) => { + // Record a failed connection attempt and retry it with incremental backoff. + const retryConnect = (error) => { socket.current.wait_connect = false; - let n_connect_errors = 0; - setConnectErrors((connectErrors) => { - const new_errors = [...connectErrors.slice(-9), error]; - n_connect_errors = new_errors.length; - return new_errors; - }); + failed_attempts = Math.min(failed_attempts + 1, 10); + setConnectErrors((connectErrors) => [...connectErrors.slice(-9), error]); window.setTimeout(() => { if (socket.current && !socket.current.connected) { socket.current.reconnect(); } - }, 200 * n_connect_errors); // Incremental backoff - }); + }, 200 * failed_attempts); // Incremental backoff + }; + + socket.current.on("connect_error", retryConnect); socket.current.on("disconnect", (reason, details) => { - socket.current.wait_connect = false; + // A transport closed before the namespace connected is a failed attempt, + // e.g. a CONNECT packet over the server or a proxy message size limit. + if (socket.current.wait_connect) { + retryConnect( + new Error(`connection closed before connecting (${reason})`), + ); + return; + } window.__reflex_otel?.onSocketDisconnect?.(reason); const try_reconnect = reason !== "io server disconnect" && reason !== "io client disconnect"; diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 0073896411d..01bedf0fa52 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -651,3 +651,52 @@ def test_synced_storage_event_while_disconnected_sends_current_value( expect(page.locator("#sync-value")).to_have_text("fresh") assert not any("update_vars_internal" in m and '"stale"' in m for m in socket.sent) assert page.evaluate("localStorage.getItem('hydrate-sync')") == "fresh" + + +@pytest.mark.parametrize("limit", ["server", "proxy"]) +def test_refused_hydration_retries_with_backoff( + hydration_storage_app: AppHarness, page: Page, limit: str +): + """A CONNECT packet dropped for its size is retried with backoff and reported. + + Args: + hydration_storage_app: The running app. + page: A fresh browser page. + limit: Whether the server limit or a smaller proxy limit drops the packet. + """ + assert hydration_storage_app.frontend_url is not None + opens: list[str] = [] + page.on("websocket", lambda ws: opens.append(ws.url)) + if limit == "proxy": + + def proxy(route: WebSocketRoute): + """Close the transport on a large frame, like a proxy with its own limit. + + Args: + route: The intercepted websocket. + """ + server = route.connect_to_server() + route.on_message( + lambda m: route.close() if len(m) > 100_000 else server.send(m) + ) + server.on_message(route.send) + + page.route_web_socket(re.compile(r".*/_event"), proxy) + page.goto(hydration_storage_app.frontend_url) + expect(page.locator("#hydrated")).to_have_text("true") + + size = 1_100_000 if limit == "server" else 200_000 + page.evaluate("n => localStorage.setItem('hydrate-local', 'x'.repeat(n))", size) + opens.clear() + page.reload() + expect(page.get_by_text("Cannot connect to server")).to_be_visible() + page.wait_for_timeout(3000) + # The backoff allows about six attempts in three seconds; an immediate + # reconnect makes hundreds. + event_opens = [url for url in opens if "/_event" in url] + assert len(event_opens) < 10, event_opens + expect(page.locator("#hydrated")).to_have_text("false") + + page.evaluate("localStorage.removeItem('hydrate-local')") + page.reload() + expect(page.locator("#hydrated")).to_have_text("true") diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 1583138edb2..97b3e3025c0 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -19,6 +19,7 @@ async function setup({ hidden = false, storedToken = "original-token", clientStorage = {}, + dropConnect = false, react = { useCallback() {}, useEffect() {}, useRef() {}, useState() {} }, } = {}) { const sockets = []; @@ -26,6 +27,8 @@ async function setup({ const timers = new Map(); const firstHydrates = []; const updates = []; + let connectErrors = []; + const errorUpdates = []; const window = new EventTarget(); window.location = new URL("http://localhost:3000/page?query=value"); const storage = new Map(storedToken ? [["token", storedToken]] : []); @@ -62,6 +65,7 @@ async function setup({ function io(url, opts) { const handlers = new Map(); const socket = { + handlers, connected: false, namespaceConnects: 0, disconnects: 0, @@ -90,6 +94,11 @@ async function setup({ assert.ok(handlers.has(name), `missing handler: ${name}`); } this.namespaceConnects++; + if (dropConnect) { + // The server closes the transport before acknowledging the CONNECT. + handlers.get("disconnect")("transport close"); + return; + } this.connected = true; handlers.get("connect")(); handlers.get("new_token")("assigned-token"); @@ -176,6 +185,14 @@ async function setup({ localStorage, socket, updates, + get connectErrors() { + // Like React with pending work, apply queued updaters only on render. + for (const update of errorUpdates.splice(0)) { + connectErrors = + typeof update === "function" ? update(connectErrors) : update; + } + return connectErrors; + }, module, dispose, flush: () => microtasks.splice(0).forEach((fn) => fn()), @@ -184,7 +201,7 @@ async function setup({ socket, { child: (delta) => updates.push(delta.count) }, transports, - () => {}, + (update) => errorUpdates.push(update), {}, () => {}, { current: {} }, @@ -252,6 +269,35 @@ test("reconnect uses the assigned token and requests a full hydrate", async () = assert.equal(app.socket.current.namespaceConnects, 2); }); +test("a transport closed before the namespace connects backs off with an error", async () => { + const app = await setup({ dropConnect: true }); + app.flush(); + await app.connect(); + const socket = app.socket.current; + assert.equal(socket.namespaceConnects, 1); + assert.equal(app.connectErrors.length, 1); + assert.deepEqual([...app.timers.values()], [200]); + + const [retry] = app.timers.keys(); + app.timers.clear(); + retry(); + assert.equal(socket.namespaceConnects, 2); + assert.equal(app.connectErrors.length, 2); + assert.deepEqual([...app.timers.values()], [400]); +}); + +test("a dropped connection reconnects at once", async () => { + const app = await setup(); + app.flush(); + await app.connect(); + const socket = app.socket.current; + socket.connected = false; + socket.handlers.get("disconnect")("transport close"); + assert.equal(socket.namespaceConnects, 2); + assert.equal(app.connectErrors.length, 0); + assert.equal(app.timers.size, 0); +}); + for (const reason of ["error", "timeout", "pagehide", "hot reload"]) { test(`discard an unclaimed warm transport on ${reason}`, async () => { const app = await setup(); From 85c29229987759327505384b1d833a29fe2c3f90 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 21 Oct 2026 00:38:22 +0500 Subject: [PATCH 2/3] fix(state): keep one pending retry per failed connection attempt A transport error before the namespace connects emits connect_error and then disconnect. The disconnect reconnected at once and skipped the backoff, so a polling CONNECT rejected with an HTTP error (for example a proxy 413) still looped. The disconnect now waits for the pending retry. A connect cancels the pending retry, so a stale timer cannot reconnect after a later intentional disconnect. The integration test now asserts that at least three retries happen with growing gaps, instead of only an upper bound after a fixed 3 s wait. --- .../reflex_base/.templates/web/utils/state.js | 12 ++++- .../test_hydration_storage.py | 23 ++++++--- tests/units/compiler/state_js.test.mjs | 50 +++++++++++++++++-- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 9894580879f..fefde207572 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1003,12 +1003,16 @@ export const connect = async ( const reported_missing_substates = new Set(); // Failed connection attempts since the last connect, for the retry backoff. let failed_attempts = 0; + // The scheduled retry of the last failed attempt. + let retry_timer; // Once the socket is open, hydrate the page. socket.current.on("connect", async () => { socket.current.wait_connect = false; reported_missing_substates.clear(); failed_attempts = 0; + window.clearTimeout(retry_timer); + retry_timer = undefined; setConnectErrors([]); window.__reflex_otel?.onSocketConnect?.(); window.addEventListener("pagehide", pagehideHandler); @@ -1024,7 +1028,9 @@ export const connect = async ( socket.current.wait_connect = false; failed_attempts = Math.min(failed_attempts + 1, 10); setConnectErrors((connectErrors) => [...connectErrors.slice(-9), error]); - window.setTimeout(() => { + window.clearTimeout(retry_timer); + retry_timer = window.setTimeout(() => { + retry_timer = undefined; if (socket.current && !socket.current.connected) { socket.current.reconnect(); } @@ -1042,6 +1048,10 @@ export const connect = async ( ); return; } + // A transport error reports connect_error first, which scheduled the retry. + if (retry_timer !== undefined) { + return; + } window.__reflex_otel?.onSocketDisconnect?.(reason); const try_reconnect = reason !== "io server disconnect" && reason !== "io client disconnect"; diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 01bedf0fa52..d4ce4ca758d 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -1,7 +1,9 @@ """Browser regressions for client-storage writes during hydration and assigned defaults.""" import re +import time from collections.abc import Callable, Generator +from itertools import pairwise import pytest from playwright.sync_api import Page, WebSocketRoute, expect @@ -665,8 +667,11 @@ def test_refused_hydration_retries_with_backoff( limit: Whether the server limit or a smaller proxy limit drops the packet. """ assert hydration_storage_app.frontend_url is not None - opens: list[str] = [] - page.on("websocket", lambda ws: opens.append(ws.url)) + opens: list[float] = [] + page.on( + "websocket", + lambda ws: opens.append(time.monotonic()) if "/_event" in ws.url else None, + ) if limit == "proxy": def proxy(route: WebSocketRoute): @@ -690,11 +695,15 @@ def proxy(route: WebSocketRoute): opens.clear() page.reload() expect(page.get_by_text("Cannot connect to server")).to_be_visible() - page.wait_for_timeout(3000) - # The backoff allows about six attempts in three seconds; an immediate - # reconnect makes hundreds. - event_opens = [url for url in opens if "/_event" in url] - assert len(event_opens) < 10, event_opens + deadline = time.monotonic() + 10 + while len(opens) < 4 and time.monotonic() < deadline: + page.wait_for_timeout(100) + # Each retry waits 200 ms longer than the one before; an immediate + # reconnect retries within a few ms. + gaps = [b - a for a, b in pairwise(opens[:4])] + assert len(gaps) == 3, gaps + assert all(gap > 0.15 for gap in gaps), gaps + assert gaps[0] < gaps[2], gaps expect(page.locator("#hydrated")).to_have_text("false") page.evaluate("localStorage.removeItem('hydrate-local')") diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index 97b3e3025c0..b1ea4ae8bb1 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -19,7 +19,8 @@ async function setup({ hidden = false, storedToken = "original-token", clientStorage = {}, - dropConnect = false, + dropConnects = 0, + dropReason = "transport close", react = { useCallback() {}, useEffect() {}, useRef() {}, useState() {} }, } = {}) { const sockets = []; @@ -61,6 +62,7 @@ async function setup({ clearTimeout: (fn) => timers.delete(fn), }); window.setTimeout = context.setTimeout; + window.clearTimeout = context.clearTimeout; function io(url, opts) { const handlers = new Map(); @@ -94,9 +96,13 @@ async function setup({ assert.ok(handlers.has(name), `missing handler: ${name}`); } this.namespaceConnects++; - if (dropConnect) { - // The server closes the transport before acknowledging the CONNECT. - handlers.get("disconnect")("transport close"); + if (this.namespaceConnects <= dropConnects) { + // The transport closes before the CONNECT is acknowledged. A + // transport error reports connect_error before the disconnect. + if (dropReason === "transport error") { + handlers.get("connect_error")(new Error("xhr post error")); + } + handlers.get("disconnect")(dropReason); return; } this.connected = true; @@ -270,7 +276,7 @@ test("reconnect uses the assigned token and requests a full hydrate", async () = }); test("a transport closed before the namespace connects backs off with an error", async () => { - const app = await setup({ dropConnect: true }); + const app = await setup({ dropConnects: Infinity }); app.flush(); await app.connect(); const socket = app.socket.current; @@ -286,6 +292,40 @@ test("a transport closed before the namespace connects backs off with an error", assert.deepEqual([...app.timers.values()], [400]); }); +test("a transport error before the namespace connects retries once with backoff", async () => { + const app = await setup({ dropConnects: 2, dropReason: "transport error" }); + app.flush(); + await app.connect(); + const socket = app.socket.current; + assert.equal(socket.namespaceConnects, 1); + assert.equal(app.connectErrors.length, 1); + assert.deepEqual([...app.timers.values()], [200]); + + const [retry] = app.timers.keys(); + app.timers.clear(); + retry(); + assert.equal(socket.namespaceConnects, 2); + assert.deepEqual([...app.timers.values()], [400]); +}); + +test("a connect cancels the pending retry", async () => { + const app = await setup({ dropConnects: 1 }); + app.flush(); + await app.connect(); + const socket = app.socket.current; + assert.equal(app.timers.size, 1); + + // Another path, such as the page becoming visible, reconnects first. + socket.reconnect(); + assert.equal(socket.connected, true); + assert.equal(app.timers.size, 0); + + socket.connected = false; + socket.handlers.get("disconnect")("io server disconnect"); + assert.equal(socket.namespaceConnects, 2); + assert.equal(app.timers.size, 0); +}); + test("a dropped connection reconnects at once", async () => { const app = await setup(); app.flush(); From 662cada158282cb262990108b09710fd7af51bc3 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 20 Oct 2026 12:48:35 -0700 Subject: [PATCH 3/3] test: expose clearTimeout in state update browser stub --- tests/units/reflex_base/client_state_updates.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/units/reflex_base/client_state_updates.mjs b/tests/units/reflex_base/client_state_updates.mjs index 9302dae924a..e873755a59f 100644 --- a/tests/units/reflex_base/client_state_updates.mjs +++ b/tests/units/reflex_base/client_state_updates.mjs @@ -65,6 +65,7 @@ async function createConnection( dependencies: { "test:browser": { window: { + clearTimeout, location: { pathname: "/", search: "",