Files
Rogeeandrogee 798ea43c2f HH-442: isolate runtime processes and harden shutdown (#90)
* HH-442: isolate runtime processes and harden shutdown

* HH-442: harden worker shutdown races

* HH-442: gate dependency shutdown on active handlers

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-22 02:38:15 +08:00

118 lines
3.4 KiB
Go

package handler
import (
"context"
"errors"
"net/http"
"runtime"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DependencyCheck struct {
Name string
Check func(context.Context) error
}
// HealthResponse is the structured health check response.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Version string `json:"version,omitempty"`
Uptime string `json:"uptime,omitempty"`
Checks map[string]string `json:"checks"`
}
// HealthHandler reports dependency state and optional runtime diagnostics.
func HealthHandler(db *gorm.DB, startTime time.Time, version string, dependencies ...DependencyCheck) gin.HandlerFunc {
return func(c *gin.Context) {
selected := c.Query("check")
checks, healthy := runDependencyChecks(c.Request.Context(), db, selected, dependencies)
uptime := time.Since(startTime)
if selected == "" {
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
checks["memory_alloc_mb"] = strconv.FormatUint(memory.Alloc/1024/1024, 10)
checks["goroutines"] = strconv.Itoa(runtime.NumGoroutine())
checks["uptime_seconds"] = strconv.FormatUint(uint64(uptime.Seconds()), 10)
}
status, statusCode := "healthy", http.StatusOK
if !healthy {
status, statusCode = "unhealthy", http.StatusServiceUnavailable
}
c.JSON(statusCode, HealthResponse{
Status: status,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Version: version,
Uptime: uptime.String(),
Checks: checks,
})
}
}
// ReadyHandler returns 503 while draining or when a required dependency fails.
func ReadyHandler(db *gorm.DB, dependencies ...DependencyCheck) gin.HandlerFunc {
return func(c *gin.Context) {
checks, ready := runDependencyChecks(c.Request.Context(), db, "", dependencies)
statusCode := http.StatusOK
if !ready {
statusCode = http.StatusServiceUnavailable
}
c.JSON(statusCode, gin.H{"ready": ready, "checks": checks})
}
}
// LiveHandler only proves that the process can serve HTTP; dependency failures
// belong to readiness so an outage does not trigger a restart loop.
func LiveHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"alive": true})
}
}
func runDependencyChecks(ctx context.Context, db *gorm.DB, selected string, dependencies []DependencyCheck) (map[string]string, bool) {
checks := make(map[string]string, len(dependencies)+1)
healthy := true
all := append([]DependencyCheck{{Name: "database", Check: databasePing(db)}}, dependencies...)
found := selected == ""
for _, dependency := range all {
if selected != "" && dependency.Name != selected {
continue
}
found = true
if dependency.Check == nil {
checks[dependency.Name] = "unhealthy: check is not configured"
healthy = false
continue
}
if err := dependency.Check(ctx); err != nil {
checks[dependency.Name] = "unhealthy: " + err.Error()
healthy = false
} else {
checks[dependency.Name] = "healthy"
}
}
if !found {
checks[selected] = "unhealthy: unknown check"
healthy = false
}
return checks, healthy
}
func databasePing(db *gorm.DB) func(context.Context) error {
return func(ctx context.Context) error {
if db == nil {
return errors.New("database is not configured")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
}
}