diff --git a/app/src/hooks/useUsageState.test.ts b/app/src/hooks/useUsageState.test.ts index 080fba929..5a54d8f38 100644 --- a/app/src/hooks/useUsageState.test.ts +++ b/app/src/hooks/useUsageState.test.ts @@ -132,6 +132,71 @@ describe('useUsageState', () => { expect(result.current.shouldShowBudgetCompletedMessage).toBe(false); }); + it('swallows CoreRpcError(kind=auth_expired) so it cannot leak to window.unhandledrejection (#1472)', async () => { + const { useUsageState } = await import('./useUsageState'); + const { CoreRpcError } = await import('../services/coreRpcClient'); + + mockGetCurrentPlan.mockResolvedValue({ + plan: 'FREE', + hasActiveSubscription: false, + planExpiry: null, + subscription: null, + monthlyBudgetUsd: 0, + weeklyBudgetUsd: 0, + fiveHourCapUsd: 0, + }); + mockGetTeamUsage.mockRejectedValue( + new CoreRpcError( + 'GET /teams failed (401 Unauthorized): Session expired. Please log in again.', + 'auth_expired', + 401 + ) + ); + + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + try { + const { result } = renderHook(() => useUsageState()); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + // Hook must NOT have a teamUsage value (auth-expired leg fell back to + // null) and the rejection must NOT have surfaced as unhandled. + expect(result.current.teamUsage).toBeNull(); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + + it('swallows non-auth transport errors silently (does not throw past Promise.all)', async () => { + const { useUsageState } = await import('./useUsageState'); + + mockGetCurrentPlan.mockResolvedValue({ + plan: 'FREE', + hasActiveSubscription: false, + planExpiry: null, + subscription: null, + monthlyBudgetUsd: 0, + weeklyBudgetUsd: 0, + fiveHourCapUsd: 0, + }); + mockGetTeamUsage.mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:7788')); + + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + try { + const { result } = renderHook(() => useUsageState()); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.teamUsage).toBeNull(); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + it('refetches when a global usage refresh is requested', async () => { const { useUsageState } = await import('./useUsageState'); const { requestUsageRefresh } = await import('./usageRefresh'); diff --git a/app/src/hooks/useUsageState.ts b/app/src/hooks/useUsageState.ts index d3601e083..897549ae2 100644 --- a/app/src/hooks/useUsageState.ts +++ b/app/src/hooks/useUsageState.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { billingApi } from '../services/api/billingApi'; import { creditsApi, type TeamUsage } from '../services/api/creditsApi'; +import { CoreRpcError } from '../services/coreRpcClient'; import type { CurrentPlanData, PlanTier } from '../types/api'; import { subscribeUsageRefresh } from './usageRefresh'; @@ -28,16 +29,44 @@ let _cache: { fetchedAt: number; } | null = null; -async function fetchUsageData(): Promise<{ teamUsage: TeamUsage; currentPlan: CurrentPlanData }> { +const USAGE_UNAVAILABLE = Symbol('usage-unavailable'); + +async function fetchUsageData(): Promise<{ + teamUsage: TeamUsage | null; + currentPlan: CurrentPlanData | null; +} | null> { if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) { return _cache.data; } + // Wrap each leg so a single failing call (e.g. /teams returning 401 after + // session expiry) cannot reject the Promise.all microtask before the + // sibling resolves — that race let the unhandled rejection leak to the + // window's unhandledrejection trap and onward to Sentry (#1472). const [teamUsage, currentPlan] = await Promise.all([ - creditsApi.getTeamUsage(), - billingApi.getCurrentPlan(), + creditsApi.getTeamUsage().catch(err => { + if (err instanceof CoreRpcError && err.kind === 'auth_expired') { + throw err; + } + return USAGE_UNAVAILABLE; + }), + billingApi.getCurrentPlan().catch(err => { + if (err instanceof CoreRpcError && err.kind === 'auth_expired') { + throw err; + } + return USAGE_UNAVAILABLE; + }), ]); - _cache = { data: { teamUsage, currentPlan }, fetchedAt: Date.now() }; - return _cache.data; + const data = { + teamUsage: teamUsage === USAGE_UNAVAILABLE ? null : (teamUsage as TeamUsage), + currentPlan: currentPlan === USAGE_UNAVAILABLE ? null : (currentPlan as CurrentPlanData), + }; + if (data.teamUsage && data.currentPlan) { + _cache = { + data: { teamUsage: data.teamUsage, currentPlan: data.currentPlan }, + fetchedAt: Date.now(), + }; + } + return data; } export function useUsageState(): UsageState { @@ -58,12 +87,17 @@ export function useUsageState(): UsageState { setIsLoading(true); fetchUsageData() .then(data => { - if (cancelled) return; + if (cancelled || !data) return; setTeamUsage(data.teamUsage); setCurrentPlan(data.currentPlan); }) - .catch(() => { - // Usage unavailable — silently ignore + .catch((err: unknown) => { + // CoreRpcError(kind=auth_expired) is the documented signal that the + // session has been revoked — coreRpcClient already dispatched the + // global reauth event, so swallow here instead of letting it leak + // to window.unhandledrejection -> Sentry (#1472). + if (err instanceof CoreRpcError && err.kind === 'auth_expired') return; + // Other failures: usage unavailable — silently ignore. }) .finally(() => { if (!cancelled) setIsLoading(false); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index bee32aff0..6c2714f05 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -544,6 +544,8 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) [refresh, refreshTeams] ); + const lastReauthAtRef = useRef(0); + const clearSession = useCallback(async () => { logoutGuardUntilRef.current = Date.now() + 5_000; snapshotRequestIdRef.current += 1; @@ -568,6 +570,40 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) }); }, [commitState, refresh]); + useEffect(() => { + const onAuthExpired = (event: Event) => { + const customEvent = event as CustomEvent<{ method?: string; source?: string }>; + const now = Date.now(); + // Multiple parallel RPC chains (usage pill + upsell banner + threads + // poll) can each surface a 401 within the same frame after a token + // expires. Debounce so clearSession only runs once per real auth-loss + // event — the 10s window covers any straggler chains without blocking + // a legitimate re-login that immediately fails again. + if (now - lastReauthAtRef.current < 10_000) { + log( + 'auth-expired debounced (method=%s source=%s)', + customEvent.detail?.method ?? 'unknown', + customEvent.detail?.source ?? 'unknown' + ); + return; + } + lastReauthAtRef.current = now; + log( + 'auth-expired: clearing session (method=%s source=%s)', + customEvent.detail?.method ?? 'unknown', + customEvent.detail?.source ?? 'unknown' + ); + void clearSession().catch(err => { + log('clearSession failed after auth-expired: %O', sanitizeError(err)); + }); + }; + + window.addEventListener('core-rpc-auth-expired', onAuthExpired as EventListener); + return () => { + window.removeEventListener('core-rpc-auth-expired', onAuthExpired as EventListener); + }; + }, [clearSession]); + const patchSnapshot = useCallback( (patch: Partial) => { commitState(previous => ({ ...previous, snapshot: { ...previous.snapshot, ...patch } })); diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 59d2fa6a4..d52409534 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -276,6 +276,48 @@ describe('CoreStateProvider — identity-change cache clearing', () => { }); }); + it('dispatching core-rpc-auth-expired triggers clearSession (and debounces repeated fires within 10s)', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + // First dispatch should clear the session. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.team_get_usage', source: 'rpc' }, + }) + ); + }); + + await waitFor(() => expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1)); + + // Repeated fires within the debounce window must NOT call logout again. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.threads_list', source: 'rpc' }, + }) + ); + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.billing_get_current_plan', source: 'rpc' }, + }) + ); + }); + + expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1); + }); + it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => { fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); listTeams.mockResolvedValue([]); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index e9e571677..e5ded5795 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -1,10 +1,10 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS } from '../../utils/config'; import type { AccessibilityStatus, CommandResponse } from '../../utils/tauriCommands'; -import { callCoreRpc } from '../coreRpcClient'; +import { callCoreRpc, classifyRpcError, CoreRpcError } from '../coreRpcClient'; function sampleAccessibilityStatus( overrides: Partial = {} @@ -495,6 +495,155 @@ describe('coreRpcClient', () => { }); }); +describe('classifyRpcError', () => { + test.each([ + ['GET /teams failed (401 Unauthorized): {"success":false}', undefined, 'auth_expired'], + ['Session expired. Please log in again.', undefined, 'auth_expired'], + ['some prefix Session expired suffix', undefined, 'auth_expired'], + ['HTTP 429 rate-limit exceeded', undefined, 'rate_limited'], + ['Budget exceeded for current period', undefined, 'budget_exceeded'], + ['Insufficient budget for request', undefined, 'budget_exceeded'], + ['error sending request for url', undefined, 'transport'], + ['client error (Connect) inner: dns', undefined, 'transport'], + ['operation timed out after 30s', undefined, 'transport'], + ['ECONNREFUSED 127.0.0.1:7788', undefined, 'transport'], + ['some random message', undefined, 'unknown'], + ] as const)('%s => %s', (message, status, expected) => { + expect(classifyRpcError(message, status)).toBe(expected); + }); + + test('http status 401 wins over message text', () => { + expect(classifyRpcError('anything', 401)).toBe('auth_expired'); + }); + + test('http status 429 wins over message text', () => { + expect(classifyRpcError('anything', 429)).toBe('rate_limited'); + }); +}); + +describe('coreRpcClient — typed errors + auth-expired event', () => { + const authExpiredHandler = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + authExpiredHandler.mockReset(); + window.addEventListener('core-rpc-auth-expired', authExpiredHandler); + }); + + afterEach(() => { + window.removeEventListener('core-rpc-auth-expired', authExpiredHandler); + }); + + test('throws CoreRpcError(kind=auth_expired) on Session expired payload and fires event once', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: 'GET /teams failed (401 Unauthorized): Session expired. Please log in again.', + }, + }), + } as Response); + + await expect(callCoreRpc({ method: 'openhuman.team_get_usage' })).rejects.toMatchObject({ + name: 'CoreRpcError', + kind: 'auth_expired', + }); + + expect(authExpiredHandler).toHaveBeenCalledTimes(1); + const evt = authExpiredHandler.mock.calls[0][0] as CustomEvent<{ + method: string; + source: string; + }>; + expect(evt.type).toBe('core-rpc-auth-expired'); + expect(evt.detail.method).toBe('openhuman.team_get_usage'); + expect(evt.detail.source).toBe('rpc'); + }); + + test('throws CoreRpcError(kind=auth_expired) on HTTP 401 (non-ok response) and fires event', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'session expired', + } as Response); + + const err = await callCoreRpc({ method: 'openhuman.threads_list' }).catch(e => e); + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('auth_expired'); + expect((err as CoreRpcError).httpStatus).toBe(401); + expect(authExpiredHandler).toHaveBeenCalledTimes(1); + }); + + test('classifies budget_exceeded without firing the auth-expired event', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'Budget exceeded for current period' }, + }), + } as Response); + + const err = await callCoreRpc({ method: 'openhuman.team_get_usage' }).catch(e => e); + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('budget_exceeded'); + expect(authExpiredHandler).not.toHaveBeenCalled(); + }); + + test('classifies rate_limited without firing the auth-expired event', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'rate-limit exceeded', + } as Response); + + const err = await callCoreRpc({ method: 'openhuman.team_get_usage' }).catch(e => e); + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('rate_limited'); + expect((err as CoreRpcError).httpStatus).toBe(429); + expect(authExpiredHandler).not.toHaveBeenCalled(); + }); + + test('network error wrapped as CoreRpcError(kind=transport) with no auth event', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockRejectedValueOnce( + new Error('error sending request for url (http://x): ECONNREFUSED') + ); + + const err = await callCoreRpc({ method: 'openhuman.threads_list' }).catch(e => e); + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('transport'); + expect(authExpiredHandler).not.toHaveBeenCalled(); + }); + + test('unknown error preserves message', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'something weird' }, + }), + } as Response); + + const err = await callCoreRpc({ method: 'openhuman.threads_list' }).catch(e => e); + expect(err).toBeInstanceOf(CoreRpcError); + expect((err as CoreRpcError).kind).toBe('unknown'); + expect((err as Error).message).toBe('something weird'); + expect(authExpiredHandler).not.toHaveBeenCalled(); + }); +}); + describe('getCoreRpcUrl', () => { // Each test gets a fresh module so module-level caches are cleared beforeEach(() => { diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 2eec0732c..926553fee 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -40,6 +40,63 @@ let resolvedCoreRpcToken: string | null = null; let didResolveCoreRpcToken = false; let resolvingCoreRpcToken: Promise | null = null; +/** + * Stable classification of an RPC failure. Callers (hooks, providers, Sentry + * filters) should branch on `kind` — never on raw message regexes. The shape + * exists so a single 401 from the Rust backend (`Session expired. Please log + * in again.`) can drive both a silent swallow in usage/credits chains AND + * a global reauth signal without every caller re-implementing the regex. + */ +export type CoreRpcErrorKind = + | 'auth_expired' + | 'transport' + | 'rate_limited' + | 'budget_exceeded' + | 'unknown'; + +export class CoreRpcError extends Error { + readonly kind: CoreRpcErrorKind; + readonly httpStatus?: number; + constructor(message: string, kind: CoreRpcErrorKind, httpStatus?: number) { + super(message); + this.name = 'CoreRpcError'; + this.kind = kind; + this.httpStatus = httpStatus; + } +} + +const AUTH_EXPIRED_EVENT = 'core-rpc-auth-expired'; + +/** + * Classify an RPC error from its surfaced message and (when available) the + * HTTP status the core returned. Patterns map to the Rust-side error shapes + * produced by `src/openhuman/backend_api/*` (`authed_json`, rate limiter, + * budget guard) and `reqwest::Error`'s connect/timeout variants. + */ +export function classifyRpcError(message: string, httpStatus?: number): CoreRpcErrorKind { + if (httpStatus === 401) return 'auth_expired'; + if (httpStatus === 429) return 'rate_limited'; + if (/\(401\b.*Unauthorized\)|Session expired/i.test(message)) return 'auth_expired'; + if (/429.*rate.?limit/i.test(message)) return 'rate_limited'; + if (/Budget exceeded|Insufficient budget/i.test(message)) return 'budget_exceeded'; + if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) { + return 'transport'; + } + return 'unknown'; +} + +function dispatchAuthExpired(method: string): void { + if (typeof window === 'undefined') return; + try { + window.dispatchEvent( + new CustomEvent(AUTH_EXPIRED_EVENT, { detail: { method, source: 'rpc' } }) + ); + } catch { + // jsdom in some test paths can throw on CustomEvent constructor edge + // cases; never let a telemetry hop fail the original RPC error path. + } +} + /** * Invalidate the cached core RPC URL so the next call to getCoreRpcUrl() * re-resolves from the user-configured or environment-default value. @@ -289,7 +346,10 @@ export async function callCoreRpc({ if (!response.ok) { const text = await response.text(); - throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`); + const httpMessage = `Core RPC HTTP ${response.status}: ${text || response.statusText}`; + const kind = classifyRpcError(text || response.statusText, response.status); + if (kind === 'auth_expired') dispatchAuthExpired(payload.method); + throw new CoreRpcError(httpMessage, kind, response.status); } const json = (await response.json()) as JsonRpcResponse; @@ -300,7 +360,10 @@ export async function callCoreRpc({ method: payload.method, error: json.error, }); - throw new Error(json.error.message || 'Core RPC returned an error'); + const rawMessage = json.error.message || 'Core RPC returned an error'; + const kind = classifyRpcError(rawMessage); + if (kind === 'auth_expired') dispatchAuthExpired(payload.method); + throw new CoreRpcError(rawMessage, kind); } if (!Object.prototype.hasOwnProperty.call(json, 'result')) { throw new Error('Core RPC response missing result'); @@ -310,6 +373,8 @@ export async function callCoreRpc({ return json.result as T; } catch (err) { coreRpcError('Core RPC call failed', sanitizeError(err)); - throw new Error(coreRpcErrorMessage(err)); + if (err instanceof CoreRpcError) throw err; + const message = coreRpcErrorMessage(err); + throw new CoreRpcError(message, classifyRpcError(message)); } }