[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() {
// 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) {
refreshQueued = true;
return refreshInFlight;
@@ -402,9 +405,15 @@
try {
const response = await fetch('/api/v1/services/status', { cache: 'no-store' });
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;
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) {
throw new Error(`Status refresh failed (${response.status})`);
@@ -424,9 +433,11 @@
}
} finally {
refreshInFlight = null;
if (refreshQueued) {
if (refreshQueued && !window._dcAuthLost) {
refreshQueued = false;
setTimeout(() => { window.refreshAll(); }, 0);
} else {
refreshQueued = false;
}
}
})();
+19 -6
View File
@@ -3,9 +3,12 @@
let es = null;
let reconnectDelay = 1000;
const MAX_RECONNECT = 30000;
let _sseFailCount = 0;
let _sseManuallyClosed = false;
function connect() {
if (es) { try { es.close(); } catch (_) {} }
if (_sseManuallyClosed) return; // auth-lost: don't reconnect
es = new EventSource('/api/v1/events/stream');
@@ -102,11 +105,16 @@
// Reconnect on error
es.onerror = () => {
es.close();
// Stop reconnecting entirely after multiple consecutive failures
// (likely auth-gate / session expired — no point hammering forever)
_sseFailCount = (_sseFailCount || 0) + 1;
// If auth was explicitly lost (401/403 from the polling loop),
// don't attempt reconnection at all.
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) {
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;
}
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
connect();
// Expose for debugging
// Expose for debugging and cross-module coordination
window._sseReconnect = connect;
window._sseClose = closeAndStop;
})();