more platform stuff

This commit is contained in:
mats committed 2026-07-18 23:28:51 +02:00
1 parent 4b9a882018
commit fc5eefbe5e
7 files changed
+756

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
// 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)
}