feat(auth): classify RPC 401s + global reauth bus (#1472) (#1495)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-05-11 11:34:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc573b05f6
commit 2426110c5d
6 changed files with 404 additions and 13 deletions
+65
View File
@@ -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');
+42 -8
View File
@@ -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);
+36
View File
@@ -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<CoreAppSnapshot>) => {
commitState(previous => ({ ...previous, snapshot: { ...previous.snapshot, ...patch } }));
@@ -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(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
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([]);
@@ -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<AccessibilityStatus> = {}
@@ -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(() => {
+68 -3
View File
@@ -40,6 +40,63 @@ let resolvedCoreRpcToken: string | null = null;
let didResolveCoreRpcToken = false;
let resolvingCoreRpcToken: Promise<string | null> | 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<T>({
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<T>;
@@ -300,7 +360,10 @@ export async function callCoreRpc<T>({
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<T>({
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));
}
}