[grade=B] fix: stop aggressive API polling and SSE reconnect when auth lost
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

When unauthenticated (TOTP gate active), the dashboard was polling
/api/v1/services/status every few seconds and reconnecting SSE every
2-8s indefinitely. Now: 401/403 sets _dcAuthLost flag to skip the
polling interval entirely (no fallback to direct probes which
misleadingly mark services as healthy), SSE exposes _sseClose()
coordinated with _dcAuthLost, and queued refreshes are guarded.
This commit is contained in:
Hermes
2026-08-13 14:43:53 -07:00
parent 89968f5485
commit 29eedb3515
3 changed files with 110 additions and 86 deletions
+77 -77
View File
File diff suppressed because one or more lines are too long
+14 -3
View File
@@ -349,6 +349,9 @@
} }
async function refreshAll() { async function refreshAll() {
// Skip if auth has been lost (e.g. TOTP gate activated externally).
// The polling interval in init.js also checks this flag.
if (window._dcAuthLost) return;
if (refreshInFlight) { if (refreshInFlight) {
refreshQueued = true; refreshQueued = true;
return refreshInFlight; return refreshInFlight;
@@ -402,9 +405,15 @@
try { try {
const response = await fetch('/api/v1/services/status', { cache: 'no-store' }); const response = await fetch('/api/v1/services/status', { cache: 'no-store' });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
// Auth lost — stop the polling loop from hammering every few seconds // Auth lost — stop the polling loop and close SSE; do NOT fall
// through to direct probes (those would misleadingly mark
// services as healthy since /probe/ treats 401/403 as "up").
window._dcAuthLost = true; window._dcAuthLost = true;
throw new Error(`Authentication required (${response.status})`); if (window._sseReconnect && window._sseClose) {
window._sseClose(); // tell SSE to stop reconnecting
}
updateStamp('auth required');
return; // skip the fallback entirely
} }
if (!response.ok) { if (!response.ok) {
throw new Error(`Status refresh failed (${response.status})`); throw new Error(`Status refresh failed (${response.status})`);
@@ -424,9 +433,11 @@
} }
} finally { } finally {
refreshInFlight = null; refreshInFlight = null;
if (refreshQueued) { if (refreshQueued && !window._dcAuthLost) {
refreshQueued = false; refreshQueued = false;
setTimeout(() => { window.refreshAll(); }, 0); setTimeout(() => { window.refreshAll(); }, 0);
} else {
refreshQueued = false;
} }
} }
})(); })();
+19 -6
View File
@@ -3,9 +3,12 @@
let es = null; let es = null;
let reconnectDelay = 1000; let reconnectDelay = 1000;
const MAX_RECONNECT = 30000; const MAX_RECONNECT = 30000;
let _sseFailCount = 0;
let _sseManuallyClosed = false;
function connect() { function connect() {
if (es) { try { es.close(); } catch (_) {} } if (es) { try { es.close(); } catch (_) {} }
if (_sseManuallyClosed) return; // auth-lost: don't reconnect
es = new EventSource('/api/v1/events/stream'); es = new EventSource('/api/v1/events/stream');
@@ -102,11 +105,16 @@
// Reconnect on error // Reconnect on error
es.onerror = () => { es.onerror = () => {
es.close(); es.close();
// Stop reconnecting entirely after multiple consecutive failures // If auth was explicitly lost (401/403 from the polling loop),
// (likely auth-gate / session expired — no point hammering forever) // don't attempt reconnection at all.
_sseFailCount = (_sseFailCount || 0) + 1; if (window._dcAuthLost || _sseManuallyClosed) {
console.warn('[SSE] Auth lost — stopping reconnection');
return;
}
// Transient failures: retry with exponential backoff, stop after 5
_sseFailCount++;
if (_sseFailCount > 5) { if (_sseFailCount > 5) {
console.warn('[SSE] Max reconnect attempts reached — stopping (auth gate active or server unreachable)'); console.warn('[SSE] Max reconnect attempts reached — stopping (server unreachable)');
return; return;
} }
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`); console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
@@ -115,11 +123,16 @@
}; };
} }
let _sseFailCount = 0; // Called by grid.js when the polling loop detects auth loss (401/403)
function closeAndStop() {
_sseManuallyClosed = true;
if (es) { try { es.close(); } catch (_) {} }
}
// Start on page load // Start on page load
connect(); connect();
// Expose for debugging // Expose for debugging and cross-module coordination
window._sseReconnect = connect; window._sseReconnect = connect;
window._sseClose = closeAndStop;
})(); })();