From bb8c68beee4cb7e0add7121449986796372c851a Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 29 Oct 2026 13:49:40 +0800 Subject: [PATCH] Add GET /healthz for deploy checks and uptime monitors Returns 200 ok when the database answers within 2s, otherwise 503. It needs no authentication and is left out of the request log. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- cmd/fmsg-webapi/main.go | 7 ++++-- internal/handlers/health.go | 34 ++++++++++++++++++++++++++ internal/handlers/health_test.go | 41 ++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 internal/handlers/health.go create mode 100644 internal/handlers/health_test.go diff --git a/README.md b/README.md index afbf249..09f65bf 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ message has already been sent. ## API Routes -All routes are prefixed with `/fmsg`. `POST /fmsg/token` accepts an API key and +All routes except `/healthz` are prefixed with `/fmsg`. `POST /fmsg/token` accepts an API key and returns a JWT. Other routes require a valid `Authorization: Bearer ` header. The WebSocket route `/fmsg/ws` additionally accepts the token via an `access_token` query parameter (browsers cannot set headers on a WebSocket). @@ -335,6 +335,7 @@ the application. | Method | Path | Description | | -------- | ------------------------------------------- | ------------------------ | +| `GET` | `/healthz` | Health check (no auth): `200 ok`, or `503` when the database is unreachable | | `GET` | `/fmsg` | List messages for the authenticated identity | | `GET` | `/fmsg/sent` | List authored messages (sent + drafts) | | `GET` | `/fmsg/ws` | WebSocket for pushed event notifications | diff --git a/cmd/fmsg-webapi/main.go b/cmd/fmsg-webapi/main.go index 3704e26..784771a 100644 --- a/cmd/fmsg-webapi/main.go +++ b/cmd/fmsg-webapi/main.go @@ -118,8 +118,10 @@ func main() { log.Fatalf("failed to initialise JWT verifier: %v", err) } - // Create Gin router. - router := gin.Default() + // Create Gin router. Like gin.Default, but health checks, which monitors + // make every few seconds, are left out of the request log. + router := gin.New() + router.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/healthz"}}), gin.Recovery()) if trustedProxies := parseCSV(os.Getenv("FMSG_TRUSTED_PROXIES")); len(trustedProxies) > 0 { if err := router.SetTrustedProxies(trustedProxies); err != nil { log.Fatalf("invalid FMSG_TRUSTED_PROXIES: %v", err) @@ -171,6 +173,7 @@ func main() { tokenHandler = handlers.NewTokenHandler(apiStore, tokenIssuer, idURL) subAccountHandler = handlers.NewSubAccountHandler(apiStore, idURL) } + router.GET("/healthz", handlers.Healthz(database.Pool)) registerRoutes(router, jwtMiddleware, msgHandler, attHandler, tokenHandler, subAccountHandler, pushHandler, wsHandler) srv := &http.Server{ diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..47f62f9 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// Pinger reports whether a dependency answers; *pgxpool.Pool satisfies it. +type Pinger interface { + Ping(ctx context.Context) error +} + +// healthTimeout bounds the database check so a stuck database fails the +// health check instead of hanging it. +const healthTimeout = 2 * time.Second + +// Healthz handles GET /healthz for deploy checks and uptime monitors. It needs +// no authentication and reveals nothing beyond whether the service is up: 200 +// "ok" when the database answers within healthTimeout, otherwise 503. +func Healthz(db Pinger) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + ctx, cancel := context.WithTimeout(c.Request.Context(), healthTimeout) + defer cancel() + if err := db.Ping(ctx); err != nil { + c.String(http.StatusServiceUnavailable, "database unavailable") + return + } + c.String(http.StatusOK, "ok") + } +} diff --git a/internal/handlers/health_test.go b/internal/handlers/health_test.go new file mode 100644 index 0000000..3452243 --- /dev/null +++ b/internal/handlers/health_test.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +type fakePinger struct{ err error } + +func (f fakePinger) Ping(ctx context.Context) error { return f.err } + +func TestHealthz(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + err error + code int + body string + }{ + {"database up", nil, http.StatusOK, "ok"}, + {"database down", errors.New("connection refused"), http.StatusServiceUnavailable, "database unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + router := gin.New() + router.GET("/healthz", Healthz(fakePinger{tc.err})) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if w.Code != tc.code || w.Body.String() != tc.body { + t.Fatalf("got %d %q, want %d %q", w.Code, w.Body.String(), tc.code, tc.body) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + }) + } +}