feat(api): add /health endpoint with Docker healthchecks

Allow Docker to distinguish a running container from one actually
serving traffic by adding a /health endpoint that pings the database.
Returns 200 when healthy, 503 when the DB connection is lost.

Add HEALTHCHECK to Dockerfile and healthcheck config to the server
service in docker-compose.yml. Also add start_period to the existing
db healthcheck for consistency.
This commit is contained in:
Houmgaor
2026-02-23 20:34:20 +01:00
parent b96cd0904b
commit a72ac43f1d
5 changed files with 79 additions and 0 deletions

View File

@@ -26,6 +26,7 @@ type Config struct {
type APIServer struct {
sync.Mutex
logger *zap.Logger
db *sqlx.DB
erupeConfig *cfg.Config
userRepo APIUserRepo
charRepo APICharacterRepo
@@ -38,6 +39,7 @@ type APIServer struct {
func NewAPIServer(config *Config) *APIServer {
s := &APIServer{
logger: config.Logger,
db: config.DB,
erupeConfig: config.ErupeConfig,
httpServer: &http.Server{},
}
@@ -61,6 +63,7 @@ func (s *APIServer) Start() error {
r.HandleFunc("/character/export", s.ExportSave)
r.HandleFunc("/api/ss/bbs/upload.php", s.ScreenShot)
r.HandleFunc("/api/ss/bbs/{id}", s.ScreenShotGet)
r.HandleFunc("/health", s.Health)
r.HandleFunc("/version", s.Version)
handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r)
s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler)

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"encoding/xml"
@@ -443,3 +444,30 @@ func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) {
writeResult("200")
}
// Health handles GET /health, returning the server's health status.
// It pings the database to verify connectivity.
func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if s.db == nil {
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "unhealthy",
"error": "database not configured",
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "unhealthy",
"error": err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}

View File

@@ -572,6 +572,44 @@ func TestNewAuthDataTimestamps(t *testing.T) {
}
}
// TestHealthEndpointNoDB tests the /health endpoint when no database is configured.
func TestHealthEndpointNoDB(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
server := &APIServer{
logger: logger,
erupeConfig: NewTestConfig(),
db: nil,
}
req := httptest.NewRequest("GET", "/health", nil)
recorder := httptest.NewRecorder()
server.Health(recorder, req)
if recorder.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" {
t.Errorf("Content-Type = %v, want application/json", contentType)
}
var resp map[string]string
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp["status"] != "unhealthy" {
t.Errorf("status = %q, want %q", resp["status"], "unhealthy")
}
if resp["error"] != "database not configured" {
t.Errorf("error = %q, want %q", resp["error"], "database not configured")
}
}
// BenchmarkLauncherEndpoint benchmarks the launcher endpoint
func BenchmarkLauncherEndpoint(b *testing.B) {
logger, _ := zap.NewDevelopment()