| 1 | // Package httpsvc exposes an HTTP server as a steward-managed service. |
| 2 | // |
| 3 | // Init builds the underlying *http.Server, Start binds the listener and |
| 4 | // runs Serve in a goroutine, Stop drains via Shutdown with a configurable |
| 5 | // timeout. A listener failure observed after Start is reported via the |
| 6 | // steward HealthChecker interface. |
| 7 | package httpsvc |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "errors" |
| 12 | "log/slog" |
| 13 | "net" |
| 14 | "net/http" |
| 15 | "sync" |
| 16 | "sync/atomic" |
| 17 | "time" |
| 18 | |
| 19 | "go.bigb.es/auxilia/culpa" |
| 20 | ) |
| 21 | |
| 22 | // defaultShutdownTimeout caps Service.Stop when ShutdownTimeout is zero. |
| 23 | const defaultShutdownTimeout = 30 * time.Second |
| 24 | |
| 25 | // Service is a steward-managed HTTP server. |
| 26 | // |
| 27 | // Configure the public fields before adding the service to the steward |
| 28 | // Manager. Leave private fields zero. Init builds the underlying |
| 29 | // *http.Server; Start runs the listener in a goroutine; Stop drains via |
| 30 | // Shutdown with ShutdownTimeout. |
| 31 | type Service struct { |
| 32 | // Addr is the listen address (e.g. ":8080" or "127.0.0.1:0"). Required. |
| 33 | Addr string |
| 34 | |
| 35 | // Handler is the root http.Handler. Required. |
| 36 | Handler http.Handler |
| 37 | |
| 38 | // ReadTimeout, WriteTimeout, and IdleTimeout map onto the matching |
| 39 | // http.Server fields. Zero values fall through to net/http defaults. |
| 40 | ReadTimeout time.Duration |
| 41 | WriteTimeout time.Duration |
| 42 | IdleTimeout time.Duration |
| 43 | |
| 44 | // ShutdownTimeout caps Stop's drain window. Zero means 30 seconds. |
| 45 | ShutdownTimeout time.Duration |
| 46 | |
| 47 | // Logger is optional. When nil, slog.Default() is used. |
| 48 | Logger *slog.Logger |
| 49 | |
| 50 | // BaseContext is optional; it is passed through to |
| 51 | // http.Server.BaseContext. The default is the standard library default. |
| 52 | BaseContext func(net.Listener) context.Context |
| 53 | |
| 54 | srv *http.Server |
| 55 | listener net.Listener |
| 56 | serveErr atomic.Pointer[error] |
| 57 | stopOnce sync.Once |
| 58 | stopErr error |
| 59 | started atomic.Bool |
| 60 | doneCh chan struct{} |
| 61 | } |
| 62 | |
| 63 | // Init validates the configuration and constructs the underlying |
| 64 | // *http.Server. It is safe to inspect Addr and Handler after Init returns. |
| 65 | func (s *Service) Init(_ context.Context) error { |
| 66 | if s.Addr == "" { |
| 67 | return culpa.WithCode(culpa.New("httpsvc: Addr is required"), "HTTPSVC_INVALID_CONFIG") |
| 68 | } |
| 69 | if s.Handler == nil { |
| 70 | return culpa.WithCode(culpa.New("httpsvc: Handler is required"), "HTTPSVC_INVALID_CONFIG") |
| 71 | } |
| 72 | |
| 73 | s.srv = &http.Server{ |
| 74 | Addr: s.Addr, |
| 75 | Handler: s.Handler, |
| 76 | ReadTimeout: s.ReadTimeout, |
| 77 | WriteTimeout: s.WriteTimeout, |
| 78 | IdleTimeout: s.IdleTimeout, |
| 79 | BaseContext: s.BaseContext, |
| 80 | } |
| 81 | s.doneCh = make(chan struct{}) |
| 82 | return nil |
| 83 | } |
| 84 | |
| 85 | // Start binds the listener (so bind errors surface synchronously) and |
| 86 | // runs Serve in a goroutine. Returns once the listener is bound. |
| 87 | func (s *Service) Start(_ context.Context) error { |
| 88 | ln, err := net.Listen("tcp", s.Addr) |
| 89 | if err != nil { |
| 90 | return culpa.Wrap(err, "httpsvc: bind listener") |
| 91 | } |
| 92 | s.listener = ln |
| 93 | s.started.Store(true) |
| 94 | |
| 95 | go s.serve() |
| 96 | return nil |
| 97 | } |
| 98 | |
| 99 | // serve runs the HTTP server until it stops, recording any non-graceful |
| 100 | // error for HealthCheck and logging it. |
| 101 | func (s *Service) serve() { |
| 102 | defer close(s.doneCh) |
| 103 | |
| 104 | err := s.srv.Serve(s.listener) |
| 105 | if err == nil || errors.Is(err, http.ErrServerClosed) { |
| 106 | return |
| 107 | } |
| 108 | |
| 109 | wrapped := culpa.Wrap(err, "http.Server.Serve") |
| 110 | s.serveErr.Store(&wrapped) |
| 111 | s.logger().Error("http server stopped unexpectedly", "err", wrapped) |
| 112 | } |
| 113 | |
| 114 | // Stop drains in-flight requests via http.Server.Shutdown, capped by |
| 115 | // ShutdownTimeout (default 30s). Idempotent: a second call is a no-op. |
| 116 | func (s *Service) Stop(ctx context.Context) error { |
| 117 | s.stopOnce.Do(func() { |
| 118 | if s.srv == nil { |
| 119 | return |
| 120 | } |
| 121 | timeout := s.ShutdownTimeout |
| 122 | if timeout <= 0 { |
| 123 | timeout = defaultShutdownTimeout |
| 124 | } |
| 125 | shutdownCtx, cancel := context.WithTimeout(ctx, timeout) |
| 126 | defer cancel() |
| 127 | |
| 128 | if err := s.srv.Shutdown(shutdownCtx); err != nil { |
| 129 | s.stopErr = culpa.Wrap(err, "httpsvc: shutdown") |
| 130 | // Shutdown does not interrupt active connections on timeout; |
| 131 | // force them closed so the serve goroutine exits and Stop |
| 132 | // returns promptly. We intentionally drop Close's error: it |
| 133 | // would only ever wrap "use of closed network connection" |
| 134 | // from the listener Shutdown already closed. |
| 135 | _ = s.srv.Close() |
| 136 | } |
| 137 | |
| 138 | // Wait for the serve goroutine to exit so HealthCheck reflects |
| 139 | // any final error and so the listener fd is released before we |
| 140 | // return. |
| 141 | if s.doneCh != nil { |
| 142 | <-s.doneCh |
| 143 | } |
| 144 | }) |
| 145 | return s.stopErr |
| 146 | } |
| 147 | |
| 148 | // HealthCheck returns nil while the listener is running, or the wrapped |
| 149 | // listener error after the goroutine exits unexpectedly. |
| 150 | func (s *Service) HealthCheck(_ context.Context) error { |
| 151 | if errPtr := s.serveErr.Load(); errPtr != nil { |
| 152 | return *errPtr |
| 153 | } |
| 154 | return nil |
| 155 | } |
| 156 | |
| 157 | // Addr returns the actual listen address. Most useful when configured |
| 158 | // with a wildcard port like "127.0.0.1:0" — the OS-assigned port is only |
| 159 | // known after Start. |
| 160 | func (s *Service) ListenAddr() net.Addr { |
| 161 | if s.listener == nil { |
| 162 | return nil |
| 163 | } |
| 164 | return s.listener.Addr() |
| 165 | } |
| 166 | |
| 167 | func (s *Service) logger() *slog.Logger { |
| 168 | if s.Logger != nil { |
| 169 | return s.Logger |
| 170 | } |
| 171 | return slog.Default() |
| 172 | } |
| 173 | |