feat: 체인 클라이언트 멀티노드 페일오버 (SPOF 해결)
CHAIN_NODE_URLS 환경변수(쉼표 구분)로 복수 노드 지정 가능. Client.Call()이 네트워크/HTTP 오류 시 다음 노드로 자동 전환. RPC 레벨 오류(트랜잭션 실패 등)는 즉시 반환 (페일오버 미적용). 기존 CHAIN_NODE_URL 단일 설정은 하위 호환 유지. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,21 +34,55 @@ func (e *rpcError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client is a JSON-RPC 2.0 client for the TOL Chain node.
|
// 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 {
|
type Client struct {
|
||||||
nodeURL string
|
nodeURLs []string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
idSeq atomic.Int64
|
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{
|
return &Client{
|
||||||
nodeURL: nodeURL,
|
nodeURLs: nodeURLs,
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call invokes a JSON-RPC method and unmarshals the result into out.
|
// 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 {
|
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{
|
reqBody := rpcRequest{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: c.idSeq.Add(1),
|
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)
|
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 {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
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))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -67,7 +67,7 @@ func main() {
|
|||||||
authSvc := auth.NewService(authRepo, rdb)
|
authSvc := auth.NewService(authRepo, rdb)
|
||||||
authHandler := auth.NewHandler(authSvc)
|
authHandler := auth.NewHandler(authSvc)
|
||||||
|
|
||||||
chainClient := chain.NewClient(config.C.ChainNodeURL)
|
chainClient := chain.NewClient(config.C.ChainNodeURLs...)
|
||||||
chainRepo := chain.NewRepository(db)
|
chainRepo := chain.NewRepository(db)
|
||||||
chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey)
|
chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,10 @@ type Config struct {
|
|||||||
GameDir string
|
GameDir string
|
||||||
|
|
||||||
// Chain integration
|
// Chain integration
|
||||||
|
// ChainNodeURL은 단일 노드 설정용 (하위 호환).
|
||||||
|
// ChainNodeURLs는 CHAIN_NODE_URLS(쉼표 구분) 또는 ChainNodeURL에서 파생.
|
||||||
ChainNodeURL string
|
ChainNodeURL string
|
||||||
|
ChainNodeURLs []string
|
||||||
ChainID string
|
ChainID string
|
||||||
OperatorKeyHex string
|
OperatorKeyHex string
|
||||||
WalletEncryptionKey string
|
WalletEncryptionKey string
|
||||||
@@ -74,6 +78,18 @@ func Load() {
|
|||||||
SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""),
|
SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""),
|
||||||
SSAFYRedirectURI: getEnv("SSAFY_REDIRECT_URI", ""),
|
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.
|
// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults.
|
||||||
|
|||||||
Reference in New Issue
Block a user