From 0a7c99264bdb4af75314a47ef050d6214c221153 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 5 Apr 2026 02:18:05 +0000 Subject: [PATCH] refactor(errors): add scoped proxy failures Co-Authored-By: Virgil --- core_impl.go | 39 ++++++++++++++++++++------------------- error.go | 38 ++++++++++++++++++++++++++++++++++++++ error_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ pool/impl.go | 18 +++++++++--------- state_impl.go | 47 +++++++++++++++++++++++------------------------ 5 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 error.go create mode 100644 error_test.go diff --git a/core_impl.go b/core_impl.go index b28ec3e..f621a34 100644 --- a/core_impl.go +++ b/core_impl.go @@ -6,7 +6,6 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" - "errors" "io" "math" "net" @@ -62,12 +61,12 @@ func splitterFactoryForMode(mode string) (func(*Config, *EventBus) Splitter, boo func LoadConfig(path string) (*Config, Result) { data, err := os.ReadFile(path) if err != nil { - return nil, newErrorResult(err) + return nil, newErrorResult(NewScopedError("proxy.config", "read config failed", err)) } config := &Config{} if err := json.Unmarshal(data, config); err != nil { - return nil, newErrorResult(err) + return nil, newErrorResult(NewScopedError("proxy.config", "parse config failed", err)) } config.configPath = path return config, config.Validate() @@ -77,31 +76,31 @@ func LoadConfig(path string) (*Config, Result) { // if result := cfg.Validate(); !result.OK { return result } func (c *Config) Validate() Result { if c == nil { - return newErrorResult(errors.New("config is nil")) + return newErrorResult(NewScopedError("proxy.config", "config is nil", nil)) } if !isValidMode(c.Mode) { - return newErrorResult(errors.New("mode must be \"nicehash\" or \"simple\"")) + return newErrorResult(NewScopedError("proxy.config", "mode must be \"nicehash\" or \"simple\"", nil)) } if !isValidWorkersMode(c.Workers) { - return newErrorResult(errors.New("workers must be one of \"rig-id\", \"user\", \"password\", \"agent\", \"ip\", or \"false\"")) + return newErrorResult(NewScopedError("proxy.config", "workers must be one of \"rig-id\", \"user\", \"password\", \"agent\", \"ip\", or \"false\"", nil)) } if len(c.Bind) == 0 { - return newErrorResult(errors.New("bind list is empty")) + return newErrorResult(NewScopedError("proxy.config", "bind list is empty", nil)) } if len(c.Pools) == 0 { - return newErrorResult(errors.New("pool list is empty")) + return newErrorResult(NewScopedError("proxy.config", "pool list is empty", nil)) } enabledPools := 0 for _, pool := range c.Pools { if pool.Enabled && strings.TrimSpace(pool.URL) == "" { - return newErrorResult(errors.New("enabled pool url is empty")) + return newErrorResult(NewScopedError("proxy.config", "enabled pool url is empty", nil)) } if pool.Enabled { enabledPools++ } } if enabledPools == 0 { - return newErrorResult(errors.New("pool list has no enabled entries")) + return newErrorResult(NewScopedError("proxy.config", "pool list has no enabled entries", nil)) } return newSuccessResult() } @@ -295,9 +294,10 @@ func (cd *CustomDiff) OnLogin(e Event) { } // limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300}) -// if limiter.Allow("203.0.113.42:3333") { -// // first 30 connection attempts per minute are allowed -// } +// +// if limiter.Allow("203.0.113.42:3333") { +// // first 30 connection attempts per minute are allowed +// } func NewRateLimiter(config RateLimit) *RateLimiter { return &RateLimiter{ config: config, @@ -306,9 +306,9 @@ func NewRateLimiter(config RateLimit) *RateLimiter { } } -// if limiter.Allow("203.0.113.42:3333") { -// // hostOnly("203.0.113.42:3333") == "203.0.113.42" -// } +// if limiter.Allow("203.0.113.42:3333") { +// // hostOnly("203.0.113.42:3333") == "203.0.113.42" +// } func (rl *RateLimiter) Allow(ip string) bool { if rl == nil || rl.config.MaxConnectionsPerMinute <= 0 { return true @@ -367,9 +367,10 @@ func (rl *RateLimiter) Tick() { } } -// watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) { -// p.Reload(cfg) -// }) +// watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) { +// p.Reload(cfg) +// }) +// // watcher.Start() // polls once per second and reloads after the file mtime changes func NewConfigWatcher(configPath string, onChange func(*Config)) *ConfigWatcher { watcher := &ConfigWatcher{ diff --git a/error.go b/error.go new file mode 100644 index 0000000..98c35c2 --- /dev/null +++ b/error.go @@ -0,0 +1,38 @@ +package proxy + +// ScopedError carries a stable error scope alongside a human-readable message. +// +// err := proxy.NewScopedError("proxy.config", "load failed", io.EOF) +type ScopedError struct { + Scope string + Message string + Cause error +} + +// NewScopedError creates an error that keeps a greppable scope token in the failure path. +// +// err := proxy.NewScopedError("proxy.server", "listen failed", cause) +func NewScopedError(scope, message string, cause error) error { + return &ScopedError{ + Scope: scope, + Message: message, + Cause: cause, + } +} + +func (e *ScopedError) Error() string { + if e == nil { + return "" + } + if e.Cause == nil { + return e.Scope + ": " + e.Message + } + return e.Scope + ": " + e.Message + ": " + e.Cause.Error() +} + +func (e *ScopedError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..863740f --- /dev/null +++ b/error_test.go @@ -0,0 +1,43 @@ +package proxy + +import ( + "errors" + "testing" +) + +func TestError_NewScopedError_Good(t *testing.T) { + err := NewScopedError("proxy.config", "bind list is empty", nil) + + if err == nil { + t.Fatalf("expected scoped error") + } + if got := err.Error(); got != "proxy.config: bind list is empty" { + t.Fatalf("unexpected scoped error string: %q", got) + } +} + +func TestError_NewScopedError_Bad(t *testing.T) { + cause := errors.New("permission denied") + err := NewScopedError("proxy.config", "read config failed", cause) + + if err == nil { + t.Fatalf("expected scoped error") + } + if !errors.Is(err, cause) { + t.Fatalf("expected errors.Is to unwrap the original cause") + } + if got := err.Error(); got != "proxy.config: read config failed: permission denied" { + t.Fatalf("unexpected wrapped error string: %q", got) + } +} + +func TestError_NewScopedError_Ugly(t *testing.T) { + var scoped *ScopedError + + if got := scoped.Error(); got != "" { + t.Fatalf("expected nil scoped error string to be empty, got %q", got) + } + if scoped.Unwrap() != nil { + t.Fatalf("expected nil scoped error to unwrap to nil") + } +} diff --git a/pool/impl.go b/pool/impl.go index fb36eb3..b29fe98 100644 --- a/pool/impl.go +++ b/pool/impl.go @@ -6,7 +6,6 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" - "errors" "io" "net" "strconv" @@ -53,15 +52,15 @@ func (c *StratumClient) IsActive() bool { // result := client.Connect() func (c *StratumClient) Connect() proxy.Result { if c == nil { - return proxy.Result{OK: false, Error: errors.New("client is nil")} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "client is nil", nil)} } addr := c.config.URL if addr == "" { - return proxy.Result{OK: false, Error: errors.New("pool url is empty")} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "pool url is empty", nil)} } conn, err := net.Dial("tcp", addr) if err != nil { - return proxy.Result{OK: false, Error: err} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "dial pool failed", err)} } if c.config.TLS { host := addr @@ -72,18 +71,18 @@ func (c *StratumClient) Connect() proxy.Result { tlsConn := tls.Client(conn, tlsCfg) if err := tlsConn.Handshake(); err != nil { _ = conn.Close() - return proxy.Result{OK: false, Error: err} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "handshake failed", err)} } if fp := strings.TrimSpace(strings.ToLower(c.config.TLSFingerprint)); fp != "" { cert := tlsConn.ConnectionState().PeerCertificates if len(cert) == 0 { _ = tlsConn.Close() - return proxy.Result{OK: false, Error: errors.New("missing certificate")} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "missing certificate", nil)} } sum := sha256.Sum256(cert[0].Raw) if hex.EncodeToString(sum[:]) != fp { _ = tlsConn.Close() - return proxy.Result{OK: false, Error: errors.New("tls fingerprint mismatch")} + return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "tls fingerprint mismatch", nil)} } } c.conn = tlsConn @@ -211,16 +210,17 @@ func (c *StratumClient) writeJSON(payload any) error { c.sendMu.Lock() defer c.sendMu.Unlock() if c.conn == nil { - return errors.New("connection is nil") + return proxy.NewScopedError("proxy.pool.client", "connection is nil", nil) } data, err := json.Marshal(payload) if err != nil { - return err + return proxy.NewScopedError("proxy.pool.client", "marshal request failed", err) } data = append(data, '\n') _, err = c.conn.Write(data) if err != nil { c.notifyDisconnect() + return proxy.NewScopedError("proxy.pool.client", "write request failed", err) } return err } diff --git a/state_impl.go b/state_impl.go index f683e9e..a110745 100644 --- a/state_impl.go +++ b/state_impl.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "encoding/json" - "errors" "net" "net/http" "reflect" @@ -40,7 +39,7 @@ type MinerSnapshot struct { // } func New(config *Config) (*Proxy, Result) { if config == nil { - return nil, newErrorResult(errors.New("config is nil")) + return nil, newErrorResult(NewScopedError("proxy", "config is nil", nil)) } if result := config.Validate(); !result.OK { return nil, result @@ -192,11 +191,11 @@ func (p *Proxy) Events() *EventBus { // p.Start() // -// go func() { -// time.Sleep(30 * time.Second) -// p.Stop() -// }() -// p.Start() +// go func() { +// time.Sleep(30 * time.Second) +// p.Stop() +// }() +// p.Start() func (p *Proxy) Start() { if p == nil { return @@ -335,10 +334,10 @@ func (p *Proxy) activeMiners() []*Miner { // p.Reload(&proxy.Config{Mode: "simple", Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}}) // -// p.Reload(&proxy.Config{ -// Mode: "simple", -// Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, -// }) +// p.Reload(&proxy.Config{ +// Mode: "simple", +// Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, +// }) func (p *Proxy) Reload(config *Config) { if p == nil || config == nil { return @@ -490,11 +489,11 @@ func buildTLSConfig(cfg TLSConfig) (*tls.Config, Result) { return nil, newSuccessResult() } if cfg.CertFile == "" || cfg.KeyFile == "" { - return nil, newErrorResult(errors.New("tls certificate or key path is empty")) + return nil, newErrorResult(NewScopedError("proxy.tls", "tls certificate or key path is empty", nil)) } cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) if err != nil { - return nil, newErrorResult(err) + return nil, newErrorResult(NewScopedError("proxy.tls", "load certificate failed", err)) } tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} applyTLSProtocols(tlsConfig, cfg.Protocols) @@ -646,7 +645,7 @@ func (p *Proxy) startMonitoringServer() bool { p.httpServer = &http.Server{Addr: addr, Handler: mux} go func() { err := p.httpServer.Serve(listener) - if err != nil && !errors.Is(err, http.ErrServerClosed) { + if err != nil && err != http.ErrServerClosed { p.Stop() } }() @@ -690,7 +689,7 @@ func (p *Proxy) allowMonitoringRequest(r *http.Request) (int, bool) { // AllowMonitoringRequest applies the configured monitoring API access checks. // -// status, ok := p.AllowMonitoringRequest(request) +// status, ok := p.AllowMonitoringRequest(request) func (p *Proxy) AllowMonitoringRequest(r *http.Request) (int, bool) { return p.allowMonitoringRequest(r) } @@ -702,8 +701,8 @@ func (p *Proxy) writeJSONResponse(w http.ResponseWriter, payload any) { // SummaryDocument builds the RFC-shaped /1/summary response body. // -// doc := p.SummaryDocument() -// _ = doc.Results.Accepted +// doc := p.SummaryDocument() +// _ = doc.Results.Accepted func (p *Proxy) SummaryDocument() SummaryDocument { summary := p.Summary() now, max := p.MinerCount() @@ -736,8 +735,8 @@ func (p *Proxy) SummaryDocument() SummaryDocument { // WorkersDocument builds the RFC-shaped /1/workers response body. // -// doc := p.WorkersDocument() -// _ = doc.Workers[0][0] +// doc := p.WorkersDocument() +// _ = doc.Workers[0][0] func (p *Proxy) WorkersDocument() WorkersDocument { records := p.WorkerRecords() rows := make([]WorkerRow, 0, len(records)) @@ -766,8 +765,8 @@ func (p *Proxy) WorkersDocument() WorkersDocument { // MinersDocument builds the RFC-shaped /1/miners response body. // -// doc := p.MinersDocument() -// _ = doc.Miners[0][7] +// doc := p.MinersDocument() +// _ = doc.Miners[0][7] func (p *Proxy) MinersDocument() MinersDocument { records := p.MinerSnapshots() rows := make([]MinerRow, 0, len(records)) @@ -1769,17 +1768,17 @@ func (s *Server) Stop() { func (s *Server) listen() Result { if s == nil { - return newErrorResult(errors.New("server is nil")) + return newErrorResult(NewScopedError("proxy.server", "server is nil", nil)) } if s.listener != nil { return newSuccessResult() } if s.addr.TLS && s.tlsCfg == nil { - return newErrorResult(errors.New("tls listener requires a tls config")) + return newErrorResult(NewScopedError("proxy.server", "tls listener requires a tls config", nil)) } ln, err := net.Listen("tcp", net.JoinHostPort(s.addr.Host, strconv.Itoa(int(s.addr.Port)))) if err != nil { - return newErrorResult(err) + return newErrorResult(NewScopedError("proxy.server", "listen failed", err)) } if s.tlsCfg != nil { ln = tls.NewListener(ln, s.tlsCfg)