mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc573b05f6
commit
2426110c5d
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user