fix(core-rpc): demote timeout unhandled-rejections + classify in client (#REACT-15+) (#2196)

This commit is contained in:
oxoxDev
2026-05-19 20:22:54 -07:00
committed by GitHub
parent ee34387f2e
commit 1ae31ba14c
12 changed files with 487 additions and 10 deletions
@@ -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);
});
});
+16 -2
View File
@@ -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([]);
+67 -2
View File
@@ -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', () => {
+28 -1
View File
@@ -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';
+12 -1
View File
@@ -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 {