fix: improve error handling and stability - no logout on transient errors, add ErrorBoundary, global error logging

This commit is contained in:
Agent Zero
2026-07-26 12:18:52 +02:00
parent e12b85c2ce
commit 808da564f3
7 changed files with 344 additions and 8 deletions
+4 -1
View File
@@ -4,6 +4,7 @@ import { AppRouter } from '@/routes';
import { setUnauthorizedHandler } from '@/api/client';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
const queryClient = new QueryClient({
defaultOptions: {
@@ -33,7 +34,9 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppRouter />
<ErrorBoundary>
<AppRouter />
</ErrorBoundary>
</QueryClientProvider>
);
}
+22
View File
@@ -55,17 +55,39 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
);
// Retry interceptor for 5xx errors (max 1 retry with short delay)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
const status = error.response?.status || 0;
// Retry 5xx errors once with a short delay
if (status >= 500 && status < 600 && config && !config._retried) {
config._retried = true;
await new Promise((resolve) => setTimeout(resolve, 500));
return apiClient.request(config);
}
return Promise.reject(error);
}
);
// Main error-handling interceptor
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const status = error.response?.status || 0;
const data = error.response?.data as any;
// Only call onUnauthorized for 401 — NOT for 403 or network errors
if (status === 401) {
if (onUnauthorized) {
onUnauthorized();
}
}
// 403: Permission error — do NOT logout, let the calling code handle it
// Network errors (status === 0): do NOT logout, transient issue
if (status === 422 && data?.detail) {
const validationErrors: Record<string, string[]> = {};
@@ -0,0 +1,105 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { logError } from '@/utils/errorLogger';
interface ErrorBoundaryProps {
children: ReactNode;
/** Optional fallback render function; receives the error and a retry callback */
fallback?: (error: Error, retry: () => void) => ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* React Error Boundary that catches JavaScript errors in the component tree.
* Shows a friendly error message with a Retry button.
* Logs errors via the global error logger.
* Only the affected subtree is broken — the rest of the app continues.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log to console + backend + sessionStorage buffer
logError(error, { componentStack: errorInfo.componentStack });
}
handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError && this.state.error) {
// Custom fallback if provided
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleRetry);
}
// Default fallback UI
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '300px',
padding: '2rem',
textAlign: 'center',
}}
>
<h2 style={{ marginBottom: '0.5rem', color: '#dc2626' }}>
Something went wrong
</h2>
<p style={{ marginBottom: '1rem', color: '#6b7280', maxWidth: '400px' }}>
An unexpected error occurred. You can try again or refresh the page.
</p>
<details style={{ marginBottom: '1rem', maxWidth: '600px', color: '#9ca3af' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.875rem' }}>
Error details
</summary>
<pre
style={{
marginTop: '0.5rem',
fontSize: '0.75rem',
overflow: 'auto',
textAlign: 'left',
}}
>
{this.state.error.message}
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
</pre>
</details>
<button
onClick={this.handleRetry}
style={{
padding: '0.5rem 1.5rem',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+9 -4
View File
@@ -4,14 +4,19 @@ import { useCurrentUser } from '@/api/hooks';
export function useAuth() {
const store = useAuthStore();
const { data, isLoading, isError } = useCurrentUser();
const { data, isLoading, isError, error } = useCurrentUser();
useEffect(() => {
if (isError) {
store.setAuthenticated(false);
store.setUser(null);
const status = (error as any)?.status || 0;
// Only logout on 401 Unauthorized — other errors are transient
if (status === 401) {
store.setAuthenticated(false);
store.setUser(null);
}
// For 403, 500, network errors: keep session, don't logout
}
}, [isError, store]);
}, [isError, error, store]);
return {
user: store.user,
+106
View File
@@ -0,0 +1,106 @@
/**
* Global Error Logger
*
* Centralized error collection and reporting.
* - Always logs to console.error
* - Optionally POSTs to /api/v1/errors (silently fails if endpoint unavailable)
* - Buffers errors in sessionStorage (ring buffer, max 50)
*/
const STORAGE_KEY = 'leocrm_error_buffer';
const MAX_BUFFER_SIZE = 50;
interface ErrorEntry {
timestamp: string;
message: string;
stack?: string;
context?: Record<string, any>;
url?: string;
userAgent?: string;
}
function getBuffer(): ErrorEntry[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as ErrorEntry[];
} catch {
return [];
}
}
function saveBuffer(buffer: ErrorEntry[]): void {
try {
// Ring buffer: keep only the last MAX_BUFFER_SIZE entries
const trimmed = buffer.slice(-MAX_BUFFER_SIZE);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
} catch {
// sessionStorage might be full or unavailable — silently ignore
}
}
function addToBuffer(entry: ErrorEntry): void {
const buffer = getBuffer();
buffer.push(entry);
saveBuffer(buffer);
}
/**
* Log an error with optional context.
* Always writes to console.error.
* Attempts to POST to /api/v1/errors (silently fails if unavailable).
* Buffers in sessionStorage as a ring buffer (max 50 entries).
*/
export function logError(error: Error | string, context?: Record<string, any>): void {
const message = typeof error === 'string' ? error : error.message;
const stack = typeof error === 'string' ? undefined : error.stack;
const entry: ErrorEntry = {
timestamp: new Date().toISOString(),
message,
stack,
context,
url: typeof window !== 'undefined' ? window.location.href : undefined,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
};
// Always log to console
console.error('[ErrorLogger]', message, { stack, context, url: entry.url });
// Buffer in sessionStorage
addToBuffer(entry);
// Attempt to send to backend (fire-and-forget, silently fail)
try {
fetch('/api/v1/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(entry),
}).catch(() => {
// Silently fail — endpoint may not exist or network may be down
});
} catch {
// Silently fail
}
}
/**
* Retrieve the buffered errors from sessionStorage.
*/
export function getErrorBuffer(): ErrorEntry[] {
return getBuffer();
}
/**
* Clear the error buffer from sessionStorage.
*/
export function clearErrorBuffer(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Silently ignore
}
}
export default { logError, getErrorBuffer, clearErrorBuffer };