From 9528107e7b0dad93386e89d666b2dca63b1f0f3e Mon Sep 17 00:00:00 2001 From: Jenks Date: Wed, 8 Jul 2026 17:04:24 +1000 Subject: [PATCH] fix(chat-widget): retry health check so the widget doesn't vanish on a blip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI chat entry points (floating bubble + header button) were gated on a single, no-retry /health fetch that defaults to hidden and fails closed: one slow/failed/timed-out response (a cold pod, a 503 during warmup, a rolling deploy, or any transient) left `isApiHealthy=false` and the whole widget rendered null until a full page reload. This is the "sometimes it doesn't load at all" symptom. Now the health check retries on a front-loaded schedule — attempts at 0, +2s, +7s, +20s, +50s, +110s (6 checks within ~110s, under 2 min), then a slow 60s poll while still unhealthy — and stops immediately once /health responds. Also re-checks on tab focus (visibilitychange) so a pod that recovers while the tab was backgrounded un-hides the widget without a reload. Per-attempt timeout tightened 5s -> 4s; retries cancelled on unmount. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/ChatWidget.tsx | 65 +++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx index 0aa65daa..00ba3be0 100644 --- a/src/components/ChatWidget.tsx +++ b/src/components/ChatWidget.tsx @@ -193,31 +193,68 @@ export default function ChatWidget() { } }; - const checkHealth = async () => { + // Health check with retry: dense at the start, backing off over ~2 minutes, + // then a slow steady poll so a recovered API un-hides the widget without a reload. + // Attempt at t=0, then +2s, +5s, +13s, +30s, +60s → 5 retries within ~110s (< 2 min). + const RETRY_SCHEDULE = [2000, 5000, 13000, 30000, 60000]; + const STEADY_POLL_MS = 60000; + + let cancelled = false; + let retryTimer: ReturnType | null = null; + let lastHealthy = false; + // Generation guard: each (re)start bumps `generation`; an attempt whose gen is + // stale bails out after its fetch resolves so a focus-triggered restart can't + // leave an orphaned polling chain running in parallel with the current one. + let generation = 0; + + const attempt = async (n: number, gen: number) => { + if (cancelled || gen !== generation) return; + const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - + const timeoutId = setTimeout(() => controller.abort(), 4000); + let healthy = false; try { const response = await fetch(`${apiBaseUrl}/health`, { signal: controller.signal }); - clearTimeout(timeoutId); - - const healthy = response.ok; - setIsApiHealthy(healthy); - setButtonVisible(healthy); + healthy = response.ok; } catch (error) { + console.error(`Health check failed (attempt ${n}):`, error); + } finally { clearTimeout(timeoutId); - console.error('Health check failed:', error); - setIsApiHealthy(false); - setButtonVisible(false); } + if (cancelled || gen !== generation) return; + + lastHealthy = healthy; + setIsApiHealthy(healthy); + setButtonVisible(healthy); + if (healthy) return; // stop retrying once the API responds + + const delay = n < RETRY_SCHEDULE.length ? RETRY_SCHEDULE[n] : STEADY_POLL_MS; + retryTimer = setTimeout(() => attempt(n + 1, gen), delay); }; - checkHealth(); + attempt(0, generation); - // Cleanup: remove style tag when component unmounts (optional, keeps it clean) - // Note: We don't remove it because we want the button to stay visible across navigations + // Re-check when the tab regains focus, so a pod that recovered while the tab + // was backgrounded shows the widget without requiring a page reload. + const onVisible = () => { + if (!document.hidden && !lastHealthy) { + generation++; // invalidate any in-flight / scheduled chain + if (retryTimer) clearTimeout(retryTimer); + attempt(0, generation); + } + }; + document.addEventListener('visibilitychange', onVisible); + + // Cleanup: cancel pending retries + listener on unmount. + // Note: we deliberately keep the injected style tag so the button stays + // visible across client-side navigations. + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + document.removeEventListener('visibilitychange', onVisible); + }; }, [apiBaseUrl]); // Fetch token limits on mount (only if API is healthy)