Files
orca/internal/daemon/health.go
T

130 lines
3.8 KiB
Go

package daemon
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
)
// handleHealthz reports liveness. It does NOT check dependencies — by design,
// a process that can answer this is "alive" even if its DB is wedged. Use
// /readyz for dependency health.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "alive",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// handleReadyz reports readiness. Returns 503 if either:
// - MarkReady has not been called, OR
// - the SQLite database cannot be pinged within 2s.
//
// Distinguishing these cases in the response body helps operators triage.
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if !s.ready.Load() {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"status": "not_ready",
"reason": "daemon not marked ready",
})
return
}
if err := s.db.PingContext(ctx); err != nil {
s.log.Warn("readyz db ping failed",
slog.String("component", "daemon"),
slog.String("error", err.Error()))
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"status": "not_ready",
"reason": "db ping failed",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ready",
"db": "ok",
})
}
// handleStatus returns a small diagnostic JSON blob. Cheap to call; does
// NOT touch the database unless we want a DB status check, in which case
// the ping is bounded by 2s.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbStatus := "ok"
if err := s.db.PingContext(ctx); err != nil {
dbStatus = "error"
s.log.Warn("status db ping failed",
slog.String("component", "daemon"),
slog.String("error", err.Error()))
}
writeJSON(w, http.StatusOK, map[string]any{
"version": Version,
"phase": "5-health-checks",
"milestone": "v0.1",
"db": dbStatus,
"ready": s.ready.Load(),
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// writeJSON encodes body as JSON with the given status code.
// Errors during encoding are logged but not surfaced — we cannot write
// another header after the response has started.
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// writeError emits a uniform error envelope: {"error": "<message>"}.
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// loggingMiddleware wraps the mux with a structured access log. It does
// NOT log request/response bodies (could contain secrets); just method,
// path, status, and duration.
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(ww, r)
log.Info("http",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.status),
slog.Duration("dur", time.Since(start)),
slog.String("remote", r.RemoteAddr),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}