refactor(errors): add scoped proxy failures

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-05 02:18:05 +00:00
parent 35db5f6840
commit 0a7c99264b
5 changed files with 133 additions and 52 deletions

View file

@ -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,6 +294,7 @@ 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
// }
@ -370,6 +370,7 @@ func (rl *RateLimiter) Tick() {
// 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{

38
error.go Normal file
View file

@ -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
}

43
error_test.go Normal file
View file

@ -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")
}
}

View file

@ -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
}

View file

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