Files
chat/internal/handlers/handlers.go
clawbot 2774be06d4
All checks were successful
check / check (push) Successful in 1m53s
feat: add session idle timeout cleanup goroutine
- Periodic cleanup loop deletes stale clients based on SESSION_IDLE_TIMEOUT
- Orphaned sessions (no clients) are cleaned up automatically
- last_seen already updated on each authenticated request via GetSessionByToken
2026-02-27 05:06:56 -08:00

182 lines
3.4 KiB
Go

// Package handlers provides HTTP request handlers for the chat server.
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"go.uber.org/fx"
)
var errUnauthorized = errors.New("unauthorized")
// Params defines the dependencies for creating Handlers.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
}
const defaultIdleTimeout = 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
cancelCleanup context.CancelFunc
}
// New creates a new Handlers instance.
func New(
lifecycle fx.Lifecycle,
params Params,
) (*Handlers, error) {
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
hdlr.startCleanup(ctx)
return nil
},
OnStop: func(_ context.Context) error {
hdlr.stopCleanup()
return nil
},
})
return hdlr, nil
}
func (hdlr *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
writer.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
writer.WriteHeader(status)
if data != nil {
err := json.NewEncoder(writer).Encode(data)
if err != nil {
hdlr.log.Error(
"json encode error", "error", err,
)
}
}
}
func (hdlr *Handlers) respondError(
writer http.ResponseWriter,
request *http.Request,
msg string,
status int,
) {
hdlr.respondJSON(
writer, request,
map[string]string{"error": msg},
status,
)
}
func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
return defaultIdleTimeout
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid SESSION_IDLE_TIMEOUT, using default",
"value", raw, "error", err,
)
return defaultIdleTimeout
}
return dur
}
func (hdlr *Handlers) startCleanup(ctx context.Context) {
cleanupCtx, cancel := context.WithCancel(ctx)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
}
func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup()
}
}
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
timeout := hdlr.idleTimeout()
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hdlr.runCleanup(ctx, timeout)
case <-ctx.Done():
return
}
}
}
func (hdlr *Handlers) runCleanup(
ctx context.Context,
timeout time.Duration,
) {
cutoff := time.Now().Add(-timeout)
deleted, err := hdlr.params.Database.DeleteStaleSessions(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"session cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale clients",
"deleted", deleted,
)
}
}