From feb8ec96ad7c448ffdf0614938cd56b77df37157 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 17:31:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B2=B4=EC=9D=B8=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=A9=80=ED=8B=B0=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=BC=EC=98=A4=EB=B2=84=20(SPOF=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHAIN_NODE_URLS 환경변수(쉼표 구분)로 복수 노드 지정 가능. Client.Call()이 네트워크/HTTP 오류 시 다음 노드로 자동 전환. RPC 레벨 오류(트랜잭션 실패 등)는 즉시 반환 (페일오버 미적용). 기존 CHAIN_NODE_URL 단일 설정은 하위 호환 유지. Co-Authored-By: Claude Sonnet 4.6 --- internal/chain/client.go | 52 +++++++++++++++++++++++++++++++++------- main.go | 2 +- pkg/config/config.go | 16 +++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/internal/chain/client.go b/internal/chain/client.go index 092df1b..3233dec 100644 --- a/internal/chain/client.go +++ b/internal/chain/client.go @@ -34,21 +34,55 @@ func (e *rpcError) Error() string { } // Client is a JSON-RPC 2.0 client for the TOL Chain node. +// It supports multiple node URLs for failover: on a network/HTTP error the +// client automatically retries against the next URL in the list. +// RPC-level errors (transaction failures, etc.) are returned immediately +// without failover since they indicate a logical error, not node unavailability. type Client struct { - nodeURL string - http *http.Client - idSeq atomic.Int64 + nodeURLs []string + http *http.Client + idSeq atomic.Int64 + next atomic.Uint64 // round-robin index } -func NewClient(nodeURL string) *Client { +// NewClient creates a client for one or more chain node URLs. +// When multiple URLs are provided, failed requests fall over to the next URL. +func NewClient(nodeURLs ...string) *Client { + if len(nodeURLs) == 0 { + panic("chain.NewClient: at least one node URL is required") + } return &Client{ - nodeURL: nodeURL, - http: &http.Client{Timeout: 10 * time.Second}, + nodeURLs: nodeURLs, + http: &http.Client{Timeout: 10 * time.Second}, } } // Call invokes a JSON-RPC method and unmarshals the result into out. +// On network or HTTP errors it tries each node URL once before giving up. func (c *Client) Call(method string, params any, out any) error { + n := len(c.nodeURLs) + start := int(c.next.Load() % uint64(n)) + + var lastErr error + for i := 0; i < n; i++ { + url := c.nodeURLs[(start+i)%n] + err := c.callNode(url, method, params, out) + if err == nil { + return nil + } + // RPC-level error (e.g. tx execution failure): return immediately, + // retrying on another node would give the same result. + if _, isRPC := err.(*rpcError); isRPC { + return err + } + // Network / HTTP error: mark this node as degraded and try the next. + lastErr = err + c.next.Add(1) + } + return fmt.Errorf("all chain nodes unreachable: %w", lastErr) +} + +func (c *Client) callNode(nodeURL, method string, params any, out any) error { reqBody := rpcRequest{ JSONRPC: "2.0", ID: c.idSeq.Add(1), @@ -60,14 +94,14 @@ func (c *Client) Call(method string, params any, out any) error { return fmt.Errorf("marshal RPC request: %w", err) } - resp, err := c.http.Post(c.nodeURL, "application/json", bytes.NewReader(data)) + resp, err := c.http.Post(nodeURL, "application/json", bytes.NewReader(data)) if err != nil { - return fmt.Errorf("RPC network error: %w", err) + return fmt.Errorf("RPC network error (%s): %w", nodeURL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("RPC HTTP error: status %d", resp.StatusCode) + return fmt.Errorf("RPC HTTP error (%s): status %d", nodeURL, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) diff --git a/main.go b/main.go index 2da5f8e..9cae70f 100644 --- a/main.go +++ b/main.go @@ -67,7 +67,7 @@ func main() { authSvc := auth.NewService(authRepo, rdb) authHandler := auth.NewHandler(authSvc) - chainClient := chain.NewClient(config.C.ChainNodeURL) + chainClient := chain.NewClient(config.C.ChainNodeURLs...) chainRepo := chain.NewRepository(db) chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 3fe3b02..f91ed5e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "log" "os" "strconv" + "strings" "github.com/joho/godotenv" ) @@ -26,7 +27,10 @@ type Config struct { GameDir string // Chain integration + // ChainNodeURL은 단일 노드 설정용 (하위 호환). + // ChainNodeURLs는 CHAIN_NODE_URLS(쉼표 구분) 또는 ChainNodeURL에서 파생. ChainNodeURL string + ChainNodeURLs []string ChainID string OperatorKeyHex string WalletEncryptionKey string @@ -74,6 +78,18 @@ func Load() { SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""), SSAFYRedirectURI: getEnv("SSAFY_REDIRECT_URI", ""), } + + // CHAIN_NODE_URLS (쉼표 구분) 우선, 없으면 CHAIN_NODE_URL 단일값 사용 + if raw := getEnv("CHAIN_NODE_URLS", ""); raw != "" { + for _, u := range strings.Split(raw, ",") { + if u = strings.TrimSpace(u); u != "" { + C.ChainNodeURLs = append(C.ChainNodeURLs, u) + } + } + } + if len(C.ChainNodeURLs) == 0 { + C.ChainNodeURLs = []string{C.ChainNodeURL} + } } // WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.