// 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) }