package ws import ( "context" "crypto/tls" "crypto/x509" "errors" "io" "math/rand" "net" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) var ErrConnectionLimit = errors.New("ws: connection limit reached") // ReconnectConfig controls Run only. Dial never retries. Zero MaxAttempts and // MaxElapsed mean unlimited retries/time when Enabled is explicitly true. // A stable connection resets both retry budgets; short flaps do not. type ReconnectConfig struct { Enabled bool `json:"enabled"` MinBackoff time.Duration `json:"minBackoff"` MaxBackoff time.Duration `json:"maxBackoff"` StableAfter time.Duration `json:"stableAfter"` MaxAttempts int `json:"maxAttempts"` MaxElapsed time.Duration `json:"maxElapsed"` } type ClientConfig struct { Session SessionConfig `json:"session"` HandshakeTimeout time.Duration `json:"handshakeTimeout"` MaxConnections int `json:"maxConnections"` Subprotocols []string `json:"subprotocols"` RequireSubprotocol bool `json:"requireSubprotocol"` EnableCompression bool `json:"enableCompression"` // ProxyMode is environment (default), direct, or url. ProxyURL may contain // credentials and must not be logged. Explicit proxy URLs ignore NO_PROXY. // Supported proxy schemes are http, socks5, and socks5h (remote DNS). ProxyMode string `json:"proxyMode"` ProxyURL string `json:"proxyUrl"` Reconnect ReconnectConfig `json:"reconnect"` Headers http.Header `json:"-"` // HeaderProvider is called before each handshake. Return a fresh header map; // the client snapshots it. Provider values replace static Headers values. // Implementations must honor context cancellation and support concurrent calls. HeaderProvider func(context.Context) (http.Header, error) `json:"-"` // Prepare creates per-session handlers after a successful handshake. When // set, its result replaces Dial/Run handlers. It must honor cancellation. Prepare func(context.Context) (Handlers, error) `json:"-"` TLSConfig *tls.Config `json:"-"` // ShouldReconnect overrides default error classification, not budgets or // explicit client/context shutdown. It must be safe for concurrent Run calls. ShouldReconnect func(error) bool `json:"-"` } func (cfg ClientConfig) Validate() error { _, _, err := normalizeClientConfig(cfg); return err } func normalizeClientConfig(cfg ClientConfig) (ClientConfig, func(*http.Request) (*url.URL, error), error) { var err error cfg.Session, err = normalizeSessionConfig(cfg.Session) if err != nil { return cfg, nil, err } if cfg.HandshakeTimeout < 0 || cfg.MaxConnections < 0 || cfg.MaxConnections > 65536 || cfg.Reconnect.MinBackoff < 0 || cfg.Reconnect.MaxBackoff < 0 || cfg.Reconnect.StableAfter < 0 || cfg.Reconnect.MaxElapsed < 0 || cfg.Reconnect.MaxAttempts < 0 { return cfg, nil, errors.New("ws: invalid client limits or durations") } if cfg.HandshakeTimeout == 0 { cfg.HandshakeTimeout = 10 * time.Second } if cfg.MaxConnections == 0 { cfg.MaxConnections = 64 } if cfg.Reconnect.MinBackoff == 0 { cfg.Reconnect.MinBackoff = 100 * time.Millisecond } if cfg.Reconnect.MaxBackoff == 0 { cfg.Reconnect.MaxBackoff = 30 * time.Second } if cfg.Reconnect.StableAfter == 0 { cfg.Reconnect.StableAfter = 30 * time.Second } if cfg.Reconnect.MinBackoff > cfg.Reconnect.MaxBackoff { return cfg, nil, errors.New("ws: minBackoff exceeds maxBackoff") } cfg.Headers, err = clientHeaders(cfg.Headers) if err != nil { return cfg, nil, err } if len(cfg.Subprotocols) > 32 { return cfg, nil, errors.New("ws: too many subprotocols") } seen := map[string]bool{} for _, protocol := range cfg.Subprotocols { if !headerToken(protocol) || len(protocol) > 256 || seen[protocol] { return cfg, nil, errors.New("ws: invalid or duplicate subprotocol") } seen[protocol] = true } if cfg.RequireSubprotocol && len(cfg.Subprotocols) == 0 { return cfg, nil, errors.New("ws: required subprotocol list is empty") } cfg.Subprotocols = append([]string(nil), cfg.Subprotocols...) if cfg.TLSConfig != nil { cfg.TLSConfig = cfg.TLSConfig.Clone() } if cfg.ProxyMode != "url" && cfg.ProxyURL != "" { return cfg, nil, errors.New("ws: proxyUrl requires url proxyMode") } var proxy func(*http.Request) (*url.URL, error) switch cfg.ProxyMode { case "", "environment": proxy = func(r *http.Request) (*url.URL, error) { u, err := http.ProxyFromEnvironment(r) if err != nil || u == nil { return u, err } return supportedProxy(u) } case "direct": case "url": u, e := url.Parse(cfg.ProxyURL) if e != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") || !validPort(u) { return cfg, nil, errors.New("ws: invalid proxy URL") } u, e = supportedProxy(u) if e != nil { return cfg, nil, e } proxy = http.ProxyURL(u) default: return cfg, nil, errors.New("ws: invalid proxyMode") } return cfg, proxy, nil } func supportedProxy(u *url.URL) (*url.URL, error) { copyURL := *u switch copyURL.Scheme { case "http", "socks5": case "socks5h": copyURL.Scheme = "socks5" default: return nil, errors.New("ws: proxy scheme must be http, socks5 or socks5h") } return ©URL, nil } func validPort(u *url.URL) bool { if strings.HasSuffix(u.Host, ":") { return false } if p := u.Port(); p != "" { n, err := strconv.Atoi(p) return err == nil && n > 0 && n <= 65535 } return true } func headerToken(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { c := s[i] if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) { continue } return false } return true } func clientHeaders(input http.Header) (http.Header, error) { if len(input) > 128 { return nil, errors.New("ws: too many handshake headers") } out := make(http.Header, len(input)) size := 0 for key, values := range input { name := http.CanonicalHeaderKey(key) if !headerToken(key) || len(key) > 256 { return nil, errors.New("ws: invalid handshake header name") } switch name { case "Host", "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions", "Sec-Websocket-Protocol", "Content-Length", "Transfer-Encoding", "Trailer": return nil, errors.New("ws: reserved handshake header") } if _, ok := out[name]; ok { return nil, errors.New("ws: duplicate handshake header") } for _, value := range values { size += len(key) + len(value) for i := 0; i < len(value); i++ { if value[i] < 32 && value[i] != '\t' || value[i] == 127 { return nil, errors.New("ws: invalid handshake header value") } } } if size > 64<<10 { return nil, errors.New("ws: handshake headers exceed limit") } out[name] = append([]string(nil), values...) } return out, nil } // Client owns its sessions. Concurrent Dial and Run calls are independent and // share only limits and lifecycle. One Run owns one reconnecting connection; // every successful handshake produces a new Session with a new ID. type Client struct { config ClientConfig dialer websocket.Dialer ctx context.Context cancel context.CancelFunc mu sync.Mutex closed bool active int connections int dialAttempts, handshakeFailures, reconnects uint64 sessions map[string]*Session releases map[string]func() done chan struct{} } func NewClient(cfg ClientConfig) (*Client, error) { cfg, proxy, err := normalizeClientConfig(cfg) if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) return &Client{config: cfg, dialer: websocket.Dialer{Proxy: proxy, HandshakeTimeout: cfg.HandshakeTimeout, TLSClientConfig: cfg.TLSConfig, Subprotocols: cfg.Subprotocols, EnableCompression: cfg.EnableCompression}, ctx: ctx, cancel: cancel, sessions: make(map[string]*Session), releases: make(map[string]func()), done: make(chan struct{})}, nil } func (c *Client) begin(connection bool) error { for { c.mu.Lock() if c.closed { c.mu.Unlock() return ErrClosed } if connection && c.connections >= c.config.MaxConnections { // Done may wake a caller before its registry watcher gets CPU time. // Retire finished sessions synchronously before reporting overload. var release func() for id, s := range c.sessions { select { case <-s.Done(): release = c.releases[id] default: } if release != nil { break } } c.mu.Unlock() if release != nil { release() continue } return ErrConnectionLimit } c.active++ if connection { c.connections++ c.dialAttempts++ } c.mu.Unlock() return nil } } func (c *Client) end(connection bool, id string) { c.mu.Lock() defer c.mu.Unlock() if id != "" { delete(c.sessions, id) delete(c.releases, id) } c.active-- if connection { c.connections-- } if c.closed && c.active == 0 { close(c.done) } } // Dial performs exactly one handshake. Its timeout ends at the handshake; // ctx continues to own the resulting session's lifetime. No message is replayed. func (c *Client) Dial(ctx context.Context, endpoint string, handlers Handlers) (*Session, error) { return c.dial(ctx, endpoint, handlers, 0) } func (c *Client) dial(ctx context.Context, endpoint string, handlers Handlers, budget time.Duration) (*Session, error) { if ctx == nil { return nil, errors.New("ws: nil context") } if err := ctx.Err(); err != nil { return nil, err } u, err := url.Parse(endpoint) if err != nil || u == nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" || (u.Scheme != "ws" && u.Scheme != "wss") || !validPort(u) { return nil, errors.New("ws: invalid endpoint") } if err = c.begin(true); err != nil { return nil, err } transferred := false sessionCtx, cancelSession := context.WithCancel(ctx) stopSessionShutdown := context.AfterFunc(c.ctx, cancelSession) defer func() { if !transferred { c.mu.Lock() c.handshakeFailures++ c.mu.Unlock() stopSessionShutdown() cancelSession() c.end(true, "") } }() handshakeTimeout := c.config.HandshakeTimeout if budget > 0 && budget < handshakeTimeout { handshakeTimeout = budget } hctx, cancel := context.WithTimeout(ctx, handshakeTimeout) stop := context.AfterFunc(c.ctx, cancel) defer func() { stop(); cancel() }() headers := c.config.Headers.Clone() if c.config.HeaderProvider != nil { provided, e := provideHeaders(c.config.HeaderProvider, hctx) if e != nil { return nil, &OpError{Op: "headers", Cause: e} } provided, e = clientHeaders(provided) if e != nil { return nil, &OpError{Op: "headers", Cause: e} } for k, v := range provided { headers[k] = v } } dialer := c.dialer // Gorilla applies the context deadline to handshake I/O, but canceling a // deadline-less context after TCP connect does not interrupt its HTTP read. // Retain the raw socket only through the handshake so Close/Stop cancels // stalled HTTP and proxy handshakes immediately, without timing out sessions. var stopHandshake func() bool dialer.NetDialContext = func(dialctx context.Context, network, address string) (net.Conn, error) { conn, err := (&net.Dialer{Timeout: c.config.HandshakeTimeout, KeepAlive: 30 * time.Second}).DialContext(dialctx, network, address) if err == nil { stopHandshake = context.AfterFunc(hctx, func() { _ = conn.Close() }) } return conn, err } if dialer.TLSClientConfig != nil { dialer.TLSClientConfig = dialer.TLSClientConfig.Clone() } conn, response, err := dialer.DialContext(hctx, endpoint, headers) if stopHandshake != nil { stopHandshake() } if err == nil && hctx.Err() != nil { _ = conn.Close() err = hctx.Err() } if err != nil { status := 0 if response != nil { status = response.StatusCode if response.Body != nil { _ = response.Body.Close() } } if ctx.Err() != nil { return nil, ctx.Err() } if c.ctx.Err() != nil { return nil, ErrClosed } if hctx.Err() != nil { err = hctx.Err() } else if deadline, ok := hctx.Deadline(); ok && !time.Now().Before(deadline) { var timed net.Error if errors.As(err, &timed) && timed.Timeout() { err = errors.Join(context.DeadlineExceeded, err) } } return nil, &HandshakeError{StatusCode: status, Cause: err} } if c.config.RequireSubprotocol && conn.Subprotocol() == "" { _ = conn.Close() return nil, &HandshakeError{StatusCode: http.StatusSwitchingProtocols, Cause: websocket.ErrBadHandshake} } if c.config.Prepare != nil { handlers, err = prepareClient(c.config.Prepare, sessionCtx) if err != nil { _ = conn.Close() return nil, &OpError{Op: "prepare", Cause: err} } } c.mu.Lock() if c.closed { c.mu.Unlock() _ = conn.Close() return nil, ErrClosed } s := newSession(conn, c.config.Session, handlers, sessionCtx) c.sessions[s.ID()] = s var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { cancelSession(); c.end(true, s.ID()) }) } c.releases[s.ID()] = release transferred = true stopSessionShutdown() c.mu.Unlock() s.start() go func() { <-s.Done(); release() }() return s, nil } func provideHeaders(fn func(context.Context) (http.Header, error), ctx context.Context) (headers http.Header, err error) { defer func() { if recover() != nil { err = errors.New("ws: header provider panicked") } }() return fn(ctx) } func prepareClient(fn func(context.Context) (Handlers, error), ctx context.Context) (handlers Handlers, err error) { defer func() { if recover() != nil { err = errors.New("ws: prepare callback panicked") } }() return fn(ctx) } // Run blocks until canceled, closed, or retry policy/budget stops it. Reconnect // must be enabled explicitly. Handlers are reused; OnOpen must restore any // application subscriptions. Credentials are refreshed by HeaderProvider. func (c *Client) Run(ctx context.Context, endpoint string, handlers Handlers) error { if ctx == nil { return errors.New("ws: nil context") } if err := c.begin(false); err != nil { return err } defer c.end(false, "") runctx, cancel := context.WithCancel(ctx) stop := context.AfterFunc(c.ctx, cancel) defer func() { stop(); cancel() }() attempts := 0 episode := time.Now() var previousError error = context.DeadlineExceeded repeat := false for { var remaining time.Duration if c.config.Reconnect.Enabled && c.config.Reconnect.MaxElapsed > 0 { remaining = c.config.Reconnect.MaxElapsed - time.Since(episode) if remaining <= 0 { return previousError } } started := time.Now() if repeat { c.mu.Lock() c.reconnects++ c.mu.Unlock() } repeat = true s, err := c.dial(runctx, endpoint, handlers, remaining) if err == nil { started = time.Now() err = s.Wait(runctx) if time.Since(started) >= c.config.Reconnect.StableAfter { attempts = 0 episode = time.Now() } } if ctx.Err() != nil { return ctx.Err() } if c.ctx.Err() != nil { return nil } if !c.config.Reconnect.Enabled || !c.retryable(err) { return err } previousError = err rc := c.config.Reconnect if rc.MaxAttempts > 0 && attempts >= rc.MaxAttempts { return err } delay := retryBackoff(rc, attempts) if rc.MaxElapsed > 0 && time.Since(episode)+delay >= rc.MaxElapsed { return err } attempts++ timer := time.NewTimer(delay) select { case <-runctx.Done(): timer.Stop() if ctx.Err() != nil { return ctx.Err() } return nil case <-timer.C: } } } func retryBackoff(cfg ReconnectConfig, attempt int) time.Duration { d := cfg.MinBackoff for i := 0; i < attempt && d < cfg.MaxBackoff; i++ { if d > cfg.MaxBackoff/2 { d = cfg.MaxBackoff } else { d *= 2 } } if d > 1 { d = d/2 + time.Duration(rand.Int63n(int64(d-d/2))+1) } return d } func (c *Client) retryable(err error) bool { if err == nil || errors.Is(err, ErrClosed) || errors.Is(err, context.Canceled) { return false } if c.config.ShouldReconnect != nil { return c.config.ShouldReconnect(err) } var handshake *HandshakeError if errors.As(err, &handshake) { switch handshake.StatusCode { case 429, 500, 502, 503, 504: return true case 0: default: return false } if errors.Is(err, websocket.ErrBadHandshake) { return false } } var unknown x509.UnknownAuthorityError var invalid x509.CertificateInvalidError var hostname x509.HostnameError if errors.As(err, &unknown) || errors.As(err, &invalid) || errors.As(err, &hostname) { return false } var closeError *CloseError if errors.As(err, &closeError) { switch closeError.Code { case 1001, 1006, 1011, 1012, 1013: return true default: return false } } var network net.Error if errors.As(err, &network) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return true } return handshake != nil || errors.Is(err, ErrNotConnected) } // Close is idempotent and nonblocking: it rejects new work, cancels handshakes // and reconnect waits, and requests session close. Drain waits for all work, // including user callbacks. A callback that ignores cancellation cannot be killed. func (c *Client) Close() error { c.mu.Lock() if c.closed { c.mu.Unlock() return nil } c.closed = true sessions := make([]*Session, 0, len(c.sessions)) for _, s := range c.sessions { sessions = append(sessions, s) } if c.active == 0 { close(c.done) } c.mu.Unlock() c.cancel() for _, s := range sessions { _ = s.Close(1001, "client shutdown") } return nil } func (c *Client) Stop(context.Context) error { return c.Close() } func (c *Client) Drain(ctx context.Context) error { if ctx == nil { return errors.New("ws: nil context") } select { case <-c.done: return nil case <-ctx.Done(): c.mu.Lock() sessions := make([]*Session, 0, len(c.sessions)) for _, s := range c.sessions { sessions = append(sessions, s) } c.mu.Unlock() for _, s := range sessions { s.Abort() } return ctx.Err() } } func (c *Client) Shutdown(ctx context.Context) error { _ = c.Close(); return c.Drain(ctx) } type ClientStats struct { ActiveConnections int Handshakes int Running int Closed bool // DialAttempts includes admitted attempts canceled before the HTTP handshake. DialAttempts uint64 HandshakeFailures uint64 // Reconnects counts subsequent Dial attempts made by Run, successful or not. Reconnects uint64 } func (c *Client) Stats() ClientStats { c.mu.Lock() defer c.mu.Unlock() return ClientStats{ActiveConnections: len(c.sessions), Handshakes: c.connections - len(c.sessions), Running: c.active - c.connections, Closed: c.closed, DialAttempts: c.dialAttempts, HandshakeFailures: c.handshakeFailures, Reconnects: c.reconnects} }