254 lines
8.5 KiB
Go
254 lines
8.5 KiB
Go
// Package httpserver holds the shared HTTP server lifecycle helpers used by
|
|
// every service in the monorepo: graceful shutdown with ordered hooks,
|
|
// timeout configuration, and standard probe/debug mux registration.
|
|
//
|
|
// This package consolidates the ~40-line signal+server+shutdown block that
|
|
// was hand-rolled in 12+ service main.go files (go-redirector,
|
|
// go-form-submissions, go-order-manager, go-cart-actor cmds, go-graph-cms-v2,
|
|
// slask-finder cmds, backoffice). It is promoted from
|
|
// slask-finder/pkg/common/graceful.go, which was the first implementation.
|
|
//
|
|
// # Usage
|
|
//
|
|
// Simplest (no custom readiness, no shutdown hooks):
|
|
//
|
|
// mux := http.NewServeMux()
|
|
// // … register handlers …
|
|
// httpserver.RegisterProbes(mux, nil) // /healthz + /readyz (always ready)
|
|
//
|
|
// server := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5*time.Second}
|
|
// if err := httpserver.RunServerWithShutdown(server, "my-service", 15*time.Second, 5*time.Second); err != nil {
|
|
// log.Fatalf("server: %v", err)
|
|
// }
|
|
//
|
|
// With custom readiness and shutdown hooks:
|
|
//
|
|
// httpserver.RegisterProbes(mux, func() bool { return store.Loaded() })
|
|
//
|
|
// if err := httpserver.RunServerWithShutdown(server, "my-service", 15*time.Second, 5*time.Second,
|
|
// func(ctx context.Context) error { return store.Flush() },
|
|
// func(ctx context.Context) error { return cache.Close() },
|
|
// ); err != nil {
|
|
// log.Fatalf("server: %v", err)
|
|
// }
|
|
//
|
|
// With a separate debug mux (metrics + pprof on a second port):
|
|
//
|
|
// debugMux := http.NewServeMux()
|
|
// httpserver.RegisterDebug(debugMux, promhttp.Handler())
|
|
// go http.ListenAndServe(":8081", debugMux)
|
|
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/http/pprof"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ShutdownHook is a function executed after a termination signal is received
|
|
// but before the HTTP server begins its graceful shutdown. If a hook returns
|
|
// an error it will be logged; shutdown continues regardless.
|
|
type ShutdownHook func(ctx context.Context) error
|
|
|
|
// RunServerWithShutdown starts the provided *http.Server and blocks until either
|
|
// a listener error occurs or a termination signal (SIGINT or SIGTERM) is
|
|
// received. It then runs any provided hooks (in order) with a context that
|
|
// shares the overall shutdown deadline, and finally gracefully shuts down the
|
|
// server. A per-hook timeout can be specified; if zero a default is used.
|
|
//
|
|
// Returns nil if the server shut down cleanly after a signal. Returns a
|
|
// wrapped listener error if ListenAndServe failed (the composition root then
|
|
// decides retry vs exit).
|
|
//
|
|
// Parameters:
|
|
//
|
|
// server: configured *http.Server (Addr, Handler, timeouts etc.)
|
|
// startupLog: message to log when the server starts listening
|
|
// shutdownTimeout: total time allowed for all hooks + server shutdown
|
|
// hookTimeout: individual timeout for each hook (if <=0 defaults to 5s)
|
|
// hooks: optional ordered list of shutdown hooks
|
|
func RunServerWithShutdown(server *http.Server, startupLog string, shutdownTimeout, hookTimeout time.Duration, hooks ...ShutdownHook) error {
|
|
if hookTimeout <= 0 {
|
|
hookTimeout = 5 * time.Second
|
|
}
|
|
|
|
// serveErr is buffered so the goroutine never blocks. nil means clean stop.
|
|
serveErr := make(chan error, 1)
|
|
|
|
// Start server in goroutine
|
|
go func() {
|
|
log.Printf("starting %s on %s", startupLog, server.Addr)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("%s listen error: %v", startupLog, err)
|
|
serveErr <- err
|
|
return
|
|
}
|
|
serveErr <- nil
|
|
}()
|
|
|
|
// Wait for either a signal or a listener error.
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
defer signal.Stop(stop)
|
|
|
|
var listenerErr error
|
|
select {
|
|
case <-stop:
|
|
log.Printf("shutdown signal received for %s", startupLog)
|
|
case err := <-serveErr:
|
|
// listener died before any signal — return wrapped error to the caller
|
|
// instead of crashing the process from inside this helper.
|
|
if err != nil {
|
|
listenerErr = fmt.Errorf("%s listen error: %w", startupLog, err)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
|
|
// Run hooks sequentially with individual timeouts
|
|
for i, h := range hooks {
|
|
if h == nil {
|
|
continue
|
|
}
|
|
hCtx, hCancel := context.WithTimeout(ctx, hookTimeout)
|
|
if err := h(hCtx); err != nil {
|
|
log.Printf("shutdown hook %d failed: %v", i, err)
|
|
}
|
|
hCancel()
|
|
if err := hCtx.Err(); err == context.DeadlineExceeded {
|
|
log.Printf("shutdown hook %d timed out", i)
|
|
}
|
|
}
|
|
|
|
// Finally shut down the HTTP server (idempotent if listener already died).
|
|
if err := server.Shutdown(ctx); err != nil {
|
|
log.Printf("graceful shutdown failed: %v", err)
|
|
} else if listenerErr == nil {
|
|
log.Printf("%s shutdown complete", startupLog)
|
|
}
|
|
|
|
// Drain the serve goroutine result if it raced.
|
|
select {
|
|
case <-serveErr:
|
|
default:
|
|
}
|
|
|
|
if listenerErr != nil {
|
|
return listenerErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TimeoutConfig holds server and shutdown related timeouts (all durations).
|
|
type TimeoutConfig struct {
|
|
ReadHeader time.Duration
|
|
Read time.Duration
|
|
Write time.Duration
|
|
Idle time.Duration
|
|
Shutdown time.Duration
|
|
Hook time.Duration
|
|
}
|
|
|
|
// LoadTimeoutConfig reads environment variables (if present) to override defaults.
|
|
// Each env var is parsed as an integer number of seconds. If parsing fails or value <=0,
|
|
// the provided default is retained.
|
|
// Env variables:
|
|
//
|
|
// READ_HEADER_TIMEOUT
|
|
// READ_TIMEOUT
|
|
// WRITE_TIMEOUT
|
|
// IDLE_TIMEOUT
|
|
// SHUTDOWN_TIMEOUT
|
|
// HOOK_TIMEOUT
|
|
func LoadTimeoutConfig(defaults TimeoutConfig) TimeoutConfig {
|
|
apply := func(curr *time.Duration, env string) {
|
|
if v := os.Getenv(env); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
*curr = time.Duration(n) * time.Second
|
|
}
|
|
}
|
|
}
|
|
apply(&defaults.ReadHeader, "READ_HEADER_TIMEOUT")
|
|
apply(&defaults.Read, "READ_TIMEOUT")
|
|
apply(&defaults.Write, "WRITE_TIMEOUT")
|
|
apply(&defaults.Idle, "IDLE_TIMEOUT")
|
|
apply(&defaults.Shutdown, "SHUTDOWN_TIMEOUT")
|
|
apply(&defaults.Hook, "HOOK_TIMEOUT")
|
|
return defaults
|
|
}
|
|
|
|
// NewServerWithTimeouts attaches timeout settings to an existing *http.Server or creates a new one if nil.
|
|
func NewServerWithTimeouts(base *http.Server, cfg TimeoutConfig) *http.Server {
|
|
if base == nil {
|
|
return &http.Server{
|
|
ReadHeaderTimeout: cfg.ReadHeader,
|
|
ReadTimeout: cfg.Read,
|
|
WriteTimeout: cfg.Write,
|
|
IdleTimeout: cfg.Idle,
|
|
}
|
|
}
|
|
base.ReadHeaderTimeout = cfg.ReadHeader
|
|
base.ReadTimeout = cfg.Read
|
|
base.WriteTimeout = cfg.Write
|
|
base.IdleTimeout = cfg.Idle
|
|
return base
|
|
}
|
|
|
|
// RegisterProbes registers /healthz and /readyz on the given mux.
|
|
//
|
|
// /healthz always returns 200 OK (liveness — the process is running).
|
|
// /readyz returns 200 if ready() returns true (or if ready is nil), 503
|
|
// otherwise (readiness — the service can handle requests).
|
|
//
|
|
// Both endpoints are registered with the "GET " method prefix so they work
|
|
// with Go 1.22+ pattern-based mux routing. The paths are pod-local ops
|
|
// endpoints (never exposed at the edge); register them first so they take
|
|
// precedence over catch-all handlers.
|
|
func RegisterProbes(mux *http.ServeMux, ready func() bool) {
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
|
|
if ready != nil && !ready() {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte("not ready"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ready"))
|
|
})
|
|
}
|
|
|
|
// RegisterDebug registers /metrics and /debug/pprof/* on the given mux for a
|
|
// separate debug/admin port. The metricsHandler is typically
|
|
// prometheus/promhttp.Handler(); it is passed as a parameter so this package
|
|
// does not depend on the prometheus client library. The pprof endpoints are
|
|
// registered from net/http/pprof.
|
|
//
|
|
// Use this on a separate mux served on a different port (e.g. :8081) so the
|
|
// debug surface is not exposed at the edge:
|
|
//
|
|
// debugMux := http.NewServeMux()
|
|
// httpserver.RegisterDebug(debugMux, promhttp.Handler())
|
|
// go http.ListenAndServe(":8081", debugMux)
|
|
func RegisterDebug(mux *http.ServeMux, metricsHandler http.Handler) {
|
|
if metricsHandler != nil {
|
|
mux.Handle("/metrics", metricsHandler)
|
|
}
|
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
|
}
|