The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
181 lines
4.7 KiB
Go
181 lines
4.7 KiB
Go
// 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
|
|
}
|