164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
|
|
// 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)
|
||
|
|
}
|