refactor(new merge) (#369)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e28eba01e1
commit
3d664c017a
@@ -1,13 +1,11 @@
|
||||
import { trackUiEvent } from './telemetry';
|
||||
|
||||
export type AppErrorCode =
|
||||
| 'TIMEOUT'
|
||||
| 'RATE_LIMIT'
|
||||
| 'PERMISSION'
|
||||
| 'NETWORK'
|
||||
| 'CONFIG'
|
||||
| 'GATEWAY'
|
||||
| 'UNKNOWN';
|
||||
import {
|
||||
AppError,
|
||||
type AppErrorCode,
|
||||
mapBackendErrorCode,
|
||||
normalizeAppError,
|
||||
} from './error-model';
|
||||
export { AppError } from './error-model';
|
||||
|
||||
export type TransportKind = 'ipc' | 'ws' | 'http';
|
||||
export type GatewayTransportPreference = 'ws-first';
|
||||
@@ -181,64 +179,8 @@ class TransportUnsupportedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
code: AppErrorCode;
|
||||
cause?: unknown;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function mapUnifiedErrorCode(code?: string): AppErrorCode {
|
||||
switch (code) {
|
||||
case 'TIMEOUT':
|
||||
return 'TIMEOUT';
|
||||
case 'PERMISSION':
|
||||
return 'PERMISSION';
|
||||
case 'GATEWAY':
|
||||
return 'GATEWAY';
|
||||
case 'VALIDATION':
|
||||
return 'CONFIG';
|
||||
case 'UNSUPPORTED':
|
||||
return 'UNKNOWN';
|
||||
default:
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown, details?: Record<string, unknown>): AppError {
|
||||
if (err instanceof AppError) {
|
||||
return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) });
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (lower.includes('timeout')) {
|
||||
return new AppError('TIMEOUT', message, err, details);
|
||||
}
|
||||
if (lower.includes('rate limit')) {
|
||||
return new AppError('RATE_LIMIT', message, err, details);
|
||||
}
|
||||
if (lower.includes('permission') || lower.includes('forbidden') || lower.includes('denied')) {
|
||||
return new AppError('PERMISSION', message, err, details);
|
||||
}
|
||||
if (lower.includes('network') || lower.includes('fetch')) {
|
||||
return new AppError('NETWORK', message, err, details);
|
||||
}
|
||||
if (lower.includes('gateway')) {
|
||||
return new AppError('GATEWAY', message, err, details);
|
||||
}
|
||||
if (lower.includes('config') || lower.includes('invalid')) {
|
||||
return new AppError('CONFIG', message, err, details);
|
||||
}
|
||||
|
||||
return new AppError('UNKNOWN', message, err, details);
|
||||
return mapBackendErrorCode(code);
|
||||
}
|
||||
|
||||
function shouldLogApiRequests(): boolean {
|
||||
@@ -389,7 +331,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
if (message.includes('APP_REQUEST_UNSUPPORTED:') || message.includes('Invalid IPC channel: app:request')) {
|
||||
// Fallback to legacy channel handlers.
|
||||
} else {
|
||||
throw normalizeError(err, { transport: 'ipc', channel, source: 'app:request' });
|
||||
throw normalizeAppError(err, { transport: 'ipc', channel, source: 'app:request' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,7 +339,7 @@ async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
||||
try {
|
||||
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
|
||||
} catch (err) {
|
||||
throw normalizeError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
|
||||
throw normalizeAppError(err, { transport: 'ipc', channel, source: 'legacy-ipc' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -956,15 +898,19 @@ export function initializeDefaultTransports(): void {
|
||||
}
|
||||
|
||||
export function toUserMessage(error: unknown): string {
|
||||
const appError = error instanceof AppError ? error : normalizeError(error);
|
||||
const appError = error instanceof AppError ? error : normalizeAppError(error);
|
||||
|
||||
switch (appError.code) {
|
||||
case 'AUTH_INVALID':
|
||||
return 'Authentication failed. Check API key or login session and retry.';
|
||||
case 'TIMEOUT':
|
||||
return 'Request timed out. Please retry.';
|
||||
case 'RATE_LIMIT':
|
||||
return 'Too many requests. Please wait and try again.';
|
||||
case 'PERMISSION':
|
||||
return 'Permission denied. Check your configuration and retry.';
|
||||
case 'CHANNEL_UNAVAILABLE':
|
||||
return 'Service channel unavailable. Retry after restarting the app or gateway.';
|
||||
case 'NETWORK':
|
||||
return 'Network error. Please verify connectivity and retry.';
|
||||
case 'CONFIG':
|
||||
@@ -1052,7 +998,7 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw normalizeError(err, {
|
||||
throw normalizeAppError(err, {
|
||||
requestId,
|
||||
channel,
|
||||
transport: kind,
|
||||
@@ -1069,7 +1015,7 @@ export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise
|
||||
message: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
});
|
||||
|
||||
throw normalizeError(lastError, {
|
||||
throw normalizeAppError(lastError, {
|
||||
requestId,
|
||||
channel,
|
||||
transport: 'ipc',
|
||||
@@ -1100,5 +1046,5 @@ export async function invokeIpcWithRetry<T>(
|
||||
}
|
||||
}
|
||||
|
||||
throw normalizeError(lastError);
|
||||
throw normalizeAppError(lastError);
|
||||
}
|
||||
|
||||
102
src/lib/error-model.ts
Normal file
102
src/lib/error-model.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type AppErrorCode =
|
||||
| 'AUTH_INVALID'
|
||||
| 'TIMEOUT'
|
||||
| 'RATE_LIMIT'
|
||||
| 'PERMISSION'
|
||||
| 'CHANNEL_UNAVAILABLE'
|
||||
| 'NETWORK'
|
||||
| 'CONFIG'
|
||||
| 'GATEWAY'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export class AppError extends Error {
|
||||
code: AppErrorCode;
|
||||
cause?: unknown;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: AppErrorCode, message: string, cause?: unknown, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapBackendErrorCode(code?: string): AppErrorCode {
|
||||
switch (code) {
|
||||
case 'TIMEOUT':
|
||||
return 'TIMEOUT';
|
||||
case 'PERMISSION':
|
||||
return 'PERMISSION';
|
||||
case 'GATEWAY':
|
||||
return 'GATEWAY';
|
||||
case 'VALIDATION':
|
||||
return 'CONFIG';
|
||||
case 'UNSUPPORTED':
|
||||
return 'CHANNEL_UNAVAILABLE';
|
||||
default:
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
function classifyMessage(message: string): AppErrorCode {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('invalid ipc channel')
|
||||
|| lower.includes('no handler registered')
|
||||
|| lower.includes('window is not defined')
|
||||
|| lower.includes('unsupported')
|
||||
) {
|
||||
return 'CHANNEL_UNAVAILABLE';
|
||||
}
|
||||
if (
|
||||
lower.includes('invalid authentication')
|
||||
|| lower.includes('unauthorized')
|
||||
|| lower.includes('auth failed')
|
||||
|| lower.includes('401')
|
||||
) {
|
||||
return 'AUTH_INVALID';
|
||||
}
|
||||
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('abort')) {
|
||||
return 'TIMEOUT';
|
||||
}
|
||||
if (lower.includes('rate limit') || lower.includes('429')) {
|
||||
return 'RATE_LIMIT';
|
||||
}
|
||||
if (
|
||||
lower.includes('permission')
|
||||
|| lower.includes('forbidden')
|
||||
|| lower.includes('denied')
|
||||
|| lower.includes('403')
|
||||
) {
|
||||
return 'PERMISSION';
|
||||
}
|
||||
if (
|
||||
lower.includes('network')
|
||||
|| lower.includes('fetch')
|
||||
|| lower.includes('econnrefused')
|
||||
|| lower.includes('econnreset')
|
||||
|| lower.includes('enotfound')
|
||||
) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
if (lower.includes('gateway')) {
|
||||
return 'GATEWAY';
|
||||
}
|
||||
if (lower.includes('config') || lower.includes('invalid') || lower.includes('validation') || lower.includes('400')) {
|
||||
return 'CONFIG';
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
export function normalizeAppError(err: unknown, details?: Record<string, unknown>): AppError {
|
||||
if (err instanceof AppError) {
|
||||
return new AppError(err.code, err.message, err.cause ?? err, { ...(err.details ?? {}), ...(details ?? {}) });
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new AppError(classifyMessage(message), message, err, details);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { trackUiEvent } from './telemetry';
|
||||
import { normalizeAppError } from './error-model';
|
||||
|
||||
const HOST_API_PORT = 3210;
|
||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
||||
@@ -45,7 +46,10 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
} catch {
|
||||
// ignore body parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
throw normalizeAppError(new Error(message), {
|
||||
source: 'browser-fallback',
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
@@ -117,8 +121,12 @@ function parseLegacyProxyResponse<T>(
|
||||
}
|
||||
|
||||
function shouldFallbackToBrowser(message: string): boolean {
|
||||
return message.includes('Invalid IPC channel: hostapi:fetch')
|
||||
|| message.includes('window is not defined');
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes('invalid ipc channel: hostapi:fetch')
|
||||
|| normalized.includes("no handler registered for 'hostapi:fetch'")
|
||||
|| normalized.includes('no handler registered for "hostapi:fetch"')
|
||||
|| normalized.includes('no handler registered for hostapi:fetch')
|
||||
|| normalized.includes('window is not defined');
|
||||
}
|
||||
|
||||
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -139,16 +147,18 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
|
||||
return parseLegacyProxyResponse<T>(response, path, method, startedAt);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method });
|
||||
const message = normalized.message;
|
||||
trackUiEvent('hostapi.fetch_error', {
|
||||
path,
|
||||
method,
|
||||
source: 'ipc-proxy',
|
||||
durationMs: Date.now() - startedAt,
|
||||
message,
|
||||
code: normalized.code,
|
||||
});
|
||||
if (!shouldFallbackToBrowser(message)) {
|
||||
throw error;
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +177,11 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status,
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
try {
|
||||
return await parseResponse<T>(response);
|
||||
} catch (error) {
|
||||
throw normalizeAppError(error, { source: 'browser-fallback', path, method });
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostEventSource(path = '/api/events'): EventSource {
|
||||
|
||||
@@ -61,9 +61,13 @@ export function Dashboard() {
|
||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
|
||||
// Fetch data only when gateway is running
|
||||
// Track page view on mount only.
|
||||
useEffect(() => {
|
||||
trackUiEvent('dashboard.page_viewed');
|
||||
}, []);
|
||||
|
||||
// Fetch data only when gateway is running.
|
||||
useEffect(() => {
|
||||
if (isGatewayRunning) {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { AppError, normalizeAppError } from '@/lib/error-model';
|
||||
import { useGatewayStore } from './gateway';
|
||||
import type { Skill, MarketplaceSkill } from '../types/skill';
|
||||
|
||||
@@ -30,6 +31,27 @@ type ClawHubListResult = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
function mapErrorCodeToSkillErrorKey(
|
||||
code: AppError['code'],
|
||||
operation: 'fetch' | 'search' | 'install',
|
||||
): string {
|
||||
if (code === 'TIMEOUT') {
|
||||
return operation === 'search'
|
||||
? 'searchTimeoutError'
|
||||
: operation === 'install'
|
||||
? 'installTimeoutError'
|
||||
: 'fetchTimeoutError';
|
||||
}
|
||||
if (code === 'RATE_LIMIT') {
|
||||
return operation === 'search'
|
||||
? 'searchRateLimitError'
|
||||
: operation === 'install'
|
||||
? 'installRateLimitError'
|
||||
: 'fetchRateLimitError';
|
||||
}
|
||||
return 'rateLimitError';
|
||||
}
|
||||
|
||||
interface SkillsState {
|
||||
skills: Skill[];
|
||||
searchResults: MarketplaceSkill[];
|
||||
@@ -131,13 +153,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
set({ skills: combinedSkills, loading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch skills:', error);
|
||||
let errorMsg = error instanceof Error ? error.message : String(error);
|
||||
if (errorMsg.includes('Timeout')) {
|
||||
errorMsg = 'timeoutError';
|
||||
} else if (errorMsg.toLowerCase().includes('rate limit')) {
|
||||
errorMsg = 'rateLimitError';
|
||||
}
|
||||
set({ loading: false, error: errorMsg });
|
||||
const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' });
|
||||
set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -151,16 +168,14 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
if (result.success) {
|
||||
set({ searchResults: result.results || [] });
|
||||
} else {
|
||||
if (result.error?.includes('Timeout')) {
|
||||
throw new Error('searchTimeoutError');
|
||||
}
|
||||
if (result.error?.toLowerCase().includes('rate limit')) {
|
||||
throw new Error('searchRateLimitError');
|
||||
}
|
||||
throw new Error(result.error || 'Search failed');
|
||||
throw normalizeAppError(new Error(result.error || 'Search failed'), {
|
||||
module: 'skills',
|
||||
operation: 'search',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({ searchError: String(error) });
|
||||
const appError = normalizeAppError(error, { module: 'skills', operation: 'search' });
|
||||
set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') });
|
||||
} finally {
|
||||
set({ searching: false });
|
||||
}
|
||||
@@ -174,13 +189,11 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
body: JSON.stringify({ slug, version }),
|
||||
});
|
||||
if (!result.success) {
|
||||
if (result.error?.includes('Timeout')) {
|
||||
throw new Error('installTimeoutError');
|
||||
}
|
||||
if (result.error?.toLowerCase().includes('rate limit')) {
|
||||
throw new Error('installRateLimitError');
|
||||
}
|
||||
throw new Error(result.error || 'Install failed');
|
||||
const appError = normalizeAppError(new Error(result.error || 'Install failed'), {
|
||||
module: 'skills',
|
||||
operation: 'install',
|
||||
});
|
||||
throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install'));
|
||||
}
|
||||
// Refresh skills after install
|
||||
await get().fetchSkills();
|
||||
@@ -258,4 +271,4 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user