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) + } + }) + } +}