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>
This commit is contained in:
Zavier
2026-04-01 22:10:29 +08:00
committed by GitHub
parent 9a80f1d88d
commit 0c1f438cc3
7 changed files with 807 additions and 77 deletions
+37 -6
View File
@@ -8,6 +8,25 @@ import (
"nofx/logger"
)
type APIErrorResponse struct {
Error string `json:"error"`
ErrorKey string `json:"error_key,omitempty"`
ErrorParams map[string]string `json:"error_params,omitempty"`
}
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
resp := APIErrorResponse{
Error: publicMsg,
}
if errorKey != "" {
resp.ErrorKey = errorKey
}
if len(errorParams) > 0 {
resp.ErrorParams = errorParams
}
c.JSON(statusCode, resp)
}
// SafeError returns a safe error message without exposing internal details
// It logs the actual error for debugging but returns a generic message to the client
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
@@ -16,34 +35,46 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
}
c.JSON(statusCode, gin.H{"error": publicMsg})
writeAPIError(c, statusCode, publicMsg, "", nil)
}
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
if internalErr != nil {
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
}
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
}
// SafeInternalError logs internal error and returns a generic message
func SafeInternalError(c *gin.Context, operation string, err error) {
logger.Errorf("[Internal Error] %s: %v", operation, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil)
}
// SafeBadRequest returns a safe bad request error
// For validation errors, we can be more specific since they're about user input
func SafeBadRequest(c *gin.Context, msg string) {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
}
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
}
// SafeNotFound returns a generic not found error
func SafeNotFound(c *gin.Context, resource string) {
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil)
}
// SafeUnauthorized returns unauthorized error
func SafeUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
}
// SafeForbidden returns forbidden error
func SafeForbidden(c *gin.Context, msg string) {
c.JSON(http.StatusForbidden, gin.H{"error": msg})
writeAPIError(c, http.StatusForbidden, msg, "", nil)
}
// IsSensitiveError checks if an error message contains sensitive information
+383 -30
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -10,6 +11,7 @@ import (
"nofx/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AI trader management related structures
@@ -52,22 +54,265 @@ type UpdateTraderRequest struct {
SystemPromptTemplate string `json:"system_prompt_template"`
}
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
}
func missingExchangeFields(exchange *store.Exchange) []string {
if exchange == nil {
return nil
}
var missing []string
switch exchange.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
case "okx", "bitget", "kucoin":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
if exchange.Passphrase == "" {
missing = append(missing, "Passphrase")
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "私钥")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
missing = append(missing, "Aster User")
}
if strings.TrimSpace(exchange.AsterSigner) == "" {
missing = append(missing, "Aster Signer")
}
if exchange.AsterPrivateKey == "" {
missing = append(missing, "Aster Private Key")
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
}
}
return missing
}
func mapStringPairs(kv ...string) map[string]string {
if len(kv) == 0 {
return nil
}
params := make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
params[kv[i]] = kv[i+1]
}
return params
}
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
}
switch exchange.ExchangeType {
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
}
}
func classifyTraderSetupReason(reason string) (string, string) {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return "", ""
}
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
default:
return "trader.reason.unknown", trimmed
}
}
func humanizeTraderSetupReason(reason string) string {
_, message := classifyTraderSetupReason(reason)
return message
}
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
params := mapStringPairs(kv...)
rawReason := SanitizeError(err, fallback)
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
if reasonMessage == "" && fallback != "" {
reasonMessage = fallback
}
if reasonMessage != "" {
if params == nil {
params = map[string]string{}
}
params["reason"] = reasonMessage
}
if reasonKey != "" {
if params == nil {
params = map[string]string{}
}
params["reason_key"] = reasonKey
}
return params
}
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
}
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
}
// handleCreateTrader Create new AI trader
func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
c.JSON(http.StatusBadRequest, gin.H{"error": "BTC/ETH leverage must be between 1-50x"})
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
return
}
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Altcoin leverage must be between 1-20x"})
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
return
}
@@ -77,12 +322,61 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid symbol format: %s, must end with USDT", symbol)})
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
}
}
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
return
}
if req.StrategyID != "" {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
}
// Generate trader ID (use short UUID prefix for readability)
exchangeIDShort := req.ExchangeID
if len(exchangeIDShort) > 8 {
@@ -127,7 +421,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
actualBalance := req.InitialBalance // Default to use user input
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
logger.Infof("⚠️ Failed to get exchange config, using user input for initial balance: %v", err)
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
// Find matching exchange configuration
@@ -139,15 +437,22 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
}
}
if exchangeCfg == nil {
logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID)
} else if !exchangeCfg.Enabled {
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
} else {
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
return
}
{
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
} else {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
} else if tempTrader != nil {
// Query actual balance
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
@@ -193,27 +498,48 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
SafeInternalError(c, "Failed to create trader", err)
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
return
}
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
// Immediately load new trader into TraderManager
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
startupWarning := ""
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
// Don't return error here since trader was successfully created in database
startupWarning = describeTraderCreationWarning(req.Name, err)
}
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
if startupWarning == "" {
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
}
}
if startupWarning == "" {
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
startupWarning = describeTraderCreationWarning(req.Name, getErr)
}
}
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
})
}
@@ -291,6 +617,17 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
strategyID = existingTrader.StrategyID
}
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
initialBalance := existingTrader.InitialBalance
if req.InitialBalance > 0 {
initialBalance = req.InitialBalance
}
if resetInitialBalance {
initialBalance = 0
}
// Update trader configuration
traderRecord := &store.Trader{
ID: traderID,
@@ -299,7 +636,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: strategyID, // Associated strategy ID
InitialBalance: req.InitialBalance,
InitialBalance: initialBalance,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
@@ -331,6 +668,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
return
}
if resetInitialBalance {
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
SafeInternalError(c, "Failed to reset trader initial balance", err)
return
}
}
// Remove old trader from memory first (this also stops if running)
s.traderManager.RemoveTrader(traderID)
@@ -396,11 +741,15 @@ func (s *Server) handleStartTrader(c *gin.Context) {
traderID := c.Param("id")
// Verify trader belongs to current user
_, err := s.store.Trader().GetFullConfig(userID, traderID)
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
traderName := traderID
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
traderName = fullCfg.Trader.Name
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
@@ -419,45 +768,49 @@ func (s *Server) handleStartTrader(c *gin.Context) {
logger.Infof("🔄 Loading trader %s from database...", traderID)
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
logger.Infof("❌ Failed to load user traders: %v", loadErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
// Check detailed reason
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
if fullCfg != nil && fullCfg.Trader != nil {
// Check strategy
if fullCfg.Strategy == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
return
}
// Check AI model
if fullCfg.AIModel == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
}
// Check if there's a specific load error
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
return
}
+275 -19
View File
@@ -21,6 +21,7 @@ import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import {
AlertTriangle,
Bot,
Plus,
MessageCircle,
@@ -33,6 +34,7 @@ import {
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
import { ApiError } from '../../lib/httpClient'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
@@ -59,6 +61,189 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') {
return error.message
}
return fallback
}
const formatActionableDescriptionByKey = (
errorKey: string,
params: Record<string, string> = {},
fallback: string
) => {
const traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(params.reason_key, params.reason || fallback)
const symbol = params.symbol || ''
const zh = language === 'zh'
switch (errorKey) {
case 'trader.create.invalid_request':
return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.'
case 'trader.create.invalid_btc_eth_leverage':
return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.'
case 'trader.create.invalid_altcoin_leverage':
return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.'
case 'trader.create.invalid_symbol':
return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
case 'trader.create.model_not_found':
return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
case 'trader.create.model_disabled':
return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
case 'trader.create.model_missing_credentials':
return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
case 'trader.create.strategy_required':
return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
case 'trader.create.strategy_not_found':
return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.'
case 'trader.create.exchange_not_found':
return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
case 'trader.create.exchange_disabled':
return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
case 'trader.create.exchange_missing_fields':
return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
case 'trader.create.exchange_unsupported':
return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
case 'trader.create.exchange_probe_failed':
return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
case 'trader.start.strategy_missing':
return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.`
case 'trader.start.model_not_found':
return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
case 'trader.start.model_disabled':
return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
case 'trader.start.exchange_not_found':
return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
case 'trader.start.exchange_disabled':
return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
case 'trader.start.setup_invalid':
case 'trader.start.load_failed':
return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}`
default:
return fallback
}
}
const localizeTraderReason = (reasonKey?: string, fallback?: string) => {
const zh = language === 'zh'
switch (reasonKey) {
case 'trader.reason.strategy_config_invalid':
return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed'
case 'trader.reason.strategy_missing':
return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration'
case 'trader.reason.private_key_invalid':
return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized'
case 'trader.reason.hyperliquid_init_failed':
return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
case 'trader.reason.aster_init_failed':
return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
case 'trader.reason.exchange_meta_unavailable':
return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange'
case 'trader.reason.hyperliquid_agent_balance_too_high':
return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
case 'trader.reason.exchange_account_init_failed':
return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match'
case 'trader.reason.exchange_unsupported':
return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization'
case 'trader.reason.exchange_balance_unavailable':
return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange'
case 'trader.reason.exchange_service_unreachable':
return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now'
default:
return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system')
}
}
const normalizeActionableDescription = (error: unknown, message: string, title: string) => {
if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message)
}
const prefixes = [
'这次未能创建机器人:',
'机器人创建失败:',
'这次未能更新机器人:',
'机器人更新失败:',
'这次未能启动机器人:',
'Failed to create trader:',
'Failed to update trader:',
'Unable to create trader:',
'Unable to update trader:',
'Unable to start trader:',
]
let description = message.trim()
if (description === title) return ''
for (const prefix of prefixes) {
if (description.startsWith(prefix)) {
description = description.slice(prefix.length).trim()
break
}
}
return description
}
const showActionableError = (title: string, error: unknown) => {
const message = getErrorMessage(error, title)
const description = normalizeActionableDescription(error, message, title)
if (description === '') {
toast.error(title)
return
}
toast.error(title, {
description,
})
}
const parseBalanceUsdc = (balance?: string) => {
if (!balance) return null
const parsed = Number.parseFloat(balance)
return Number.isFinite(parsed) ? parsed : null
}
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => {
if (language === 'zh') {
return blocking
? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDC,AI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
: `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC,虽然还能尝试启动,但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。`
}
return blocking
? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.`
: `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.`
}
const getClaw402BalanceIssue = (traderId: string) => {
const trader = traders?.find((item) => item.trader_id === traderId)
if (!trader) return null
const model =
allModels.find((item) => item.id === trader.ai_model) ||
allModels.find((item) => item.provider === trader.ai_model)
if (!model || model.provider !== 'claw402') return null
const balance = parseBalanceUsdc(model.balanceUsdc)
if (balance === null) return null
if (balance <= 0) {
return {
blocking: true,
title: language === 'zh' ? '启动失败' : 'Start failed',
description: getClaw402BalanceMessage(balance, true),
}
}
if (balance < 1) {
return {
blocking: false,
title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance',
description: getClaw402BalanceMessage(balance, false),
}
}
return null
}
const navigateInApp = (path: string) => {
navigate(path)
@@ -180,6 +365,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || []
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc)
const claw402BalanceAlert =
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1
? {
blocking: enabledClaw402Balance <= 0,
title:
language === 'zh'
? enabledClaw402Balance <= 0
? 'Claw402 钱包余额为 0'
: 'Claw402 钱包余额偏低'
: enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0),
}
: null
const enabledExchanges =
allExchanges?.filter((e) => {
if (!e.enabled) return false
@@ -237,26 +439,19 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const handleCreateTrader = async (data: CreateTraderRequest) => {
try {
const model = allModels?.find((m) => m.id === data.ai_model_id)
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
if (!model?.enabled) {
toast.error(t('modelNotConfigured', language))
return
const createdTrader = await api.createTrader(data)
if (createdTrader.startup_warning) {
toast.success(t('aiTradersToast.created', language), {
description: createdTrader.startup_warning,
})
} else {
toast.success(t('aiTradersToast.created', language))
}
if (!exchange?.enabled) {
toast.error(t('exchangeNotConfigured', language))
return
}
await api.createTrader(data)
toast.success(t('aiTradersToast.created', language))
setShowCreateModal(false)
await mutateTraders()
} catch (error) {
console.error('Failed to create trader:', error)
toast.error(t('createTraderFailed', language))
showActionableError(t('createTraderFailed', language), error)
}
}
@@ -306,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders()
} catch (error) {
console.error('Failed to update trader:', error)
toast.error(t('updateTraderFailed', language))
showActionableError(t('updateTraderFailed', language), error)
}
}
@@ -331,16 +526,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
try {
if (running) {
await api.stopTrader(traderId)
toast.success(t('aiTradersToast.stopped', language))
toast.success(t('aiTradersToast.stopped', language))
} else {
const claw402Issue = getClaw402BalanceIssue(traderId)
if (claw402Issue?.blocking) {
toast.error(claw402Issue.title, {
description: claw402Issue.description,
})
return
}
if (claw402Issue && !claw402Issue.blocking) {
toast.warning(claw402Issue.title, {
description: claw402Issue.description,
})
}
await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language))
toast.success(t('aiTradersToast.started', language))
}
await mutateTraders()
} catch (error) {
console.error('Failed to toggle trader:', error)
toast.error(t('operationFailed', language))
showActionableError(
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language),
error
)
}
}
@@ -770,6 +980,52 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
/>
) : null}
{claw402BalanceAlert ? (
<div
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 rounded-full p-2"
style={{
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}}
>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<div
className="text-sm font-semibold"
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
>
{claw402BalanceAlert.title}
</div>
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
{claw402BalanceAlert.description}
</div>
</div>
</div>
<button
type="button"
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)',
}}
>
{language === 'zh' ? '查看 AI 钱包' : 'Open AI wallet'}
</button>
</div>
) : null}
{/* Configuration Status Grid */}
<ConfigStatusGrid
configuredModels={configuredModels}
+41 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../../types'
import type { AIModel, Exchange, CreateTraderRequest, ExchangeAccountStateResponse, Strategy } from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { toast } from 'sonner'
@@ -124,32 +124,62 @@ export function TraderConfigModal({
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleExchangeChange = (exchangeId: string) => {
setBalanceFetchError('')
setFormData((prev) => {
if (prev.exchange_id === exchangeId) {
return prev
}
const next: FormState = { ...prev, exchange_id: exchangeId }
// Exchange balance belongs to the selected exchange, not the trader record.
// Clear the old baseline so we don't carry Exchange B's balance into Exchange A.
if (isEditMode) {
next.initial_balance = undefined
}
return next
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
if (!isEditMode) {
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
if (!formData.exchange_id) {
setBalanceFetchError(t('balanceFetchFailed', language))
return
}
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const result = await httpClient.get<{
total_equity?: number
balance?: number
}>(`/api/account?trader_id=${traderData.trader_id}`)
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
if (result.success && result.data) {
const selectedState = result.data?.states?.[formData.exchange_id]
if (result.success && selectedState?.status === 'ok') {
const currentBalance =
result.data.total_equity || result.data.balance || 0
selectedState.total_equity ??
selectedState.available_balance ??
0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success(t('balanceFetched', language))
} else {
throw new Error(result.message || t('balanceFetchFailed', language))
setBalanceFetchError(
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
)
}
} catch (error) {
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(t('balanceFetchNetworkError', language))
setBalanceFetchError(
error instanceof Error && error.message
? error.message
: t('balanceFetchNetworkError', language)
)
} finally {
setIsFetchingBalance(false)
}
@@ -176,8 +206,6 @@ export function TraderConfigModal({
}
await onSave(saveData)
toast.success(t('saveSuccess', language))
onClose()
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
} finally {
@@ -269,9 +297,7 @@ export function TraderConfigModal({
</label>
<NofxSelect
value={formData.exchange_id}
onChange={(val) =>
handleInputChange('exchange_id', val)
}
onChange={handleExchangeChange}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={availableExchanges.map((exchange) => ({
value: exchange.id,
+34 -3
View File
@@ -4,6 +4,16 @@ import type {
CreateTraderRequest,
} from '../../types'
import { API_BASE, httpClient } from './helpers'
import { ApiError } from '../httpClient'
function throwApiError(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
): never {
throw new ApiError(message, errorKey, errorParams, statusCode)
}
export const traderApi = {
async getTraders(): Promise<TraderInfo[]> {
@@ -23,7 +33,14 @@ export const traderApi = {
`${API_BASE}/traders`,
request
)
if (!result.success) throw new Error('Failed to create trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to create trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
return result.data!
},
@@ -36,7 +53,14 @@ export const traderApi = {
const result = await httpClient.post(
`${API_BASE}/traders/${traderId}/start`
)
if (!result.success) throw new Error('Failed to start trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to start trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
},
async stopTrader(traderId: string): Promise<void> {
@@ -88,7 +112,14 @@ export const traderApi = {
`${API_BASE}/traders/${traderId}`,
request
)
if (!result.success) throw new Error('Failed to update trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to update trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
return result.data!
},
}
+36 -4
View File
@@ -19,6 +19,28 @@ 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
}
}
/**
@@ -86,6 +108,13 @@ export class HttpClient {
*/
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) {
@@ -98,10 +127,7 @@ export class HttpClient {
throw new Error('Network error')
}
const { status } = error.response as AxiosResponse<{
error?: string
message?: string
}>
const status = error.response?.status ?? 0
// Handle 401 Unauthorized
if (status === 401) {
@@ -159,6 +185,9 @@ export class HttpClient {
// Handle 500+ Server Error - system error
if (status >= 500) {
if (serverMessage) {
return Promise.reject(error)
}
if (!isSilent) {
toast.error('Server Error', {
id: 'server-error',
@@ -212,6 +241,9 @@ export class HttpClient {
return {
success: false,
message: errorData?.error || errorData?.message || 'Operation failed',
errorKey: errorData?.error_key,
errorParams: errorData?.error_params,
statusCode: error.response.status,
}
}
+1
View File
@@ -97,6 +97,7 @@ export interface TraderInfo {
ai_model: string
exchange_id?: string
is_running?: boolean
startup_warning?: string
show_in_competition?: boolean
strategy_id?: string
strategy_name?: string