package client import ( "context" "log/slog" "time" ) const ( checkInterval = 2 * time.Second minBackoff = 1 * time.Second maxBackoff = 60 * time.Second backoffFactor = 2.0 PongEvent = "PONG" FailedPingEvent = "UNREACHABLE" ) type Observer interface { Update(event string) GetID() string } type RobustQ3Client struct { Client *Q3Client backoff time.Duration attempt uint stop chan bool observers map[string]Observer } func NewRobust(ctx context.Context, client *Q3Client) *RobustQ3Client { c := &RobustQ3Client{ Client: client, backoff: minBackoff, attempt: 0, stop: make(chan bool), observers: make(map[string]Observer), } go c.keepAliveLoop(ctx) return c } func (rc *RobustQ3Client) Stop() error { close(rc.stop) return rc.Client.Close() } func (rc *RobustQ3Client) Ping() error { return rc.Client.Ping() } func (rc *RobustQ3Client) GetInfo() (map[string]string, error) { return rc.Client.GetInfo() } func (rc *RobustQ3Client) GetStatus() (*Status, error) { return rc.Client.GetStatus() } func (rc *RobustQ3Client) Subscribe(o Observer) { rc.observers[o.GetID()] = o } func (rc *RobustQ3Client) Unsubscribe(id string) { delete(rc.observers, id) } func (rc *RobustQ3Client) keepAliveLoop(ctx context.Context) { slog.Info("Starting robust client reconnect loop", "interval", checkInterval) ticker := time.NewTicker(checkInterval) defer ticker.Stop() for { select { case <-ctx.Done(): slog.Info("Exiting robust client reconnect loop", "reason", "context cancelled") return case <-rc.stop: slog.Info("Exiting robust client reconnect loop", "reason", "stop signal") return case <-ticker.C: slog.Debug("Tick; Trying to ping gameserver") rc.tryPing(ctx) } } } func (rc *RobustQ3Client) tryPing(ctx context.Context) { if err := rc.Client.Ping(); err != nil { slog.Warn("No response to gameserver ping", "error", err) rc.notifyObservers(FailedPingEvent) rc.reconnectLoop(ctx) } else { slog.Debug("Pong!") rc.notifyObservers(PongEvent) } } func (rc *RobustQ3Client) reconnectLoop(ctx context.Context) { slog.Debug("Waiting until gameserver is reachable") for { select { case <-ctx.Done(): slog.Info("Exiting reconnect attempt loop", "reason", "context cancelled") return case <-rc.stop: slog.Info("Exiting reconnect attempt loop", "reason", "stop signal") return default: if err := rc.attemptReconnect(ctx); err != nil { rc.notifyObservers(FailedPingEvent) continue } return } } } func (rc *RobustQ3Client) attemptReconnect(ctx context.Context) error { if err := rc.Client.Ping(); err != nil { slog.Warn( "Failed to reach gameserver", "error", err, "backoff", rc.backoff, "attempt", rc.attempt, ) timer := time.NewTimer(rc.backoff) select { case <-ctx.Done(): timer.Stop() case <-rc.stop: timer.Stop() case <-timer.C: rc.incRetryVars() } return err } slog.Info("Successfully reached gameserver", "attempts", rc.attempt) rc.resetRetryVars() return nil } func (rc *RobustQ3Client) incRetryVars() { rc.backoff = min( time.Duration(float64(rc.backoff)*backoffFactor), maxBackoff, ) rc.attempt++ } func (rc *RobustQ3Client) resetRetryVars() { rc.backoff = minBackoff rc.attempt = 0 } func (rc *RobustQ3Client) notifyObservers(event string) { slog.Debug("Notifying all observers", "event", event) for _, o := range rc.observers { o.Update(event) } }