Files
leocrm/frontend/src/api/client.ts
T
Agent Zero 1ba702f6fe fix: report API errors (422, 404, 5xx, network) and React errors to Forgejo error reporter
- client.ts: logError() import added, API error interceptor now reports to /api/v1/errors
- ErrorBoundary.tsx: logError() import added, React rendering errors now reported
- 401 (auth) and 403 (permission) errors are NOT reported (expected behavior)
- 422 (validation), 404 (not found), 5xx (server), 0 (network) ARE reported
2026-07-26 23:45:59 +02:00

175 lines
5.5 KiB
TypeScript

import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { logError } from '@/utils/errorLogger';
export interface ApiError {
status: number;
message: string;
detail?: string;
validationErrors?: Record<string, string[]>;
}
export const apiClient = axios.create({
baseURL: '/api/v1',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
// CSRF token storage — persisted in sessionStorage, sent on all unsafe methods
const CSRF_KEY = 'leocrm_csrf_token';
let csrfToken: string | null = sessionStorage.getItem(CSRF_KEY);
export function setCsrfToken(token: string | null) {
csrfToken = token;
if (token) {
sessionStorage.setItem(CSRF_KEY, token);
} else {
sessionStorage.removeItem(CSRF_KEY);
}
}
export function getCsrfToken(): string | null {
return csrfToken;
}
let onUnauthorized: (() => void) | null = null;
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
export function setValidationErrorHandler(handler: (errors: Record<string, string[]>) => void) {
onValidationError = handler;
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Attach CSRF token on unsafe methods
const unsafe = ['post', 'put', 'patch', 'delete'];
if (unsafe.includes(config.method?.toLowerCase() ?? '') && csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
return config;
},
(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[]> = {};
if (Array.isArray(data.detail)) {
for (const item of data.detail) {
if (item.loc && item.loc.length > 1) {
const field = item.loc[item.loc.length - 1];
if (!validationErrors[field]) {
validationErrors[field] = [];
}
validationErrors[field].push(item.msg || 'Invalid value');
}
}
}
if (onValidationError) {
onValidationError(validationErrors);
}
}
const apiError: ApiError = {
status,
message: data?.detail?.message || data?.message || error.message || 'An error occurred',
detail: typeof data?.detail === 'string' ? data.detail : undefined,
validationErrors: status === 422 ? extractValidationErrors(data) : undefined,
};
// Report API errors to backend error logger (for Forgejo issue creation)
// Skip 401 (auth) and 403 (permission) — these are expected, not bugs
// Report: 422 (validation), 404 (not found), 5xx (server), 0 (network)
if (status !== 401 && status !== 403) {
logError(apiError.message, {
status,
url: error.config?.url,
method: error.config?.method,
detail: apiError.detail,
});
}
return Promise.reject(apiError);
}
);
function extractValidationErrors(data: any): Record<string, string[]> | undefined {
if (!data?.detail || !Array.isArray(data.detail)) return undefined;
const errors: Record<string, string[]> = {};
for (const item of data.detail) {
if (item.loc && item.loc.length > 1) {
const field = item.loc[item.loc.length - 1];
if (!errors[field]) {
errors[field] = [];
}
errors[field].push(item.msg || 'Invalid value');
}
}
return Object.keys(errors).length > 0 ? errors : undefined;
}
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.get<T>(url, config);
return response.data;
}
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.post<T>(url, data, config);
return response.data;
}
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.put<T>(url, data, config);
return response.data;
}
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.patch<T>(url, data, config);
return response.data;
}
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.delete<T>(url, config);
return response.data;
}
export default apiClient;