check / check (push) Successful in 3m50s
The http.Server was built with only ReadHeaderTimeout set; the other three timeouts were zero, which in net/http means no limit, so a peer could hold a connection open past the header phase, responses had no write deadline, and keep-alive connections were never reaped. ReadTimeout 15s, WriteTimeout 75s, and IdleTimeout 120s now join ReadHeaderTimeout 10s as named constants. WriteTimeout must stay above the 60s chimw.Timeout handler budget, because net/http arms the write deadline once request headers are read; a test fails the build if either number moves alone. The server literal moved into newHTTPServer so the configuration can be asserted without binding a socket (closes #99) Model: opus-5
187 lines
5.1 KiB
Go
187 lines
5.1 KiB
Go
// Package server provides the HTTP server.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"go.uber.org/fx"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/config"
|
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
"sneak.berlin/go/dnswatcher/internal/handlers"
|
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
"sneak.berlin/go/dnswatcher/internal/middleware"
|
|
)
|
|
|
|
// Params contains dependencies for Server.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Middleware *middleware.Middleware
|
|
Handlers *handlers.Handlers
|
|
}
|
|
|
|
// shutdownTimeout is how long to wait for graceful shutdown.
|
|
const shutdownTimeout = 30 * time.Second
|
|
|
|
// Socket-level timeouts for the HTTP server.
|
|
//
|
|
// These bound time spent on the connection itself and are a distinct
|
|
// control from the per-request handler budget enforced by
|
|
// chimw.Timeout(requestTimeout) in routes.go: that one cancels the
|
|
// request context after requestTimeout but never touches the socket,
|
|
// so without the values below a peer can hold a connection open
|
|
// forever (slowloris, unreaped keep-alives).
|
|
//
|
|
// The one hard constraint between the two controls is
|
|
// writeTimeout > requestTimeout. net/http arms the write deadline
|
|
// once the request headers have been read, so on a plaintext
|
|
// connection it covers handler execution AND the response flush. If
|
|
// writeTimeout were <= requestTimeout the server would sever the
|
|
// connection before a handler that legitimately consumed its full
|
|
// budget could emit anything, making the 60s budget unreachable in
|
|
// practice. The margin between them is the response-flush allowance.
|
|
//
|
|
// The only clients of this service are browsers loading the dashboard
|
|
// and a Prometheus scraper; the values are sized for those.
|
|
const (
|
|
// readHeaderTimeout is the max duration for reading request
|
|
// headers.
|
|
readHeaderTimeout = 10 * time.Second
|
|
|
|
// readTimeout bounds reading the entire request, headers plus
|
|
// body. Every route here is a GET with no body, so this only
|
|
// ever needs to cover headers; the extra 5s over
|
|
// readHeaderTimeout is slack, not a real allowance, and keeps a
|
|
// body dribbled one byte at a time from holding the read side
|
|
// open indefinitely.
|
|
readTimeout = 15 * time.Second
|
|
|
|
// writeTimeout must exceed the requestTimeout handler budget
|
|
// (60s) per the note above. The 15s difference is the allowance
|
|
// for flushing a completed response to a slow client.
|
|
writeTimeout = 75 * time.Second
|
|
|
|
// idleTimeout reaps keep-alive connections between requests. It
|
|
// is deliberately longer than the common Prometheus scrape
|
|
// intervals (15s/30s/60s) so the scraper reuses its connection
|
|
// rather than reconnecting every cycle, while a browser tab
|
|
// left open on the dashboard stops occupying a connection
|
|
// within two minutes of going quiet.
|
|
idleTimeout = 120 * time.Second
|
|
)
|
|
|
|
// Server is the HTTP server.
|
|
type Server struct {
|
|
startupTime time.Time
|
|
port int
|
|
log *slog.Logger
|
|
router *chi.Mux
|
|
httpServer *http.Server
|
|
params Params
|
|
mw *middleware.Middleware
|
|
handlers *handlers.Handlers
|
|
}
|
|
|
|
// New creates a new Server instance.
|
|
func New(
|
|
lifecycle fx.Lifecycle,
|
|
params Params,
|
|
) (*Server, error) {
|
|
srv := &Server{
|
|
port: params.Config.Port,
|
|
log: params.Logger.Get(),
|
|
params: params,
|
|
mw: params.Middleware,
|
|
handlers: params.Handlers,
|
|
}
|
|
|
|
lifecycle.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
srv.startupTime = time.Now()
|
|
go srv.Run()
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
return srv.Shutdown(ctx)
|
|
},
|
|
})
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
// newHTTPServer builds the listening http.Server with every
|
|
// socket-level timeout set. All four are set deliberately: a zero
|
|
// value in net/http means "no limit", not "some default".
|
|
func newHTTPServer(
|
|
listenAddr string,
|
|
handler http.Handler,
|
|
) *http.Server {
|
|
return &http.Server{
|
|
Addr: listenAddr,
|
|
Handler: handler,
|
|
ReadTimeout: readTimeout,
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
}
|
|
}
|
|
|
|
// Run starts the HTTP server.
|
|
func (s *Server) Run() {
|
|
s.SetupRoutes()
|
|
|
|
listenAddr := fmt.Sprintf(":%d", s.port)
|
|
s.httpServer = newHTTPServer(listenAddr, s)
|
|
|
|
s.log.Info("http server starting", "addr", listenAddr)
|
|
|
|
err := s.httpServer.ListenAndServe()
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
s.log.Error("http server error", "error", err)
|
|
}
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the server.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
|
|
s.log.Info("shutting down http server")
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(
|
|
ctx, shutdownTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
err := s.httpServer.Shutdown(shutdownCtx)
|
|
if err != nil {
|
|
s.log.Error("http server shutdown error", "error", err)
|
|
|
|
return fmt.Errorf("shutting down http server: %w", err)
|
|
}
|
|
|
|
s.log.Info("http server stopped")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (s *Server) ServeHTTP(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
s.router.ServeHTTP(writer, request)
|
|
}
|