mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 22:42:52 +08:00
- New shared launch module (web/src/lib/launch): preflight client, model/ exchange resolution, and a single launchAutopilot orchestrator used by both Strategy Studio and the guided launch panel — the two previously duplicated and divergent launch implementations are gone. - Preflight runs BEFORE any strategy mutation, so a failed launch no longer rewrites/activates the strategy as a side effect; readiness and minimums come from the server (live balances) instead of stale client caches. - Guided panel polls preflight every 20s so deposits are detected without manual refresh; step metas show live balances and required minimums; the beginner wallet page polls its balance while the deposit screen is open. - Every failure now routes to a guided fix: hyperliquid-funds gets its own setup anchor (scrolls to the launch panel), start rejections surface the server's preflight reason (including manual restarts from the trader list), and structured error bodies flow through ApiError.errorData. - Terminal dashboard shows a persistent runtime banner when the AI fee wallet runs low/empty or the trader enters safe mode. - Vitest coverage for setup-target routing, readiness helpers, and the launch orchestration order (no side effects before preflight).
332 lines
8.6 KiB
TypeScript
332 lines
8.6 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
|
|
/** Full error response body for endpoints that return structured details. */
|
|
errorData?: Record<string, any>
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
errorKey?: string
|
|
errorParams?: Record<string, string>
|
|
statusCode?: number
|
|
errorData?: Record<string, any>
|
|
|
|
constructor(
|
|
message: string,
|
|
errorKey?: string,
|
|
errorParams?: Record<string, string>,
|
|
statusCode?: number,
|
|
errorData?: Record<string, any>
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.errorKey = errorKey
|
|
this.errorParams = errorParams
|
|
this.statusCode = statusCode
|
|
this.errorData = errorData
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const isTimeout = error.code === 'ECONNABORTED'
|
|
const message = isTimeout
|
|
? 'Request timed out'
|
|
: error.message || 'Network error'
|
|
if (!isSilent) {
|
|
toast.error(isTimeout ? 'Request timed out' : 'Network error', {
|
|
id: 'network-error',
|
|
description: isTimeout
|
|
? 'The upstream service took too long to respond'
|
|
: 'Unable to reach the server',
|
|
})
|
|
}
|
|
throw new Error(message)
|
|
}
|
|
|
|
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
|
|
timeout?: number
|
|
} = {}
|
|
): 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,
|
|
timeout: options.timeout,
|
|
...(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,
|
|
errorData:
|
|
errorData && typeof errorData === 'object' ? errorData : undefined,
|
|
}
|
|
}
|
|
|
|
// 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()
|