package httpx import ( "context" "errors" "log/slog" "net" "net/http" "time" ) // Timeouts configures a listener's deadlines. // // WriteTimeout is deliberately optional and left at zero for the static // listener: a global write deadline covers the whole response, so a large file // over a slow link gets its connection torn down mid-download even though // nothing is wrong. Per-response deadlines belong to the handler, via // http.ResponseController. type Timeouts struct { ReadHeader time.Duration Read time.Duration Idle time.Duration Write time.Duration // 0 = none } // Server is an http.Server whose listener is already bound. // // Binding at construction time means a port conflict is reported before any // background work starts, and it lets a caller pass ":0" and read back the // chosen address — which is what the integration tests do. type Server struct { Name string srv *http.Server ln net.Listener log *slog.Logger } // Listen binds addr and prepares a server for h. func Listen(name, addr string, h http.Handler, t Timeouts, log *slog.Logger) (*Server, error) { ln, err := net.Listen("tcp", addr) if err != nil { return nil, err } s := &Server{ Name: name, ln: ln, log: log, srv: &http.Server{ Handler: h, ReadHeaderTimeout: t.ReadHeader, ReadTimeout: t.Read, IdleTimeout: t.Idle, WriteTimeout: t.Write, // Route net/http's own errors (malformed requests, TLS handshake // failures) into the structured log rather than bare stderr. ErrorLog: slog.NewLogLogger(log.With("listener", name).Handler(), slog.LevelWarn), }, } return s, nil } // Addr is the address actually bound, which differs from the requested one when // port 0 was asked for. func (s *Server) Addr() string { return s.ln.Addr().String() } // Serve blocks until the server stops. It returns nil on a graceful shutdown. func (s *Server) Serve() error { err := s.srv.Serve(s.ln) if errors.Is(err, http.ErrServerClosed) { return nil } return err } // Shutdown stops accepting connections and waits for in-flight requests, up to // ctx's deadline. Past the deadline the remaining connections are closed. func (s *Server) Shutdown(ctx context.Context) error { err := s.srv.Shutdown(ctx) if err != nil { // Shutdown only fails by running out of time; Close is then the only way // to release the port. s.log.Warn("graceful shutdown timed out, closing connections", "listener", s.Name, "err", err) return s.srv.Close() } return nil } // Group runs several servers with a shared lifetime: if one fails, all stop. type Group struct { Servers []*Server Grace time.Duration Log *slog.Logger } // Run serves until ctx is cancelled or a server fails, then shuts every server // down within Grace. It returns the first non-nil error. func (g *Group) Run(ctx context.Context) error { errs := make(chan error, len(g.Servers)) for _, s := range g.Servers { go func() { g.Log.Info("listening", "listener", s.Name, "addr", s.Addr()) errs <- s.Serve() }() } var first error done := 0 select { case <-ctx.Done(): g.Log.Info("shutting down", "grace", g.Grace) case err := <-errs: done++ first = err if err != nil { g.Log.Error("listener failed, stopping", "err", err) } } // The shutdown deadline must survive the cancellation that triggered it, // otherwise ctx.Done() would make Shutdown return immediately. shutCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), g.Grace) defer cancel() for _, s := range g.Servers { if err := s.Shutdown(shutCtx); err != nil && first == nil { first = err } } // Drain the remaining Serve results so no goroutine is left blocked on send. for ; done < len(g.Servers); done++ { select { case err := <-errs: if err != nil && first == nil { first = err } case <-time.After(5 * time.Second): return first } } return first }