Files
webhooker/internal/server/http.go
sneak b235a5d48e
All checks were successful
check / check (push) Successful in 3m32s
Fail loudly on an unparseable SENTRY_DSN and a malformed .env (closes #283)
Two configuration paths still failed silently, against the rule every
other variable follows: a value that is set but cannot be parsed must
abort startup rather than substitute a default.

SENTRY_DSN is now parsed in loadFromEnv, with sentry.NewDsn — the same
call sentry.Init makes on the DSN it is handed, so configuration and
initialisation cannot disagree about what a valid DSN is. That costs
internal/config an import of the Sentry SDK, which is already a module
dependency already linked into the binary, and buys a single definition
of validity rather than a hand-rolled second one free to drift. A typo
in a DSN used to log one error line and leave the process serving with
error reporting off forever, which nothing downstream can notice: the
variable is still set, so every later signal reports it as on.

hasSentryDSN is replaced by Config.SentryEnabled(), following
MetricsAuthEnabled(): one method read by the startup log field, by the
SDK initialisation and by the sentryhttp middleware, so the log cannot
report reporting as on while nothing is sending. enableSentry's error
branch is now fatal, and Run gives up before it listens rather than
binding a port it is about to release. Fatal there means what a listen
failure already meant — Shutdowner.Shutdown(fx.ExitCode(1)), through
fx's normal stop sequence — so shutdownOnListenFailure is now
shutdownWithFailure and ListenFailureExitCode is
StartupFailureExitCode.

The godotenv/autoload blank import is replaced by config.LoadDotEnv,
called at the top of dispatch. autoload discarded Load's error, and
godotenv applies nothing at all when a file will not parse, so one
mistyped line reverted every variable in the file to its default and
started the server with no log line naming the file. A missing file
stays fine — it is optional and most deployments have none. The call
sits in dispatch rather than in loadFromEnv because autoload ran in an
init(), ahead of config.DataDir(), which both the DATA_DIR lock and
resetpw call outside the fx graph; loading any later would let a .env
that sets DATA_DIR lock one directory while the config opened
databases in another.

Both defects were reproduced against the previous build first: an
unparseable DSN served traffic while logging "hasSentryDSN":true, and
a malformed .env started on the default port with the file unmentioned.
2026-08-24 02:14:18 +00:00

84 lines
2.5 KiB
Go

package server
import (
"errors"
"net"
"net/http"
"strconv"
"time"
)
const (
// httpReadTimeout is the maximum duration for reading the
// entire request, including the body.
httpReadTimeout = 10 * time.Second
// httpWriteTimeout is the maximum duration before timing out
// writes of the response. It must stay above the router's
// requestTimeout (60s, in routes.go) so the middleware timeout
// fires first and returns a clean 503, rather than the transport
// cutting the connection at the socket write deadline.
httpWriteTimeout = 65 * time.Second
// httpMaxHeaderBytes is the maximum number of bytes the
// server will read parsing the request headers.
httpMaxHeaderBytes = 1 << 20
)
// listenAddr renders the address the HTTP listener binds.
//
// The host half is always present: an empty host would be the
// wildcard, and the whole point of BIND_ADDRESS is that binding every
// interface is a choice the operator makes rather than one the
// process makes for them. Config guarantees a literal, so
// JoinHostPort's bracketing is enough to make IPv6 well formed.
func (s *Server) listenAddr() string {
return net.JoinHostPort(
s.params.Config.BindAddress,
strconv.Itoa(s.params.Config.Port),
)
}
// newHTTPServer builds the HTTP server for this Server's
// configuration.
//
// It is called from New, on the constructing goroutine, rather than
// from the serving goroutine that used to assign s.httpServer
// directly. Two goroutines reach that field — the serving goroutine
// and the fx stop hook, which calls Shutdown on it — with nothing
// ordering them. Constructing it during New puts the write before
// every hook fx will later run, which both removes the race and rules
// out the nil dereference a stop that arrived before the serving
// goroutine had run would have caused.
func (s *Server) newHTTPServer() *http.Server {
return &http.Server{
Addr: s.listenAddr(),
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: httpMaxHeaderBytes,
Handler: s,
}
}
func (s *Server) serveUntilShutdown() {
// add routes
// this does any necessary setup in each handler
s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err)
s.shutdownWithFailure()
}
}
// ServeHTTP delegates to the router.
func (s *Server) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
s.router.ServeHTTP(w, r)
}