Files
nofx/web/src/lib/httpClient.ts
Zavier d353d8aed9 fix: improve trader error feedback, stale balance cleanup, and claw402 warnings (#1452)
* fix: improve trader error handling and balance validation

* fix: localize structured trader failure reasons

---------

Co-authored-by: apple <apple@MacbookPro-zbh.local>
2026-04-04 23:39:05 +08:00

315 lines
8.0 KiB
TypeScript

/**
* HTTP Client with Axios
*
* Features:
* - Axios-based unified request wrapper
* - Automatic error interception and toast notifications
* - Network errors and system errors are intercepted and shown via toast
* - Only business logic errors are returned to the caller
* - Automatic 401 token expiration handling
*/
import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios'
import { toast } from 'sonner'
/**
* Business response format - only business errors reach the caller
*/
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
}
export class ApiError extends Error {
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
constructor(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
) {
super(message)
this.name = 'ApiError'
this.errorKey = errorKey
this.errorParams = errorParams
this.statusCode = statusCode
}
}
/**
* HTTP Client Class
*/
export class HttpClient {
private axiosInstance: AxiosInstance
private static isHandling401 = false
constructor() {
// Create axios instance
this.axiosInstance = axios.create({
baseURL: '/',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Setup interceptors
this.setupInterceptors()
}
/**
* Reset 401 handling flag (call after successful login)
*/
public reset401Flag(): void {
HttpClient.isHandling401 = false
}
/**
* Setup request and response interceptors
*/
private setupInterceptors(): void {
// Request interceptor - add auth token
this.axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor - handle errors
this.axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// Success response - pass through
return response
},
(error: AxiosError) => {
return this.handleError(error)
}
)
}
/**
* Handle different types of errors
* Network and system errors are intercepted and shown via toast
* Only business errors are returned to caller
*/
private async handleError(error: AxiosError): Promise<any> {
const isSilent = (error.config as any)?.silentError === true
const errorData = error.response?.data as {
error?: string
message?: string
error_key?: string
error_params?: Record<string, string>
} | undefined
const serverMessage = errorData?.error || errorData?.message
// Network error (no response from server)
if (!error.response) {
if (!isSilent) {
toast.error('Network error - Please check your connection', {
id: 'network-error',
description: 'Unable to reach the server',
})
}
throw new Error('Network error')
}
const status = error.response?.status ?? 0
// Handle 401 Unauthorized
if (status === 401) {
if (HttpClient.isHandling401) {
throw new Error('Session expired')
}
HttpClient.isHandling401 = true
// Clean up
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
// Notify global listeners
window.dispatchEvent(new Event('unauthorized'))
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
const returnUrl = window.location.pathname + window.location.search
if (returnUrl !== '/login' && returnUrl !== '/') {
sessionStorage.setItem('returnUrl', returnUrl)
}
sessionStorage.setItem('from401', 'true')
window.location.href = '/login'
// Return pending promise
return new Promise(() => {})
}
throw new Error('Session expired')
}
// Handle 403 Forbidden - system error
if (status === 403) {
if (!isSilent) {
toast.error('Permission Denied', {
id: 'permission-denied',
description: 'You do not have permission to access this resource',
})
}
throw new Error('Permission denied')
}
// Handle 404 Not Found - system error
if (status === 404) {
if (!isSilent) {
toast.error('API Not Found', {
id: `404-${(error.config as any)?.url || 'unknown'}`,
description: 'The requested endpoint does not exist (404)',
})
}
throw new Error('API not found')
}
// Handle 500+ Server Error - system error
if (status >= 500) {
if (serverMessage) {
return Promise.reject(error)
}
if (!isSilent) {
toast.error('Server Error', {
id: 'server-error',
description: 'Please try again later or contact support',
})
}
throw new Error('Server error')
}
// 4xx errors (except 401/403/404) are business logic errors
// Return them to the caller for handling
return Promise.reject(error)
}
/**
* Generic JSON request with standardized response
* System/network errors are already intercepted and shown via toast
* Only business errors are returned
*/
async request<T = any>(
url: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
data?: any
params?: any
headers?: Record<string, string>
silent?: boolean
} = {}
): Promise<ApiResponse<T>> {
try {
const response = await this.axiosInstance.request<T>({
url,
method: options.method || 'GET',
data: options.data,
params: options.params,
headers: options.headers,
...(options.silent && { silentError: true }),
})
// Success
return {
success: true,
data: response.data,
message: (response.data as any)?.message,
}
} catch (error) {
// If we get here, it's a business logic error (4xx except 401/403/404)
// System errors were already intercepted and toasted
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data as any
return {
success: false,
message: errorData?.error || errorData?.message || 'Operation failed',
errorKey: errorData?.error_key,
errorParams: errorData?.error_params,
statusCode: error.response.status,
}
}
// Network error or other exception (already toasted)
throw error
}
}
/**
* GET request
*/
async get<T = any>(
url: string,
params?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'GET', params, headers })
}
/**
* POST request
*/
async post<T = any>(
url: string,
data?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'POST', data, headers })
}
/**
* PUT request
*/
async put<T = any>(
url: string,
data?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'PUT', data, headers })
}
/**
* DELETE request
*/
async delete<T = any>(
url: string,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'DELETE', headers })
}
/**
* PATCH request
*/
async patch<T = any>(
url: string,
data?: any,
headers?: Record<string, string>
): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'PATCH', data, headers })
}
}
// Export singleton instance
export const httpClient = new HttpClient()
// Export helper function to reset 401 flag
export const reset401Flag = () => httpClient.reset401Flag()