Files
nofx/web/src/lib/httpClient.ts
tinkle-community a744328313 feat: unify autopilot launch on server preflight with live balance polling
- 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).
2026-07-05 20:38:51 +09:00

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()