mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(core-rpc): demote timeout unhandled-rejections + classify in client (#REACT-15+) (#2196)
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import debug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import { sanitizeError } from '../../../utils/sanitize';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const log = debug('core-rpc:error');
|
||||
|
||||
const TeamInvitesPanel = () => {
|
||||
const { t } = useT();
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -34,7 +38,15 @@ const TeamInvitesPanel = () => {
|
||||
useEffect(() => {
|
||||
if (!currentTeamId) return;
|
||||
setIsLoadingInvites(true);
|
||||
void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false));
|
||||
// `.finally()` alone left this as `void promise(...)`, so any rejection
|
||||
// (cold core boot, backend 504, local AbortController timeout) became an
|
||||
// unhandled rejection → OPENHUMAN-REACT-12. Swallow into a logged
|
||||
// breadcrumb; the user can retry by navigating away and back.
|
||||
refreshTeamInvites(currentTeamId)
|
||||
.catch(err => {
|
||||
log('refreshTeamInvites failed in TeamInvitesPanel: %O', sanitizeError(err));
|
||||
})
|
||||
.finally(() => setIsLoadingInvites(false));
|
||||
}, [currentTeamId, refreshTeamInvites]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import debug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
@@ -5,9 +6,12 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import type { TeamMember, TeamRole } from '../../../types/team';
|
||||
import { sanitizeError } from '../../../utils/sanitize';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const log = debug('core-rpc:error');
|
||||
|
||||
const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER'];
|
||||
|
||||
const TeamMembersPanel = () => {
|
||||
@@ -41,7 +45,15 @@ const TeamMembersPanel = () => {
|
||||
useEffect(() => {
|
||||
if (!currentTeamId) return;
|
||||
setIsLoadingMembers(true);
|
||||
void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false));
|
||||
// `.finally()` alone left this as `void promise(...)`, so any rejection
|
||||
// (cold core boot, backend 504, local AbortController timeout) became an
|
||||
// unhandled rejection → OPENHUMAN-REACT-10. Swallow into a logged
|
||||
// breadcrumb; the user can retry by navigating away and back.
|
||||
refreshTeamMembers(currentTeamId)
|
||||
.catch(err => {
|
||||
log('refreshTeamMembers failed in TeamMembersPanel: %O', sanitizeError(err));
|
||||
})
|
||||
.finally(() => setIsLoadingMembers(false));
|
||||
}, [currentTeamId, refreshTeamMembers]);
|
||||
|
||||
const handleChangeRole = (member: TeamMember, newRole: TeamRole) => {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import { CoreRpcError } from '../../../services/coreRpcClient';
|
||||
import type { TeamWithRole } from '../../../types/team';
|
||||
import { sanitizeError } from '../../../utils/sanitize';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const log = debug('core-rpc:error');
|
||||
|
||||
const TeamPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation();
|
||||
@@ -30,13 +35,26 @@ const TeamPanel = () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await refreshTeams();
|
||||
} catch (err) {
|
||||
// Bootstrap-time `team_list_teams` failures (cold core boot, backend
|
||||
// 504, local AbortController hit `CORE_RPC_TIMEOUT_MS`) used to leak
|
||||
// as unhandled promise rejections via the `void` in the useEffect
|
||||
// below, polluting Sentry as OPENHUMAN-REACT-15/11. The next visible
|
||||
// user action retries, so swallow silently for transient kinds.
|
||||
const kind = err instanceof CoreRpcError ? err.kind : 'unknown';
|
||||
log('refreshTeams failed in TeamPanel (kind=%s): %O', kind, sanitizeError(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [refreshTeams]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTeamsWithLoading();
|
||||
// `refreshTeamsWithLoading` already absorbs rejections internally, but
|
||||
// keep the `.catch()` as a belt-and-suspenders guard so a future refactor
|
||||
// that re-throws cannot regress the unhandled-rejection family.
|
||||
refreshTeamsWithLoading().catch(err => {
|
||||
log('refreshTeamsWithLoading rethrew unexpectedly: %O', sanitizeError(err));
|
||||
});
|
||||
}, [refreshTeamsWithLoading]);
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TeamInvitesPanel — unhandled-rejection guard test.
|
||||
*
|
||||
* Regression coverage for OPENHUMAN-REACT-12: bootstrap-time
|
||||
* `team_list_invites` failures leaked through the
|
||||
* `void refreshTeamInvites(...).finally(...)` pattern. The new explicit
|
||||
* `.catch()` chained before `.finally()` must absorb the rejection
|
||||
* silently.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { useCoreState } from '../../../../providers/CoreStateProvider';
|
||||
import { CoreRpcError } from '../../../../services/coreRpcClient';
|
||||
import TeamInvitesPanel from '../TeamInvitesPanel';
|
||||
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
|
||||
}));
|
||||
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ teamId: 'team-u1' }),
|
||||
useLocation: () => ({ pathname: '/team/manage/team-u1' }),
|
||||
}));
|
||||
|
||||
describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => {
|
||||
let urEvents: PromiseRejectionEvent[];
|
||||
const urHandler = (e: PromiseRejectionEvent) => {
|
||||
urEvents.push(e);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
urEvents = [];
|
||||
window.addEventListener('unhandledrejection', urHandler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener('unhandledrejection', urHandler);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('swallows refreshTeamInvites CoreRpcError(timeout) without unhandledrejection', async () => {
|
||||
const refreshTeamInvites = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new CoreRpcError('Core RPC openhuman.team_list_invites timed out after 30000ms', 'timeout')
|
||||
);
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
|
||||
teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
|
||||
teamInvitesById: {},
|
||||
refreshTeamInvites,
|
||||
} as never);
|
||||
|
||||
render(<TeamInvitesPanel />);
|
||||
await waitFor(() => expect(refreshTeamInvites).toHaveBeenCalledWith('team-u1'));
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
|
||||
expect(urEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TeamMembersPanel — unhandled-rejection guard test.
|
||||
*
|
||||
* Regression coverage for OPENHUMAN-REACT-10: bootstrap-time
|
||||
* `team_list_members` failures leaked through the
|
||||
* `void refreshTeamMembers(...).finally(...)` pattern. The new explicit
|
||||
* `.catch()` chained before `.finally()` must absorb the rejection
|
||||
* silently.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { useCoreState } from '../../../../providers/CoreStateProvider';
|
||||
import { CoreRpcError } from '../../../../services/coreRpcClient';
|
||||
import TeamMembersPanel from '../TeamMembersPanel';
|
||||
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
|
||||
}));
|
||||
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ teamId: 'team-u1' }),
|
||||
useLocation: () => ({ pathname: '/team/manage/team-u1' }),
|
||||
}));
|
||||
|
||||
describe('TeamMembersPanel — unhandled-rejection guard (#REACT-10)', () => {
|
||||
let urEvents: PromiseRejectionEvent[];
|
||||
const urHandler = (e: PromiseRejectionEvent) => {
|
||||
urEvents.push(e);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
urEvents = [];
|
||||
window.addEventListener('unhandledrejection', urHandler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener('unhandledrejection', urHandler);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('swallows refreshTeamMembers CoreRpcError(timeout) without unhandledrejection', async () => {
|
||||
const refreshTeamMembers = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new CoreRpcError('Core RPC openhuman.team_list_members timed out after 30000ms', 'timeout')
|
||||
);
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
|
||||
teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
|
||||
teamMembersById: {},
|
||||
refreshTeamMembers,
|
||||
} as never);
|
||||
|
||||
render(<TeamMembersPanel />);
|
||||
await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-u1'));
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
|
||||
expect(urEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* TeamPanel — unhandled-rejection guard tests.
|
||||
*
|
||||
* Regression coverage for OPENHUMAN-REACT-15 / REACT-11: bootstrap-time
|
||||
* `team_list_teams` failures (cold core boot, backend 504, local
|
||||
* AbortController timeout) used to leak as `void promise(...)` unhandled
|
||||
* rejections via the `useEffect` mount handler. The new `.catch()` in
|
||||
* `refreshTeamsWithLoading` plus the defensive `.catch()` on the
|
||||
* `useEffect` invocation must absorb the rejection silently.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { useCoreState } from '../../../../providers/CoreStateProvider';
|
||||
import { CoreRpcError } from '../../../../services/coreRpcClient';
|
||||
import TeamPanel from '../TeamPanel';
|
||||
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
navigateToTeamManagement: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
|
||||
|
||||
describe('TeamPanel — unhandled-rejection guard (#REACT-15)', () => {
|
||||
let urEvents: PromiseRejectionEvent[];
|
||||
const urHandler = (e: PromiseRejectionEvent) => {
|
||||
urEvents.push(e);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
urEvents = [];
|
||||
window.addEventListener('unhandledrejection', urHandler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener('unhandledrejection', urHandler);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('swallows refreshTeams CoreRpcError(timeout) without unhandledrejection', async () => {
|
||||
const refreshTeams = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new CoreRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms', 'timeout')
|
||||
);
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
|
||||
teams: [],
|
||||
refresh: vi.fn(),
|
||||
refreshTeams,
|
||||
} as never);
|
||||
|
||||
render(<TeamPanel />);
|
||||
|
||||
// Mount-effect must have fired refreshTeams and observed the rejection.
|
||||
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
|
||||
// Flush microtasks so the `.catch()` chain has a chance to settle.
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
|
||||
expect(urEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('swallows transport-kind refreshTeams failure without unhandledrejection', async () => {
|
||||
// Backend 504 / connect-refused shape (REACT-13 / REACT-14 family) must
|
||||
// also be absorbed — the `.catch()` is unconditional, not
|
||||
// kind-gated.
|
||||
const refreshTeams = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new CoreRpcError('backend request GET /teams', 'transport'));
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
|
||||
teams: [],
|
||||
refresh: vi.fn(),
|
||||
refreshTeams,
|
||||
} as never);
|
||||
|
||||
render(<TeamPanel />);
|
||||
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
|
||||
expect(urEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -573,7 +573,15 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
const updateLocalState = useCallback(
|
||||
async (params: Parameters<typeof updateCoreLocalState>[0]) => {
|
||||
await updateCoreLocalState(params);
|
||||
await refresh();
|
||||
// The follow-up refresh is best-effort cache reconciliation, not part
|
||||
// of the write contract — sibling helpers (setAnalyticsEnabled,
|
||||
// setMeetAutoOrchestratorHandoff, …) already swallow here. An
|
||||
// un-caught `app_state_snapshot` timeout used to bubble out of
|
||||
// `setEncryptionKey` / `setOnboardingTasks` callers as an unhandled
|
||||
// rejection → OPENHUMAN-REACT-Z/Y. The next poll tick will reconcile.
|
||||
await refresh().catch(err => {
|
||||
log('refresh failed after updateLocalState: %O', sanitizeError(err));
|
||||
});
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
@@ -594,7 +602,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
|
||||
// restartApp call here was redundant and skipped the persist purge,
|
||||
// letting redux-persist rehydrate the prior user's slices on launch
|
||||
// (#900). Restart now happens inside handleIdentityFlip after purge.
|
||||
await refresh();
|
||||
// Swallow refresh failures here so a cold-boot `app_state_snapshot`
|
||||
// timeout post-login doesn't surface as an unhandled rejection
|
||||
// (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within
|
||||
// `POLL_MS`.
|
||||
await refresh().catch(err => {
|
||||
log('refresh failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
await refreshTeams().catch(err => {
|
||||
log('refreshTeams failed after session store: %O', sanitizeError(err));
|
||||
});
|
||||
|
||||
@@ -578,6 +578,79 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
|
||||
expect(screen.getByTestId('token').textContent).toBe('tok1');
|
||||
});
|
||||
|
||||
it('setEncryptionKey (updateLocalState) swallows refresh errors after the local-state write lands (#REACT-Z #REACT-Y)', async () => {
|
||||
// Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the
|
||||
// follow-up `refresh()` inside `updateLocalState` let an
|
||||
// `app_state_snapshot` timeout bubble out as an unhandled rejection.
|
||||
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(coreStateApi.updateCoreLocalState).mockReset();
|
||||
vi.mocked(coreStateApi.updateCoreLocalState).mockResolvedValue(undefined as never);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
fetchSnapshot.mockRejectedValueOnce(
|
||||
new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms')
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// setEncryptionKey is a thin sync wrapper around updateLocalState
|
||||
// (provider line 694) — exercising it covers the new .catch() arm
|
||||
// on line 579-580.
|
||||
await expect(ctx!.setEncryptionKey('new-key')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(vi.mocked(coreStateApi.updateCoreLocalState)).toHaveBeenCalledWith({
|
||||
encryptionKey: 'new-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('storeSessionToken swallows refresh errors after the session write lands (#REACT-Z #REACT-Y)', async () => {
|
||||
// Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the
|
||||
// post-login `refresh()` inside `storeSessionToken` let an
|
||||
// `app_state_snapshot` timeout bubble out as an unhandled rejection
|
||||
// immediately after sign-in.
|
||||
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
vi.mocked(tauriCommands.storeSession).mockReset();
|
||||
vi.mocked(tauriCommands.storeSession).mockResolvedValue(undefined as never);
|
||||
vi.mocked(tauriCommands.syncMemoryClientToken).mockReset();
|
||||
vi.mocked(tauriCommands.syncMemoryClientToken).mockResolvedValue(undefined as never);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
fetchSnapshot.mockRejectedValueOnce(
|
||||
new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms')
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const token = makeJwt({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
await expect(ctx!.storeSessionToken(token, {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(vi.mocked(tauriCommands.storeSession)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => {
|
||||
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
|
||||
listTeams.mockResolvedValue([]);
|
||||
|
||||
@@ -79,6 +79,11 @@ vi.mock('../../utils/config', () => ({
|
||||
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
|
||||
SENTRY_RELEASE: 'openhuman@test+abc',
|
||||
SENTRY_SMOKE_TEST: false,
|
||||
// analytics.ts now imports CoreRpcError from coreRpcClient, whose
|
||||
// dependency chain reads CORE_RPC_URL and CORE_RPC_TIMEOUT_MS. Provide
|
||||
// stub values so the module graph loads under this mock.
|
||||
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
|
||||
CORE_RPC_TIMEOUT_MS: 30000,
|
||||
}));
|
||||
|
||||
describe('triggerSentryTestEvent', () => {
|
||||
@@ -155,14 +160,20 @@ describe('triggerSentryTestEvent', () => {
|
||||
describe('initSentry beforeSend manual-staging bypass', () => {
|
||||
/** Capture the `beforeSend` callback that `initSentry` registers. */
|
||||
async function captureBeforeSend(): Promise<
|
||||
(event: Record<string, unknown>) => Record<string, unknown> | null
|
||||
(
|
||||
event: Record<string, unknown>,
|
||||
hint?: { originalException?: unknown }
|
||||
) => Record<string, unknown> | null
|
||||
> {
|
||||
hoisted.init.mockReset();
|
||||
const { initSentry } = await import('../analytics');
|
||||
initSentry();
|
||||
expect(hoisted.init).toHaveBeenCalledTimes(1);
|
||||
const opts = hoisted.init.mock.calls[0][0] as {
|
||||
beforeSend: (event: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
beforeSend: (
|
||||
event: Record<string, unknown>,
|
||||
hint?: { originalException?: unknown }
|
||||
) => Record<string, unknown> | null;
|
||||
};
|
||||
return opts.beforeSend.bind(opts);
|
||||
}
|
||||
@@ -223,6 +234,60 @@ describe('initSentry beforeSend manual-staging bypass', () => {
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('drops CoreRpcError with kind=timeout via the originalException hint', async () => {
|
||||
// Regression for OPENHUMAN-REACT-15/11/10/12/Z/Y: a missed `.catch()` at
|
||||
// any `await callCoreRpc(...)` chain in the team panels surfaced as an
|
||||
// unhandled rejection captured by `auto.browser.global_handlers`. Even
|
||||
// with .catch() landed, future call sites must not regress the family
|
||||
// — this filter is the last line of defense.
|
||||
hoisted.analyticsEnabled = true; // consent on so non-test events normally pass.
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const { CoreRpcError } = await import('../coreRpcClient');
|
||||
const timeoutErr = new CoreRpcError(
|
||||
'Core RPC openhuman.team_list_teams timed out after 30000ms',
|
||||
'timeout'
|
||||
);
|
||||
|
||||
const result = beforeSend(
|
||||
{ message: 'CoreRpcError', tags: {}, contexts: {} },
|
||||
{ originalException: timeoutErr }
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('drops cross-realm CoreRpcError-shaped timeouts (name + kind match)', async () => {
|
||||
// Test harnesses and dynamic imports can construct CoreRpcError in a
|
||||
// separate module scope where `instanceof` fails. The filter must still
|
||||
// demote them.
|
||||
hoisted.analyticsEnabled = true;
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const fakeErr = Object.assign(new Error('Core RPC X timed out after 30000ms'), {
|
||||
name: 'CoreRpcError',
|
||||
kind: 'timeout',
|
||||
});
|
||||
|
||||
const result = beforeSend(
|
||||
{ message: 'CoreRpcError', tags: {}, contexts: {} },
|
||||
{ originalException: fakeErr }
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('lets non-timeout CoreRpcError shapes through (transport, auth_expired, …)', async () => {
|
||||
hoisted.analyticsEnabled = true;
|
||||
const beforeSend = await captureBeforeSend();
|
||||
const { CoreRpcError } = await import('../coreRpcClient');
|
||||
const transportErr = new CoreRpcError('error sending request', 'transport');
|
||||
|
||||
const result = beforeSend(
|
||||
{ message: 'CoreRpcError', tags: {}, contexts: {} },
|
||||
{ originalException: transportErr }
|
||||
);
|
||||
// Transport errors are still worth seeing — only the local 30s
|
||||
// AbortController shape gets demoted at the source.
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('forwards release tag and registers httpContextIntegration (#1403)', async () => {
|
||||
// Regression for #1403: production events arrived in Sentry with no
|
||||
// `release` tag and no `os` context. The release must reach Sentry.init
|
||||
|
||||
@@ -327,7 +327,14 @@ describe('coreRpcClient', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1);
|
||||
|
||||
await expect(pending).rejects.toThrow(
|
||||
const err = await pending.catch(e => e);
|
||||
// The timeout path must throw a CoreRpcError pre-classified as
|
||||
// `timeout` so the outer catch does not re-wrap a bare `Error` and so
|
||||
// Sentry / call-site `.catch()` can branch on `err.kind`. Regression
|
||||
// guard for OPENHUMAN-REACT-Z/Y (the bare-Error shape pre-fix).
|
||||
expect(err).toBeInstanceOf(CoreRpcError);
|
||||
expect((err as CoreRpcError).kind).toBe('timeout');
|
||||
expect((err as Error).message).toBe(
|
||||
`Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms`
|
||||
);
|
||||
} finally {
|
||||
@@ -519,6 +526,21 @@ describe('classifyRpcError', () => {
|
||||
['client error (Connect) inner: dns', undefined, 'transport'],
|
||||
['operation timed out after 30s', undefined, 'transport'],
|
||||
['ECONNREFUSED 127.0.0.1:7788', undefined, 'transport'],
|
||||
// OPENHUMAN-REACT-15/11/10/12 verbatim from Sentry — local AbortController
|
||||
// timeout, NOT backend transport. Must classify as `timeout`.
|
||||
['Core RPC openhuman.team_list_teams timed out after 30000ms', undefined, 'timeout'],
|
||||
['Core RPC openhuman.team_list_members timed out after 30000ms', undefined, 'timeout'],
|
||||
['Core RPC openhuman.team_list_invites timed out after 30000ms', undefined, 'timeout'],
|
||||
// OPENHUMAN-REACT-Z/Y verbatim (bare-Error shape pre-fix; now CoreRpcError
|
||||
// with same message): still kind=timeout under the new classifier.
|
||||
['Core RPC openhuman.app_state_snapshot timed out after 30000ms', undefined, 'timeout'],
|
||||
// OPENHUMAN-REACT-13 verbatim — backend-side connect timeout. Body never
|
||||
// hits the `timed out after \d+ms` matcher and stays `transport`.
|
||||
[
|
||||
'backend request GET /teams: error sending request for url (https://api.tinyhumans.ai/teams): client error (Connect): operation timed out',
|
||||
undefined,
|
||||
'transport',
|
||||
],
|
||||
['some random message', undefined, 'unknown'],
|
||||
] as const)('%s => %s', (message, status, expected) => {
|
||||
expect(classifyRpcError(message, status)).toBe(expected);
|
||||
@@ -537,6 +559,15 @@ describe('classifyRpcError', () => {
|
||||
classifyRpcError('thread thread-123 not found', undefined, { kind: 'ThreadNotFound' })
|
||||
).toBe('thread_not_found');
|
||||
});
|
||||
|
||||
test('local AbortController timeout precedence wins over generic transport regex', () => {
|
||||
// The `timed out` substring also matches the broader transport arm; the
|
||||
// `timed out after \d+ms` arm MUST run first so callers can distinguish
|
||||
// a local 30s ceiling from a backend `client error (Connect)` timeout.
|
||||
expect(classifyRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms')).toBe(
|
||||
'timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coreRpcClient — typed errors + auth-expired event', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SENTRY_RELEASE,
|
||||
SENTRY_SMOKE_TEST,
|
||||
} from '../utils/config';
|
||||
import { CoreRpcError } from './coreRpcClient';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA4 — module-level state
|
||||
@@ -70,6 +71,20 @@ export function isAnalyticsEnabled(): boolean {
|
||||
return getCoreStateSnapshot().snapshot.analyticsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`.
|
||||
* `instanceof` can fail across module scopes (test harness, dynamic import,
|
||||
* Vitest module isolation), so also accept a duck-typed match on `name`
|
||||
* and `kind`. Used by the Sentry `beforeSend` filter to drop the
|
||||
* OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source.
|
||||
*/
|
||||
function isCoreRpcTimeoutError(err: unknown): boolean {
|
||||
if (err instanceof CoreRpcError) return err.kind === 'timeout';
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
const candidate = err as { name?: unknown; kind?: unknown };
|
||||
return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout';
|
||||
}
|
||||
|
||||
export function initSentry(): void {
|
||||
if (!SENTRY_DSN) return;
|
||||
|
||||
@@ -105,7 +120,19 @@ export function initSentry(): void {
|
||||
],
|
||||
sendDefaultPii: false,
|
||||
|
||||
beforeSend(event) {
|
||||
beforeSend(event, hint) {
|
||||
// Drop noisy local-AbortController RPC timeouts at the source so a
|
||||
// missed `.catch()` at a future call site cannot regress the
|
||||
// OPENHUMAN-REACT-15/11/10/12/Z/Y family. Sister to the Rust-side
|
||||
// `is_session_expired_event` filter / loopback classifier in PR #2063.
|
||||
// Cross-realm-safe: also accept a non-instanceof match on the
|
||||
// class name + kind (test harness can construct CoreRpcError in a
|
||||
// different module scope).
|
||||
const original = hint?.originalException as unknown;
|
||||
if (isCoreRpcTimeoutError(original)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always allow the smoke-test event through so pipeline validation works
|
||||
// even when the user hasn't opted into analytics yet on first boot.
|
||||
const isSmokeTest = event.message === 'react-sentry-smoke-test';
|
||||
|
||||
@@ -51,6 +51,7 @@ let resolvingCoreRpcToken: Promise<string | null> | null = null;
|
||||
export type CoreRpcErrorKind =
|
||||
| 'auth_expired'
|
||||
| 'transport'
|
||||
| 'timeout'
|
||||
| 'rate_limited'
|
||||
| 'budget_exceeded'
|
||||
| 'thread_not_found'
|
||||
@@ -94,6 +95,10 @@ export function classifyRpcError(
|
||||
if (/no backend session token/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';
|
||||
// Local AbortController hit `CORE_RPC_TIMEOUT_MS` — distinct from backend
|
||||
// `client error (Connect): operation timed out`. Must run BEFORE the
|
||||
// `transport` arm so the more specific kind wins.
|
||||
if (/timed out after \d+ms/i.test(message)) return 'timeout';
|
||||
if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) {
|
||||
return 'transport';
|
||||
}
|
||||
@@ -430,7 +435,13 @@ export async function callCoreRpc<T>({
|
||||
});
|
||||
} catch (fetchErr) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`);
|
||||
// Throw a fully-classified `CoreRpcError` here so the outer catch
|
||||
// doesn't re-wrap a bare `Error` and so callers can branch on
|
||||
// `err.kind === 'timeout'` (Sentry filter, soft toast skip).
|
||||
throw new CoreRpcError(
|
||||
`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`,
|
||||
'timeout'
|
||||
);
|
||||
}
|
||||
throw fetchErr;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user