From ec429f51c61b5188e4eedde345ac26394e5cb42c Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Mon, 27 Jul 2026 04:32:35 +0300 Subject: [PATCH] fix: return 200 for HEAD / so uptime monitors don't fail (#773) --- docs/administration/healthcheck.md | 10 ++++++++++ internal/web/handlers/health/healthcheck.go | 13 ++++++++++++- .../web/handlers/health/healthcheck_test.go | 17 +++++++++++++++++ internal/web/server/router.go | 3 +++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/administration/healthcheck.md b/docs/administration/healthcheck.md index 2005a88..58b440b 100644 --- a/docs/administration/healthcheck.md +++ b/docs/administration/healthcheck.md @@ -11,3 +11,13 @@ curl http://localhost:6157/healthcheck ```json {"database":"ok","opengist":"ok","time":"2024-01-04T05:18:33+01:00"} ``` + +## `HEAD /healthcheck` + +A `HEAD` request to `/healthcheck` returns the same status code as the `GET` +request above, but without a body. This is handy for uptime monitors and load +balancers that probe the endpoint with `curl -I`: + +```shell +curl -I http://localhost:6157/healthcheck +``` diff --git a/internal/web/handlers/health/healthcheck.go b/internal/web/handlers/health/healthcheck.go index 4a2c3e5..0173474 100644 --- a/internal/web/handlers/health/healthcheck.go +++ b/internal/web/handlers/health/healthcheck.go @@ -1,11 +1,16 @@ package health import ( + "net/http" + "time" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/web/context" - "time" ) +// Healthcheck reports service health. A GET request returns the status as JSON; +// a HEAD request returns only the status code (no body), which is convenient +// for uptime monitors and load balancers probing the endpoint with `curl -I`. func Healthcheck(ctx *context.Context) error { // Check database connection dbOk := "ok" @@ -17,6 +22,12 @@ func Healthcheck(ctx *context.Context) error { httpStatus = 503 } + // A HEAD request returns only the status code (no body), so uptime + // monitors and load balancers can probe the endpoint with `curl -I`. + if ctx.Request().Method == http.MethodHead { + return ctx.NoContent(httpStatus) + } + return ctx.JSON(httpStatus, map[string]interface{}{ "opengist": "ok", "database": dbOk, diff --git a/internal/web/handlers/health/healthcheck_test.go b/internal/web/handlers/health/healthcheck_test.go index 754a636..60483ac 100644 --- a/internal/web/handlers/health/healthcheck_test.go +++ b/internal/web/handlers/health/healthcheck_test.go @@ -28,3 +28,20 @@ func TestHealthcheck(t *testing.T) { require.NotEmpty(t, result["time"]) }) } + +func TestHealthcheckHead(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + t.Run("Returns 200 without authentication", func(t *testing.T) { + // A HEAD request to "/healthcheck" should return 200 (with no body) so + // that uptime monitors and load balancers using `curl -I` do not see a + // 404. + resp := s.Request(t, "HEAD", "/healthcheck", nil, 200) + + // A HEAD response must not carry a body. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Empty(t, body) + }) +} diff --git a/internal/web/server/router.go b/internal/web/server/router.go index d25aa74..2a69008 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -35,6 +35,9 @@ func (s *Server) registerRoutes() { r.POST("/upload", gist.Upload, logged, checkFileUploadEnabled) r.DELETE("/upload/:uuid", gist.DeleteUpload, logged, checkFileUploadEnabled) + // HEAD "/healthcheck" returns the health status code without a body, for + // uptime monitors and load balancers probing the endpoint (e.g. `curl -I`). + r.HEAD("/healthcheck", health.Healthcheck) r.GET("/healthcheck", health.Healthcheck) r.Static("/avatar", settings.AvatarsDir())