From 9c14c13752e893eff4b35ef7363109a287733170 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Wed, 20 May 2026 09:02:22 +0530 Subject: [PATCH] fix(oauth): surface backend outage instead of opening browser to 504 (#1985) (#2147) Co-authored-by: Steven Enamakel --- .../components/oauth/OAuthProviderButton.tsx | 79 +++++++- .../__tests__/OAuthProviderButton.test.tsx | 189 ++++++++++++++++-- .../services/__tests__/backendHealth.test.ts | 119 +++++++++++ app/src/services/backendHealth.ts | 78 ++++++++ app/test/OAuthDiscord.test.tsx | 18 +- app/test/OAuthGitHub.test.tsx | 18 +- app/test/OAuthLoginSection.test.tsx | 18 +- app/test/OAuthTwitter.test.tsx | 18 +- 8 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 app/src/services/__tests__/backendHealth.test.ts create mode 100644 app/src/services/backendHealth.ts diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index edaf3ac43..767f120de 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; -import { getBackendUrl } from '../../services/backendUrl'; +import { checkBackendHealthy } from '../../services/backendHealth'; import { getDeepLinkAuthState } from '../../store/deepLinkAuthState'; import type { OAuthProviderConfig } from '../../types/oauth'; import { IS_DEV } from '../../utils/config'; @@ -20,6 +20,15 @@ interface OAuthProviderButtonProps { // redirect fails so the `openhuman://` deep link never fires. const OAUTH_LOADING_TIMEOUT_MS = 90_000; +// Pre-flight budget for `/health` before we open the system browser. Kept +// short so a healthy backend adds barely any perceptible click→browser delay, +// while an outage (Cloudflare 504, DNS, offline) is caught fast enough that +// the user never sees the broken provider page. +const OAUTH_PREFLIGHT_TIMEOUT_MS = 4_000; + +const BACKEND_UNAVAILABLE_MESSAGE = + 'OpenHuman cloud sign-in is temporarily unavailable. Please try again in a few minutes.'; + const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig): string => { if (provider.id === 'twitter') { return 'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.'; @@ -51,12 +60,50 @@ const OAuthProviderButton = ({ const { t } = useT(); const [isLoading, setIsLoading] = useState(false); const [startupError, setStartupError] = useState(null); + // Tracks whether the user actually got dispatched to the system browser on + // this attempt. Lets the focus/visibility handlers distinguish "user came + // back from the browser" (probe for backend health) from "click never even + // reached openUrl" (no probe needed — we already set a startup error). + const browserOpenedRef = useRef(false); useEffect(() => { if (!isLoading) return; const reset = () => setIsLoading(false); + // Confirm backend health when the user returns without a deep-link + // callback. Healthy → silent reset (user just cancelled in the browser). + // Unhealthy → surface a clear banner so the user understands why the + // browser landed on an error page (issue #1985). + const probeBackendOnReturn = (label: string) => { + if (!browserOpenedRef.current) return; + // Consume the flag so the second of a focus/visibilitychange pair (macOS + // can fire both back-to-back when returning from the system browser) + // becomes a no-op instead of triggering a redundant concurrent probe. + browserOpenedRef.current = false; + void checkBackendHealthy() + .then(result => { + if (!result.healthy) { + console.warn(`[oauth-button][${provider.id}] ${label} probe → backend unhealthy`, { + reason: result.reason, + latencyMs: result.latencyMs, + status: 'status' in result ? result.status : undefined, + }); + setStartupError(BACKEND_UNAVAILABLE_MESSAGE); + } else { + console.debug(`[oauth-button][${provider.id}] ${label} probe → backend healthy`, { + status: result.status, + latencyMs: result.latencyMs, + }); + } + }) + .catch(err => { + // checkBackendHealthy already swallows network/abort errors and + // turns them into a result; reaching this branch is unexpected. + console.debug(`[oauth-button][${provider.id}] ${label} probe threw`, err); + }); + }; + // Skip reset when a deep-link auth round-trip is already in flight — the // OAuth callback flips `isProcessing=true` AFTER the OS focus event fires, // and resetting first would briefly re-enable the button mid-redirect. @@ -74,6 +121,7 @@ const OAuthProviderButton = ({ if (skipDuringDeepLink('focus')) return; console.debug(`[oauth-button][${provider.id}] window focus → reset isLoading`); reset(); + probeBackendOnReturn('focus'); }; // Backup path: macOS Spaces / virtual desktops sometimes restore window @@ -84,11 +132,15 @@ const OAuthProviderButton = ({ if (skipDuringDeepLink('visibilitychange')) return; console.debug(`[oauth-button][${provider.id}] visibilitychange visible → reset isLoading`); reset(); + probeBackendOnReturn('visibilitychange'); }; const timer = window.setTimeout(() => { console.debug(`[oauth-button][${provider.id}] timeout → reset isLoading`); reset(); + // 90s with no deep-link is a strong "something went wrong" signal even + // if the user never refocused the app. Probe so we can attribute it. + probeBackendOnReturn('timeout'); }, OAUTH_LOADING_TIMEOUT_MS); window.addEventListener('focus', handleFocus); @@ -113,9 +165,29 @@ const OAuthProviderButton = ({ setStartupError(null); setIsLoading(true); + browserOpenedRef.current = false; + + // Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening + // the browser lets us catch Cloudflare 504s / DNS outages immediately + // (issue #1985) instead of sending the user into a system browser that + // lands on a gateway-error page with no path back into the app. + const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS }); + if (!preflight.healthy) { + console.warn(`[oauth-button][${provider.id}] preflight → backend unhealthy`, { + reason: preflight.reason, + latencyMs: preflight.latencyMs, + status: 'status' in preflight ? preflight.status : undefined, + }); + setStartupError(BACKEND_UNAVAILABLE_MESSAGE); + setIsLoading(false); + return; + } try { - const backendUrl = await getBackendUrl(); + // Reuse the URL the preflight already resolved — `getBackendUrl()` may + // hit a Tauri IPC round-trip and the result hasn't changed within a + // single click handler. + const backendUrl = preflight.backendUrl; const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`; if (IS_DEV) { @@ -133,6 +205,7 @@ const OAuthProviderButton = ({ // Web fallback: direct OAuth flow in current window window.location.href = loginUrl; } + browserOpenedRef.current = true; } catch (error) { const message = getOAuthStartupFailureMessage(provider); console.error(`[oauth-button][${provider.id}] OAuth startup failed`, { diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index 5e2e8a428..657c29caa 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -1,13 +1,13 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getBackendUrl } from '../../../services/backendUrl'; +import { checkBackendHealthy } from '../../../services/backendHealth'; import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState'; import { openUrl } from '../../../utils/openUrl'; import { isTauri } from '../../../utils/tauriCommands'; import OAuthProviderButton from '../OAuthProviderButton'; -vi.mock('../../../services/backendUrl', () => ({ getBackendUrl: vi.fn() })); +vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: vi.fn() })); vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); @@ -29,10 +29,17 @@ const stubProvider = { const twitterProvider = { ...stubProvider, id: 'twitter' as const, name: 'Twitter' }; +const healthyResult = { + healthy: true as const, + status: 200, + latencyMs: 12, + backendUrl: 'https://backend.test', +}; + describe('OAuthProviderButton', () => { beforeEach(() => { vi.useFakeTimers(); - vi.mocked(getBackendUrl).mockResolvedValue('https://backend.test'); + vi.mocked(checkBackendHealthy).mockResolvedValue(healthyResult); vi.mocked(openUrl).mockResolvedValue(undefined); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(getDeepLinkAuthState).mockReturnValue({ @@ -55,11 +62,11 @@ describe('OAuthProviderButton', () => { // Drain the microtasks queued by the async click handler so openUrl resolves. await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); - expect(getBackendUrl).toHaveBeenCalledTimes(1); expect(openUrl).toHaveBeenCalledWith( expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/) ); @@ -71,8 +78,9 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Google' })); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); expect(screen.getByText('Connecting...')).toBeInTheDocument(); @@ -96,8 +104,9 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Google' })); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); expect(screen.getByText('Connecting...')).toBeInTheDocument(); @@ -115,8 +124,9 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Google' })); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); expect(screen.getByText('Connecting...')).toBeInTheDocument(); @@ -138,8 +148,9 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Google' })); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); expect(screen.getByText('Connecting...')).toBeInTheDocument(); @@ -160,7 +171,7 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Google' })); expect(override).toHaveBeenCalledTimes(1); - expect(getBackendUrl).not.toHaveBeenCalled(); + expect(checkBackendHealthy).not.toHaveBeenCalled(); expect(openUrl).not.toHaveBeenCalled(); expect(screen.queryByText('Connecting...')).not.toBeInTheDocument(); }); @@ -173,11 +184,12 @@ describe('OAuthProviderButton', () => { fireEvent.click(button); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); - expect(getBackendUrl).toHaveBeenCalledTimes(1); + expect(checkBackendHealthy).toHaveBeenCalledTimes(1); expect(openUrl).toHaveBeenCalledTimes(1); }); @@ -191,8 +203,9 @@ describe('OAuthProviderButton', () => { fireEvent.click(screen.getByRole('button', { name: 'Twitter' })); await act(async () => { - await Promise.resolve(); - await Promise.resolve(); + // Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl + // → openUrl, plus any internal `.then` chains. + for (let i = 0; i < 6; i++) await Promise.resolve(); }); expect(screen.getByRole('alert')).toHaveTextContent( @@ -209,4 +222,140 @@ describe('OAuthProviderButton', () => { }) ); }); + + // --- Pre-flight + post-failure backend health probe (issue #1985) --- + + it.each([ + [ + 'http-5xx', + { healthy: false as const, reason: 'http-5xx' as const, status: 504, latencyMs: 1234 }, + ], + ['timeout', { healthy: false as const, reason: 'timeout' as const, latencyMs: 4000 }], + ['network', { healthy: false as const, reason: 'network' as const, latencyMs: 5 }], + [ + 'resolve-failure', + { healthy: false as const, reason: 'resolve-failure' as const, latencyMs: 0 }, + ], + ])( + 'pre-flight reason=%s blocks openUrl and shows the "temporarily unavailable" banner', + async (_label, preflightResult) => { + vi.mocked(checkBackendHealthy).mockResolvedValue(preflightResult); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + expect(openUrl).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent( + /OpenHuman cloud sign-in is temporarily unavailable/i + ); + expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled(); + } + ); + + it('does NOT trigger a post-return probe when pre-flight blocked browser launch', async () => { + vi.mocked(checkBackendHealthy).mockResolvedValueOnce({ + healthy: false, + reason: 'http-5xx', + status: 504, + latencyMs: 1500, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + // Pre-flight ran exactly once for the click — the focus/visibility + // handlers should NOT probe again because the browser was never opened. + expect(checkBackendHealthy).toHaveBeenCalledTimes(1); + + // Even if the user happens to re-focus the window (e.g. they alt-tabbed), + // we must not fire an additional probe — there's nothing for the user to + // have returned from. + // + // The focus listener is attached only while isLoading=true, which the + // pre-flight failure already cleared. So this is a regression guard: + await act(async () => { + window.dispatchEvent(new FocusEvent('focus')); + for (let i = 0; i < 4; i++) await Promise.resolve(); + }); + expect(checkBackendHealthy).toHaveBeenCalledTimes(1); + }); + + it('after browser-return focus, surfaces the banner when the backend is unhealthy', async () => { + // Happy pre-flight so the browser opens and the focus listener gets armed. + vi.mocked(checkBackendHealthy) + .mockResolvedValueOnce(healthyResult) + .mockResolvedValueOnce({ healthy: false, reason: 'http-5xx', status: 504, latencyMs: 800 }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + expect(openUrl).toHaveBeenCalledTimes(1); + + // User comes back from the browser without a deep-link round-trip. + await act(async () => { + window.dispatchEvent(new FocusEvent('focus')); + // Drain microtasks for the background probe's then-chain. + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + expect(checkBackendHealthy).toHaveBeenCalledTimes(2); + expect(screen.getByRole('alert')).toHaveTextContent( + /OpenHuman cloud sign-in is temporarily unavailable/i + ); + }); + + it('after browser-return focus, stays silent when the backend is healthy (user cancelled)', async () => { + vi.mocked(checkBackendHealthy).mockResolvedValue(healthyResult); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + await act(async () => { + window.dispatchEvent(new FocusEvent('focus')); + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + expect(checkBackendHealthy).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows the unavailable banner if the 90s timeout fires while the backend is down', async () => { + vi.mocked(checkBackendHealthy) + .mockResolvedValueOnce(healthyResult) + .mockResolvedValueOnce({ healthy: false, reason: 'timeout', latencyMs: 6000 }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + expect(openUrl).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(90_000); + // After the safety timer fires we kick off probeBackendOnReturn(). + // Drain enough microtasks for that async probe to resolve and its + // .then() to flush the alert into the DOM. + for (let i = 0; i < 12; i++) await Promise.resolve(); + }); + + expect(checkBackendHealthy).toHaveBeenCalledTimes(2); + expect(screen.getByRole('alert')).toHaveTextContent( + /OpenHuman cloud sign-in is temporarily unavailable/i + ); + }); }); diff --git a/app/src/services/__tests__/backendHealth.test.ts b/app/src/services/__tests__/backendHealth.test.ts new file mode 100644 index 000000000..6af5ced78 --- /dev/null +++ b/app/src/services/__tests__/backendHealth.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BACKEND_HEALTH_TIMEOUT_MS, checkBackendHealthy } from '../backendHealth'; +import { getBackendUrl } from '../backendUrl'; + +// Local explicit mock — overrides the global one in app/src/test/setup.ts so +// this suite controls `getBackendUrl()` behavior end-to-end (including the +// rejection case for `resolve-failure`) without depending on the global +// mock's resolved value. +vi.mock('../backendUrl', () => ({ getBackendUrl: vi.fn() })); + +const mockedGetBackendUrl = vi.mocked(getBackendUrl); + +function makeResponse(status: number): Response { + return new Response(JSON.stringify({ status: 'ok' }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('checkBackendHealthy', () => { + beforeEach(() => { + mockedGetBackendUrl.mockResolvedValue('https://backend.test'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns healthy when GET /health responds 200', async () => { + const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200)); + + const result = await checkBackendHealthy({ fetchImpl }); + + expect(result.healthy).toBe(true); + if (result.healthy) { + expect(result.status).toBe(200); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + } + expect(fetchImpl).toHaveBeenCalledWith( + 'https://backend.test/health', + expect.objectContaining({ method: 'GET', cache: 'no-store', credentials: 'omit' }) + ); + }); + + it('treats 5xx upstream errors (Cloudflare 504) as unhealthy with reason http-5xx', async () => { + const fetchImpl = vi.fn().mockResolvedValue(makeResponse(504)); + + const result = await checkBackendHealthy({ fetchImpl }); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.reason).toBe('http-5xx'); + expect(result.status).toBe(504); + } + }); + + it('treats 4xx as healthy — the backend is at least reachable', async () => { + // /health may not exist in every environment; a 404 still proves the + // edge + origin are answering. Only 5xx + network failures count as + // "service down" for the OAuth banner. + const fetchImpl = vi.fn().mockResolvedValue(makeResponse(404)); + + const result = await checkBackendHealthy({ fetchImpl }); + + expect(result.healthy).toBe(true); + }); + + it('returns reason=timeout when the fetch aborts after the timeout budget', async () => { + const fetchImpl: typeof fetch = vi.fn((_input, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + // Mimic real fetch: reject with an AbortError when the caller's + // AbortSignal fires. + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }); + }); + }); + + const result = await checkBackendHealthy({ fetchImpl, timeoutMs: 20 }); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.reason).toBe('timeout'); + } + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('returns reason=network when fetch rejects (DNS / offline / CORS / TLS)', async () => { + const fetchImpl = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await checkBackendHealthy({ fetchImpl }); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.reason).toBe('network'); + } + }); + + it('returns reason=resolve-failure when getBackendUrl throws', async () => { + mockedGetBackendUrl.mockRejectedValueOnce(new Error('Core returned an empty backend URL')); + const fetchImpl = vi.fn(); + + const result = await checkBackendHealthy({ fetchImpl }); + + expect(result.healthy).toBe(false); + if (!result.healthy) { + expect(result.reason).toBe('resolve-failure'); + } + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('exposes a default timeout budget that is short enough for a click handler', () => { + // Pre-flight runs inline on OAuth button click — a multi-second default + // would feel like a stuck UI when the backend is slow. Locking the + // public default to <= 6s catches drift. + expect(BACKEND_HEALTH_TIMEOUT_MS).toBeLessThanOrEqual(6_000); + }); +}); diff --git a/app/src/services/backendHealth.ts b/app/src/services/backendHealth.ts new file mode 100644 index 000000000..093a7d3d8 --- /dev/null +++ b/app/src/services/backendHealth.ts @@ -0,0 +1,78 @@ +import { getBackendUrl } from './backendUrl'; + +export const BACKEND_HEALTH_TIMEOUT_MS = 6_000; + +export type BackendHealthFailureReason = + | 'timeout' // AbortError after BACKEND_HEALTH_TIMEOUT_MS + | 'network' // fetch rejected before any HTTP response (DNS, CORS, offline, TLS) + | 'http-5xx' // upstream/edge returned a 5xx (e.g. Cloudflare 504 gateway timeout) + | 'resolve-failure'; // could not resolve the backend URL at all + +export type BackendHealthResult = + | { healthy: true; status: number; latencyMs: number; backendUrl: string } + | { healthy: false; reason: BackendHealthFailureReason; status?: number; latencyMs: number }; + +interface CheckOptions { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +/** + * Probes the backend `/health` endpoint. + * + * `GET /health` on the OpenHuman backend returns 200 `{"status":"ok"}`. We + * treat any 2xx/3xx/4xx as "the backend is reachable at all" — the goal of + * this probe is specifically to catch full edge/origin failures (Cloudflare + * 5xx, DNS, offline) so we can surface them on the Welcome screen instead of + * silently sending the user into a system browser that lands on an error page. + * + * **Never throws.** All network, timeout, and URL-resolution failures are + * caught internally and surfaced as `{ healthy: false, reason: … }` results, + * so callers do not need to wrap this in try/catch. The healthy variant also + * returns the resolved `backendUrl` so callers can reuse it without a second + * `getBackendUrl()` round-trip. + */ +export async function checkBackendHealthy( + options: CheckOptions = {} +): Promise { + const { timeoutMs = BACKEND_HEALTH_TIMEOUT_MS, fetchImpl = fetch } = options; + const start = Date.now(); + + let backendUrl: string; + try { + backendUrl = await getBackendUrl(); + } catch (err) { + console.debug('[backend-health] could not resolve backend URL', err); + return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(`${backendUrl}/health`, { + method: 'GET', + cache: 'no-store', + credentials: 'omit', + signal: controller.signal, + }); + const latencyMs = Date.now() - start; + + if (response.status >= 500 && response.status < 600) { + console.debug(`[backend-health] unhealthy: HTTP ${response.status} in ${latencyMs}ms`); + return { healthy: false, reason: 'http-5xx', status: response.status, latencyMs }; + } + + return { healthy: true, status: response.status, latencyMs, backendUrl }; + } catch (err) { + const latencyMs = Date.now() - start; + if (err instanceof DOMException && err.name === 'AbortError') { + console.debug(`[backend-health] timeout after ${latencyMs}ms`); + return { healthy: false, reason: 'timeout', latencyMs }; + } + console.debug(`[backend-health] network error after ${latencyMs}ms`, err); + return { healthy: false, reason: 'network', latencyMs }; + } finally { + clearTimeout(timer); + } +} diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx index ce6a7286c..d8f78f6f6 100644 --- a/app/test/OAuthDiscord.test.tsx +++ b/app/test/OAuthDiscord.test.tsx @@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + // Default to a healthy backend so the pre-flight in OAuthProviderButton + // (added for issue #1985) doesn't short-circuit the OAuth flow these + // tests exercise. + mockCheckBackendHealthy: vi.fn().mockImplementation(async () => { + // Mirror the real checkBackendHealthy contract: never throw — convert a + // getBackendUrl() rejection into the resolve-failure result so tests that + // exercise that error path see the production banner instead of an + // unhandled rejection in the click handler. + try { + const backendUrl = await mockGetBackendUrl(); + return { healthy: true, status: 200, latencyMs: 5, backendUrl }; + } catch { + return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; + } + }), })); vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); diff --git a/app/test/OAuthGitHub.test.tsx b/app/test/OAuthGitHub.test.tsx index b50978e98..67e962164 100644 --- a/app/test/OAuthGitHub.test.tsx +++ b/app/test/OAuthGitHub.test.tsx @@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + // Default to a healthy backend so the pre-flight in OAuthProviderButton + // (added for issue #1985) doesn't short-circuit the OAuth flow these + // tests exercise. + mockCheckBackendHealthy: vi.fn().mockImplementation(async () => { + // Mirror the real checkBackendHealthy contract: never throw — convert a + // getBackendUrl() rejection into the resolve-failure result so tests that + // exercise that error path see the production banner instead of an + // unhandled rejection in the click handler. + try { + const backendUrl = await mockGetBackendUrl(); + return { healthy: true, status: 200, latencyMs: 5, backendUrl }; + } catch { + return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; + } + }), })); vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx index 0e974199c..33888a17b 100644 --- a/app/test/OAuthLoginSection.test.tsx +++ b/app/test/OAuthLoginSection.test.tsx @@ -24,13 +24,29 @@ import { renderWithProviders } from '../src/test/test-utils'; // (which are hoisted to the top of the file by Vitest). // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + // Default to a healthy backend so the pre-flight in OAuthProviderButton + // (added for issue #1985) doesn't short-circuit the OAuth flow these + // tests exercise. + mockCheckBackendHealthy: vi.fn().mockImplementation(async () => { + // Mirror the real checkBackendHealthy contract: never throw — convert a + // getBackendUrl() rejection into the resolve-failure result so tests that + // exercise that error path see the production banner instead of an + // unhandled rejection in the click handler. + try { + const backendUrl = await mockGetBackendUrl(); + return { healthy: true, status: 200, latencyMs: 5, backendUrl }; + } catch { + return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; + } + }), })); vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); diff --git a/app/test/OAuthTwitter.test.tsx b/app/test/OAuthTwitter.test.tsx index 53d2948ab..0489ed216 100644 --- a/app/test/OAuthTwitter.test.tsx +++ b/app/test/OAuthTwitter.test.tsx @@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + // Default to a healthy backend so the pre-flight in OAuthProviderButton + // (added for issue #1985) doesn't short-circuit the OAuth flow these + // tests exercise. + mockCheckBackendHealthy: vi.fn().mockImplementation(async () => { + // Mirror the real checkBackendHealthy contract: never throw — convert a + // getBackendUrl() rejection into the resolve-failure result so tests that + // exercise that error path see the production banner instead of an + // unhandled rejection in the click handler. + try { + const backendUrl = await mockGetBackendUrl(); + return { healthy: true, status: 200, latencyMs: 5, backendUrl }; + } catch { + return { healthy: false, reason: 'resolve-failure', latencyMs: 0 }; + } + }), })); vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>();