4 Commits

Author SHA1 Message Date
9b6608418b feat(httpserver): promote to v1.0.2 — configurable ShutdownTimeout, bump deps to v1.0.1, go 1.26
Add Config.ShutdownTimeout (SERVER_SHUTDOWN_TIMEOUT, default 10s) so callers can
configure the graceful-shutdown deadline without code changes. Previously hardcoded
to 10 seconds. Bump launcher and logz from v0.9.0 to v1.0.1, update go directive
from 1.25 to 1.26. API committed as stable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 04:17:52 +00:00
1801754a9b chore(release): v0.9.2 2026-03-25 00:38:43 +00:00
5ab0120597 fix(httpserver): bind port synchronously in OnStart so bind errors propagate
Previously ListenAndServe ran entirely in a goroutine, so a port-in-use
error was only logged and the launcher received nil from OnStart — leaving
the application running without an HTTP server.

Replace with net.Listen (synchronous) + srv.Serve(ln) (goroutine). A bind
failure now returns an error from OnStart, which the launcher treats as
fatal and triggers a clean shutdown immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 00:26:15 +00:00
69cea64ea0 fix(httpserver): guard OnStop against nil srv when OnStart was never called
If a component earlier in the launcher sequence fails during OnInit or OnStart,
the launcher calls OnStop on every already-registered component for cleanup.
httpserver.OnStop previously called s.srv.Shutdown(ctx) unconditionally; because
s.srv is only assigned inside OnStart, any shutdown triggered before OnStart ran
caused a nil pointer panic.

Add an early return in OnStop: `if s.srv == nil { return nil }`.
2026-03-21 10:52:56 -06:00
4 changed files with 71 additions and 18 deletions

View File

@@ -5,6 +5,36 @@ All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.2] - 2026-05-12
### Added
- `Config.ShutdownTimeout time.Duration` (`SERVER_SHUTDOWN_TIMEOUT`, default `10s`) — configures the graceful-shutdown deadline passed to `http.Server.Shutdown`. Previously hardcoded to 10 seconds; callers that serve long-running requests (uploads, streaming) or prefer a shorter fail-fast window can now set this per environment.
### Changed
- `launcher` and `logz` dependencies bumped from v0.9.0 to v1.0.1.
- `go` directive updated from 1.25 to 1.26.
## [0.9.2] - 2026-03-25
### Fixed
- `OnStart` now binds the TCP listener synchronously via `net.Listen` before
launching the serve goroutine. A port-in-use (or any other bind) error is
returned immediately from `OnStart`, allowing the launcher to treat it as a
fatal startup failure and trigger a clean shutdown. Previously the error only
appeared in a log line while the application continued running without an
HTTP server.
## [0.9.1] - 2026-03-21
### Fixed
- `OnStop` now returns `nil` immediately when `s.srv == nil`, preventing a nil pointer
panic when the launcher calls cleanup on a server component whose `OnStart` was never
reached (e.g. because an earlier component failed during startup).
## [0.9.0] - 2026-03-18 ## [0.9.0] - 2026-03-18
### Added ### Added
@@ -25,4 +55,7 @@ and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
- No middleware is installed by default; the full middleware stack is composed explicitly via `WithMiddleware` at construction time, keeping the stack visible and ordering unambiguous in the application source - No middleware is installed by default; the full middleware stack is composed explicitly via `WithMiddleware` at construction time, keeping the stack visible and ordering unambiguous in the application source
- chi was chosen as the underlying router because it uses stdlib `http.Handler` throughout, making it fully compatible with `httpmw` middleware and `httputil` handler adapters without any wrapper code at the boundary - chi was chosen as the underlying router because it uses stdlib `http.Handler` throughout, making it fully compatible with `httpmw` middleware and `httputil` handler adapters without any wrapper code at the boundary
[1.0.2]: https://code.nochebuena.dev/go/httpserver/releases/tag/v1.0.2
[0.9.2]: https://code.nochebuena.dev/go/httpserver/releases/tag/v0.9.2
[0.9.1]: https://code.nochebuena.dev/go/httpserver/releases/tag/v0.9.1
[0.9.0]: https://code.nochebuena.dev/go/httpserver/releases/tag/v0.9.0 [0.9.0]: https://code.nochebuena.dev/go/httpserver/releases/tag/v0.9.0

6
go.mod
View File

@@ -1,10 +1,10 @@
module code.nochebuena.dev/go/httpserver module code.nochebuena.dev/go/httpserver
go 1.25 go 1.26
require ( require (
code.nochebuena.dev/go/launcher v0.9.0 code.nochebuena.dev/go/launcher v1.0.1
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
) )
require code.nochebuena.dev/go/logz v0.9.0 // indirect require code.nochebuena.dev/go/logz v1.0.1 // indirect

8
go.sum
View File

@@ -1,6 +1,6 @@
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA= code.nochebuena.dev/go/launcher v1.0.1 h1:hbPV8jNtyxfchrT7igzz3M2tKGI3bm8uWkHBXRvSPgg=
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g= code.nochebuena.dev/go/launcher v1.0.1/go.mod h1:1KwndVuqm31JN9Dpl9YvOmlogPlKKzoDMo9aRFkYwmM=
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE= code.nochebuena.dev/go/logz v1.0.1 h1:kK9aZo19L208CwCr2D/dbSOMaOv62cXsigMSsdFu+8Y=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= code.nochebuena.dev/go/logz v1.0.1/go.mod h1:YNpNm03fURm2v0ySh/477z9AJhtfRcd9rFOW6fFqgNM=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"time" "time"
@@ -25,6 +26,7 @@ type Config struct {
ReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT" envDefault:"5s"` ReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT" envDefault:"5s"`
WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT" envDefault:"10s"` WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT" envDefault:"10s"`
IdleTimeout time.Duration `env:"SERVER_IDLE_TIMEOUT" envDefault:"120s"` IdleTimeout time.Duration `env:"SERVER_IDLE_TIMEOUT" envDefault:"120s"`
ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT" envDefault:"10s"`
} }
// serverOpts holds functional options. // serverOpts holds functional options.
@@ -80,7 +82,10 @@ func (s *httpServer) OnInit() error {
return nil return nil
} }
// OnStart starts the HTTP server in a background goroutine. // OnStart binds the TCP listener synchronously so that a port conflict returns
// an error immediately — allowing the launcher to trigger a clean shutdown
// instead of running silently without an HTTP server. The accepted connections
// are then served in a background goroutine.
func (s *httpServer) OnStart() error { func (s *httpServer) OnStart() error {
host := s.cfg.Host host := s.cfg.Host
if host == "" { if host == "" {
@@ -90,16 +95,23 @@ func (s *httpServer) OnStart() error {
if port == 0 { if port == 0 {
port = 8080 port = 8080
} }
addr := fmt.Sprintf("%s:%d", host, port)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("httpserver: bind %s: %w", addr, err)
}
s.srv = &http.Server{ s.srv = &http.Server{
Addr: fmt.Sprintf("%s:%d", host, port), Addr: addr,
Handler: s.Router, Handler: s.Router,
ReadTimeout: s.cfg.ReadTimeout, ReadTimeout: s.cfg.ReadTimeout,
WriteTimeout: s.cfg.WriteTimeout, WriteTimeout: s.cfg.WriteTimeout,
IdleTimeout: s.cfg.IdleTimeout, IdleTimeout: s.cfg.IdleTimeout,
} }
s.logger.Info("httpserver: starting", "addr", s.srv.Addr) s.logger.Info("httpserver: starting", "addr", addr)
go func() { go func() {
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("httpserver: fatal error", err) s.logger.Error("httpserver: fatal error", err)
} }
}() }()
@@ -107,10 +119,18 @@ func (s *httpServer) OnStart() error {
} }
// OnStop performs a graceful shutdown, waiting up to 10 seconds for in-flight // OnStop performs a graceful shutdown, waiting up to 10 seconds for in-flight
// requests to complete. // requests to complete. If OnStart was never called (e.g. a prior component
// failed during startup), this is a no-op.
func (s *httpServer) OnStop() error { func (s *httpServer) OnStop() error {
if s.srv == nil {
return nil
}
s.logger.Info("httpserver: shutting down gracefully") s.logger.Info("httpserver: shutting down gracefully")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) timeout := s.cfg.ShutdownTimeout
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
return s.srv.Shutdown(ctx) return s.srv.Shutdown(ctx)
} }