From 3b413f5dac58c642d6f48858b102a30c29fe9ab5 Mon Sep 17 00:00:00 2001 From: ikio-nen <148907316+ikio-nen@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:23:08 +0530 Subject: [PATCH] fix(gofr): warn when readiness checks are registered but the HTTP server never starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app that registers readiness checks via AddReadinessCheck but registers no HTTP routes — and has no public/ directory — starts metrics-only: Run() skips startHTTPServer because httpRegistered is false, no port is bound, and /.well-known/health is never served. Nothing in the startup log says why, so the app looks healthy while every readiness probe gets connection refused. startHTTPServer now logs a warning when it detects exactly that mismatch (readiness checks registered, HTTP server not marked for startup), pointing at the fix — register at least one HTTP route. Apps without readiness checks are unaffected and stay silent. Covered by TestApp_startHTTPServer_warnsWhenReadinessChecksCannotBeServed. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- pkg/gofr/run.go | 15 +++++++++++++++ pkg/gofr/run_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/pkg/gofr/run.go b/pkg/gofr/run.go index b57a1eeb6c..c1d5eb22fe 100644 --- a/pkg/gofr/run.go +++ b/pkg/gofr/run.go @@ -202,6 +202,13 @@ func (a *App) startMetricsServer(wg *sync.WaitGroup) { } // startHTTPServer starts the HTTP server if registered. +// +// If nothing marked the HTTP server for startup — no route was registered and +// no public/ directory exists — the server is skipped silently. That silence is +// a trap for an app that registered readiness checks but no routes: it starts +// metrics-only and stays unreachable, with /.well-known/health never served and +// no log line saying why. Warn when exactly that mismatch is detected, so the +// operator sees the cause instead of a connection refused. func (a *App) startHTTPServer(wg *sync.WaitGroup) { if a.httpRegistered { wg.Add(1) @@ -212,6 +219,14 @@ func (a *App) startHTTPServer(wg *sync.WaitGroup) { s.run(a.container) }(a.httpServer) + + return + } + + if len(a.readinessChecks) > 0 { + a.Logger().Warnf("readiness: %d app check(s) registered, but the HTTP server will not start — "+ + "no route was registered and no public/ directory exists, so /.well-known/health is unreachable. "+ + "Register at least one HTTP route.", len(a.readinessChecks)) } } diff --git a/pkg/gofr/run_test.go b/pkg/gofr/run_test.go index 1cee352b85..2721151a6e 100644 --- a/pkg/gofr/run_test.go +++ b/pkg/gofr/run_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strconv" + "sync" "syscall" "testing" "time" @@ -160,6 +161,47 @@ func TestApp_Run_waitsForShutdown(t *testing.T) { assert.NotContains(t, out.String(), shutdownRaced) } +// TestApp_startHTTPServer_warnsWhenReadinessChecksCannotBeServed pins the warning for the +// metrics-only startup: an app that registers readiness checks but no HTTP routes skips the HTTP +// server without a word, and /.well-known/health is unreachable for no logged reason. +func TestApp_startHTTPServer_warnsWhenReadinessChecksCannotBeServed(t *testing.T) { + runStart := func(setup func(*App)) string { + // The app must be created INSIDE the capture: the container logger stores + // os.Stdout at construction, so an app built before the swap would log + // past the pipe and the capture would come back empty. + return testutil.StdoutOutputForFunc(func() { + app := New() + + // New() marks the HTTP server for startup whenever the working directory + // happens to hold a static directory, so pin the state under test instead + // of inheriting whatever the CI/workspace checkout looks like. + app.httpRegistered = false + + setup(app) + + var wg sync.WaitGroup + + app.startHTTPServer(&wg) + wg.Wait() + }) + } + + t.Run("readiness checks registered but no routes", func(t *testing.T) { + out := runStart(func(app *App) { + app.AddReadinessCheck(func(*Context) error { return nil }) + }) + + assert.Contains(t, out, "readiness:", "the metrics-only startup must say why health is unreachable:\n%s", out) + assert.Contains(t, out, "the HTTP server will not start", "the warning must name the consequence:\n%s", out) + }) + + t.Run("no readiness checks and no routes stays silent", func(t *testing.T) { + out := runStart(func(*App) {}) + + assert.NotContains(t, out, "the HTTP server will not start", "an app without readiness checks has nothing to explain:\n%s", out) + }) +} + // TestShutdownHelperProcess is the app under test for TestApp_Run_waitsForShutdown, run as its own // process so the SIGTERM it is sent reaches nothing else. It skips unless the parent asked for it. func TestShutdownHelperProcess(t *testing.T) {