feat(httpserver,log): /healthz, graceful shutdown, slog constructor

Implements internal/httpserver and internal/log.

httpserver (forgejo-mcp-broker-8ei):
- Server struct owns the HTTP lifecycle; Run(ctx) blocks, Handler() returns
  the composed handler for unit tests
- GET /healthz returns JSON with status, version, git_revision, build_date,
  and store probe result. Returns 503 when the store reports unhealthy
- Signal handling delegated to the caller via ctx cancellation — main wires
  signal.NotifyContext, httpserver just responds to Done()
- Graceful shutdown with a configurable deadline (default 10s). When the
  deadline expires, falls back to http.Server.Close() so lingering
  connections are forcibly terminated — http.Server.Shutdown alone never
  interrupts active connections
- ExtraHandler extension point for the OAuth + MCP routes that land in
  phase 2 and phase 5, so the server doesn't need to be re-plumbed later

log:
- Small slog wrapper: New(w, debug) returns a JSON logger that stamps every
  record with service/version/git_rev for correlation across deployments
- Discard() helper for tests

Tests: 97.9% coverage on httpserver (all health states, wrong-method,
ExtraHandler dispatch, ctx-cancel shutdown, shutdown-deadline force-close
of hanging requests, missing-field errors), 100% on log.

Closes forgejo-mcp-broker-8ei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-24 17:26:32 +02:00
commit 36722940eb
7 changed files with 506 additions and 14 deletions

View file

@ -0,0 +1,164 @@
// Package httpserver hosts the broker's HTTP surface. In phase 1 that's just
// /healthz; OAuth endpoints and the gated MCP endpoint land in later phases.
//
// The package owns an *http.Server and its lifecycle. Signal handling lives
// in main: the caller passes a context that is canceled on SIGTERM/SIGINT,
// and Run initiates a graceful shutdown with a bounded deadline.
package httpserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo"
)
// DefaultShutdownTimeout is the graceful-shutdown deadline used when
// Server.ShutdownTimeout is zero.
const DefaultShutdownTimeout = 10 * time.Second
// Pinger reports whether a dependency is still reachable. The store
// implements this; other backends can too.
type Pinger interface {
Ping(ctx context.Context) error
}
// Server is the broker's HTTP front end. It composes a few well-known
// handlers (/healthz today; OAuth and MCP in later phases) with an optional
// ExtraHandler for routes the server doesn't own natively.
type Server struct {
// Addr is the TCP listen address (e.g. ":8080"). Required.
Addr string
// Log is the structured logger used for lifecycle and request events.
// Must not be nil.
Log *slog.Logger
// Store is probed by /healthz. nil means "not configured" (health still
// reports 200 but marks store as unconfigured).
Store Pinger
// ExtraHandler, if non-nil, receives any request /healthz does not match.
// This is the extension point later phases use to add OAuth and MCP
// routes without forking the server.
ExtraHandler http.Handler
// ShutdownTimeout bounds how long Run will wait for in-flight requests
// during graceful shutdown. Zero means DefaultShutdownTimeout.
ShutdownTimeout time.Duration
}
// Handler returns the composed HTTP handler without any listening setup.
// Useful for handler-level tests via httptest.NewRecorder.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
if s.ExtraHandler != nil {
// ServeMux treats "/" as the catch-all pattern; specific patterns
// (like "GET /healthz") take precedence in Go 1.22+ routing.
mux.Handle("/", s.ExtraHandler)
}
return mux
}
// Run starts the HTTP server and blocks until ctx is canceled or the server
// stops on its own. On ctx cancellation, initiates graceful shutdown with
// ShutdownTimeout as the deadline.
func (s *Server) Run(ctx context.Context) error {
if s.Log == nil {
return errors.New("httpserver: Log is required")
}
if s.Addr == "" {
return errors.New("httpserver: Addr is required")
}
srv := &http.Server{
Addr: s.Addr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
serveErr := make(chan error, 1)
go func() {
s.Log.Info("server listening", slog.String("addr", s.Addr))
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
serveErr <- err
}()
select {
case err := <-serveErr:
return err
case <-ctx.Done():
s.Log.Info("shutdown initiated", slog.String("cause", ctx.Err().Error()))
}
timeout := s.ShutdownTimeout
if timeout <= 0 {
timeout = DefaultShutdownTimeout
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
shutdownErr := srv.Shutdown(shutdownCtx)
if shutdownErr != nil {
// Graceful shutdown timed out. http.Server.Shutdown does not
// interrupt active connections on its own — Close forces the
// sockets closed, which cancels each in-flight request's context
// and lets handlers observe the termination via r.Context().Done().
s.Log.Error("graceful shutdown exceeded deadline; forcing close",
slog.Duration("deadline", timeout),
slog.String("err", shutdownErr.Error()))
_ = srv.Close()
}
// Wait for ListenAndServe's goroutine to exit so we don't leak it.
<-serveErr
if shutdownErr != nil {
return fmt.Errorf("shutdown: %w", shutdownErr)
}
s.Log.Info("server stopped")
return nil
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
GitRevision string `json:"git_revision"`
BuildDate string `json:"build_date"`
Store string `json:"store"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Version: buildinfo.Version,
GitRevision: buildinfo.GitRevision,
BuildDate: buildinfo.BuildDate,
Store: "not configured",
}
status := http.StatusOK
if s.Store != nil {
pingCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.Store.Ping(pingCtx); err != nil {
resp.Status = "degraded"
resp.Store = "error: " + err.Error()
status = http.StatusServiceUnavailable
} else {
resp.Store = "ok"
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(resp)
}