Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user