Stage 7 · Master
Phase 2 — Organization Service
Health Checks
Retire the /ping smoke route from Service Bootstrap in favor of a real liveness/readiness split — the distinction Kubernetes will depend on two lessons from now.
Liveness and Readiness Are Two Different Questions
/ping has answered exactly one question since Service Bootstrap: is this process running at all? That question alone cannot distinguish 'this process is fine but its database is temporarily unreachable' from 'this process is genuinely stuck and needs to be killed and restarted' — and an orchestrator that conflates the two will restart a perfectly healthy process for a transient database blip, potentially in a cascading loop across every replica at once.
Liveness Must Never Depend on Anything External
/healthz answers only whether this process's own event loop is responsive — it performs no database call, no network request, nothing that could itself hang or fail for a reason unrelated to this process's own health. A liveness probe that pings the database conflates 'my process is broken' with 'my dependency is broken,' and an orchestrator acting on that conflated signal restarts the wrong thing.
package handler
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
type HealthHandler struct {
pool *pgxpool.Pool
}
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
return &HealthHandler{pool: pool}
}
// Live answers only "is this process's own request loop responding" — it
// must never call the database, Redis, or any other dependency, since its
// entire purpose is to be a signal Kubernetes can trust independently of
// anything this process depends on.
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// Ready answers "can this instance currently serve real traffic" — bounded
// to 2 seconds so a genuinely stuck database connection fails this check
// quickly instead of leaving the probe itself hanging indefinitely.
func (h *HealthHandler) Ready(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := h.pool.Ping(ctx); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable",
"reason": "database unreachable",
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func New(orgHandler *handler.OrganizationHandler, healthHandler *handler.HealthHandler) *gin.Engine {
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(middleware.ErrorHandler())
engine.GET("/healthz", healthHandler.Live)
engine.GET("/readyz", healthHandler.Ready)
v1 := engine.Group("/api/v1")
// organizations group unchanged below this line
repo := repository.NewOrganizationPostgres(pool)
svc := service.NewOrganizationService(repo)
orgHandler := handler.NewOrganizationHandler(svc, validation.New())
healthHandler := handler.NewHealthHandler(pool)
engine := router.New(orgHandler, healthHandler)
healthHandler receives the same *pgxpool.Pool constructed earlier in main — no new connection, no new dependency, just a second consumer of the pool this file already owns.
If /readyz were wired as Kubernetes' liveness probe instead of /healthz (an easy misconfiguration — the Kubernetes Deployment lesson demonstrates this exact mistake), a brief Postgres restart would fail readiness, which would then fail liveness, which would trigger a container restart across every replica simultaneously — turning a 10-second database blip into a full-service outage caused entirely by the orchestrator's own remediation.
Proving the Split Actually Distinguishes the Two Failure Modes
Applied exercise
Confirm the 2-second bound on Ready actually matters
The lesson claims the timeout keeps a stuck database from hanging the readiness probe itself. Prove that specifically.
- Temporarily change Ready's context.WithTimeout duration to 30 * time.Second.
- Simulate a hung connection (for example, using a firewall rule or docker compose pause postgres, which suspends the container without stopping it cleanly) and time how long a single /readyz request takes to respond.
- Restore the 2-second timeout, repeat the same simulated hang, and compare the two response times.
Deliverable
A short note recording both response times and confirming the 2-second version fails fast while the 30-second version does not.
Completion checks
- The note shows a measurable difference between the two timeout values under the same simulated failure.
- The timeout is restored to 2 seconds at the end of the exercise.
Why must /healthz never call the database, while /readyz must?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.