Files
2026-08-15 07:13:00 +00:00

144 lines
3.9 KiB
Go

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
}