From 8f0fe9783a12803f3628a0abdfeaf8408d7512c8 Mon Sep 17 00:00:00 2001 From: Giorgio Date: Wed, 14 Oct 2026 15:05:52 +0200 Subject: [PATCH] fix(web): suppress false offline errors and refetch data on tab resume After a tab sat in the background for a long time, returning to it fired a burst of requests on stale keep-alive connections (ERR_CONNECTION_CLOSED / 503 from the proxy), and the one-shot health check declared the server offline even though it was healthy. - Add an 8s resume grace window that suppresses connectivity toasts while the reconnect burst settles (callers still receive the error responses) - Replace the single /api/health probe on visibilitychange with up to 3 backoff probes (0s/2s/5s) before showing the offline banner - Dispatch a debounced tt:tab-resumed event; floating timer bar and smart notifications refetch authoritative data on it - Floating timer bar retries a failed status fetch once (network errors surface as synthetic 503 responses via the fetch interceptor) --- app/static/error-handling-enhanced.js | 77 ++++++++++++++++++++++++--- app/static/floating-timer-bar.js | 22 ++++++++ app/static/smart-notifications.js | 1 + tests/test_reconnect_on_resume.py | 12 +++-- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/app/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js index b43fb33c5..b2ce2924c 100644 --- a/app/static/error-handling-enhanced.js +++ b/app/static/error-handling-enhanced.js @@ -56,14 +56,10 @@ class EnhancedErrorHandler { // Re-probe when the tab becomes active again (background timers may have gone stale) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { + this.beginResumeGrace(); this.startHealthProbeInterval(); - this.checkOnlineStatus({ quiet: true, onResume: true }); - // Second probe to sweep up errors from resuming background requests - setTimeout(() => { - if (document.visibilityState === 'visible') { - this.checkOnlineStatus({ quiet: true, onResume: true }); - } - }, 1500); + this.probeAfterResume(); + this.dispatchTabResumed(); } else { this.stopHealthProbeInterval(); } @@ -81,6 +77,56 @@ class EnhancedErrorHandler { } } + // When the tab wakes up, requests race a burst of reconnects and stale + // keep-alive sockets. Treat failures inside this window as transient: + // no error toasts, and a later probe clears the state automatically. + static RESUME_GRACE_MS = 8000; + static TAB_RESUMED_DEBOUNCE_MS = 1500; + + beginResumeGrace() { + this._resumeGraceUntil = Date.now() + EnhancedErrorHandler.RESUME_GRACE_MS; + } + + inResumeGraceWindow() { + return Boolean(this._resumeGraceUntil) && Date.now() < this._resumeGraceUntil; + } + + // Probe /api/health several times with short backoff before declaring the + // server offline — the first probe right after resume often fails on a + // dead keep-alive connection even when the server is healthy. + probeAfterResume(attempts = [0, 2000, 5000]) { + if (this._resumeProbeToken === undefined) this._resumeProbeToken = 0; + const token = ++this._resumeProbeToken; + + const attemptAt = (delays) => { + if (token !== this._resumeProbeToken) return; + const [delay, ...rest] = delays; + setTimeout(() => { + if (token !== this._resumeProbeToken || document.visibilityState !== 'visible') return; + this.checkOnlineStatus({ quiet: true, onResume: true }).then((healthy) => { + if (token !== this._resumeProbeToken) return; + if (healthy || rest.length === 0) return; + attemptAt(rest); + }); + }, delay); + }; + attemptAt(attempts); + } + + // Single debounced signal so widgets (timer bar, notifications) can + // refetch authoritative data once the reconnect burst has settled. + dispatchTabResumed() { + if (this._tabResumedTimer) { + clearTimeout(this._tabResumedTimer); + } + this._tabResumedTimer = setTimeout(() => { + this._tabResumedTimer = null; + if (document.visibilityState === 'visible') { + document.dispatchEvent(new CustomEvent('tt:tab-resumed')); + } + }, EnhancedErrorHandler.TAB_RESUMED_DEBOUNCE_MS); + } + startHealthProbeInterval() { if (this._healthProbeTimer) return; this._healthProbeTimer = window.setInterval(() => { @@ -371,6 +417,12 @@ class EnhancedErrorHandler { } async handleFetchError(response, url, options) { + // Transient proxy errors right after tab resume: the server is usually + // fine again within seconds, so don't alarm the user. + if (this.inResumeGraceWindow() && [502, 503, 504].includes(response.status)) { + console.debug('Suppressed transient error during tab-resume grace window:', response.status, url); + return response; + } // Clone the response before reading it, so the caller can still read it const clonedResponse = response.clone(); const errorData = await clonedResponse.json().catch(() => ({ error: 'Unknown error' })); @@ -403,6 +455,17 @@ class EnhancedErrorHandler { }); } + // Network error on a stale keep-alive connection during the resume + // window: callers still get a non-ok response, but no toast — the + // resume probes will declare offline if the server is really gone. + if (this.inResumeGraceWindow()) { + console.debug('Suppressed network error during tab-resume grace window:', url); + return new Response(JSON.stringify({ error: 'Reconnecting' }), { + status: 503, + statusText: 'Reconnecting' + }); + } + const userFriendlyMessage = this.getUserFriendlyMessage(0, error); const errorId = this.showErrorWithRetry(userFriendlyMessage, 0, () => { return this.retryFetch(url, options); diff --git a/app/static/floating-timer-bar.js b/app/static/floating-timer-bar.js index cf0a54906..e4843bdda 100644 --- a/app/static/floating-timer-bar.js +++ b/app/static/floating-timer-bar.js @@ -54,6 +54,7 @@ this.lastFocusFetch = now; this.fetchStatus(); }); + document.addEventListener('tt:tab-resumed', () => this.fetchStatus()); document.addEventListener('click', (evt) => { if (!this.switchPopover || this.switchPopover.classList.contains('hidden')) return; if (this.switchPopover.contains(evt.target)) return; @@ -177,6 +178,13 @@ return; } if (!res.ok) { + // The fetch interceptor converts network errors (stale + // keep-alive connection after tab resume) into synthetic + // 503 responses, so retry those the same as real failures. + if ([502, 503, 504].includes(res.status) && !this._statusRetried) { + this.retryStatusFetchOnce(); + return; + } console.warn('FloatingTimerBar: fetch status failed', res.status); return; } @@ -195,10 +203,24 @@ } syncFabDesktopHide(this.timerData); } catch (e) { + if (!this._statusRetried) { + this.retryStatusFetchOnce(); + return; + } console.warn('FloatingTimerBar: fetch status failed', e); } } + retryStatusFetchOnce() { + // Right after tab resume the first fetch can land on a dead + // keep-alive connection; one delayed retry recovers transparently. + this._statusRetried = true; + setTimeout(() => { + this._statusRetried = false; + this.fetchStatus(); + }, 1500); + } + startElapsedUpdater() { this.stopElapsedUpdater(); const update = () => { diff --git a/app/static/smart-notifications.js b/app/static/smart-notifications.js index 12fc9321b..a79773a60 100644 --- a/app/static/smart-notifications.js +++ b/app/static/smart-notifications.js @@ -314,6 +314,7 @@ class SmartNotificationManager { try { setTimeout(() => this.pollServerSmartNotifications(), 12000); setInterval(() => this.pollServerSmartNotifications(), this._serverSmartPollMs); + document.addEventListener('tt:tab-resumed', () => this.pollServerSmartNotifications()); } catch (e) { console.error('[SmartNotifications] server poll init:', e); } diff --git a/tests/test_reconnect_on_resume.py b/tests/test_reconnect_on_resume.py index c0707f2c2..8a1cfc9d4 100644 --- a/tests/test_reconnect_on_resume.py +++ b/tests/test_reconnect_on_resume.py @@ -27,11 +27,13 @@ def test_error_handler_quiet_health_and_visibility_reconnect(): assert "startHealthProbeInterval" in content assert "/api/health" in content assert "if (quiet)" in content - # Delayed probes on resume sweep errors from background fetches (#703 / #746) - assert "setTimeout" in content - assert "1500" in content - assert "5000" in content - assert content.count("checkOnlineStatus({ quiet: true, onResume: true })") >= 3 + # Resume probes retry with backoff before declaring the server offline, + # and transient failures during the grace window are toast-suppressed + assert "probeAfterResume" in content + assert "beginResumeGrace" in content + assert "inResumeGraceWindow" in content + # Debounced signal for widgets to refetch after the reconnect burst settles + assert "tt:tab-resumed" in content def test_idle_background_fetches_are_quiet():