// Package server owns the HTTP server lifecycle and the route table. package server import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/globals" "sneak.berlin/go/simplexcalc/internal/handlers" "sneak.berlin/go/simplexcalc/internal/logger" "sneak.berlin/go/simplexcalc/internal/middleware" "sneak.berlin/go/simplexcalc/internal/telemetry" ) // Params defines dependencies for Server. type Params struct { fx.In Logger *logger.Logger Globals *globals.Globals Config *config.Config Middleware *middleware.Middleware Handlers *handlers.Handlers Metrics *telemetry.Metrics } // Server is the HTTP server and its lifecycle state. type Server struct { params Params log *slog.Logger mw *middleware.Middleware h *handlers.Handlers metrics *telemetry.Metrics httpServer *http.Server listener net.Listener router *chi.Mux serveErr chan error } // New creates the Server and hooks it into the fx lifecycle. // // The listener is opened during OnStart, not in a goroutine after it: // binding is the part that fails (port in use, permission denied), and // a failure there has to be a failure to start. A server that logs // "listen failed" from a goroutine while fx reports a successful // startup is a process that is up and serving nothing. func New(lc fx.Lifecycle, params Params) (*Server, error) { s := &Server{ params: params, log: params.Logger.Get(), mw: params.Middleware, h: params.Handlers, metrics: params.Metrics, serveErr: make(chan error, 1), } lc.Append(fx.Hook{ OnStart: s.start, OnStop: s.stop, }) return s, nil } // Addr returns the address actually bound, which is what a test that // asked for port 0 needs. func (s *Server) Addr() string { if s.listener == nil { return "" } return s.listener.Addr().String() } // Handler exposes the router so tests can drive it with // httptest.NewRecorder without binding a port. func (s *Server) Handler() http.Handler { return s.router } // ServeHTTP dispatches requests through the router. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } // start builds the routes, binds, and begins serving. func (s *Server) start(ctx context.Context) error { s.SetupRoutes() addr := ":" + strconv.Itoa(s.params.Config.Port) s.httpServer = s.newHTTPServer(addr) // ListenConfig rather than net.Listen: the start context bounds // name resolution and the bind, so a start that cannot bind fails // within fx's start timeout instead of hanging inside it. var lc net.ListenConfig ln, err := lc.Listen(ctx, "tcp", addr) if err != nil { return fmt.Errorf("listening on %s: %w", addr, err) } s.listener = ln s.log.Info("http listening", "addr", ln.Addr().String(), "debug", s.params.Config.Debug, "metrics_protected", s.metrics.AuthRequired(), ) go func() { serveErr := s.httpServer.Serve(ln) if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { s.log.Error("http serve failed", "error", serveErr) s.serveErr <- serveErr return } s.serveErr <- nil }() return nil } // stop drains in-flight requests, bounded by the stop context. // // The bound is whichever of the two comes first: the deadline fx gives // this hook, or the configured grace period. Taking the minimum is the // point — a shutdown that outlives the context it was given is a // shutdown the supervisor kills with SIGKILL, which is precisely the // abrupt termination the draining was supposed to avoid. // // Past the deadline, Shutdown returns and every remaining connection is // closed. That is a deliberate choice of a bounded, reported failure // over an unbounded wait. func (s *Server) stop(ctx context.Context) error { if s.httpServer == nil { return nil } grace := s.params.Config.ShutdownGrace stopCtx, cancel := context.WithTimeout(ctx, grace) defer cancel() s.log.Info("http shutting down", "grace", grace.String()) start := time.Now() err := s.httpServer.Shutdown(stopCtx) if err != nil { // Connections were still open at the deadline. Report it — // silence here would hide requests that were cut off — but do // not fail the stop sequence: there is nothing left to retry // and the rest of the graph still has to close cleanly. s.log.Error("http shutdown did not drain in time", "error", err, "waited", time.Since(start).Round(time.Millisecond).String(), ) return nil } s.log.Info("http shutdown complete", "waited", time.Since(start).Round(time.Millisecond).String(), ) return nil }