From 8d6930b087482fc87fdad9607fb21ba08bc4bd78 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Mon, 11 May 2026 19:05:27 -0600 Subject: [PATCH] =?UTF-8?q?feat(health)!:=20promote=20to=20v1.0.0=20?= =?UTF-8?q?=E2=80=94=20configurable=20check=20timeout=20via=20NewHandlerWi?= =?UTF-8?q?thConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Config struct and NewHandlerWithConfig(logger, cfg, checks...) constructor. Config.CheckTimeout sets the per-request deadline for all health checks; zero value defaults to 5 seconds. NewHandler remains unchanged as a backward-compatible wrapper. API committed as stable. --- CHANGELOG.md | 16 ++++++++++++++++ health.go | 33 +++++++++++++++++++++++++++------ health_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 685eaf1..1e5966b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this module will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] — 2026-05-12 + +### Added + +- `Config` struct — `CheckTimeout time.Duration`; zero value defaults to 5 seconds. +- `NewHandlerWithConfig(logger Logger, cfg Config, checks ...Checkable) http.Handler` — + constructor with explicit configuration. `NewHandler` is now a backward-compatible + wrapper that calls `NewHandlerWithConfig` with zero `Config`. + +### Unchanged + +All existing API (`Level`, `LevelCritical`, `LevelDegraded`, `Checkable`, `Logger`, +`ComponentStatus`, `Response`, `NewHandler`) is API-compatible with v0.9.0. + +[1.0.0]: https://code.nochebuena.dev/go/health/releases/tag/v1.0.0 + ## [0.9.0] - 2026-03-18 ### Added diff --git a/health.go b/health.go index 2eb6e56..0e140d8 100644 --- a/health.go +++ b/health.go @@ -48,23 +48,44 @@ type Response struct { Components map[string]ComponentStatus `json:"components"` } -type handler struct { - logger Logger - checks []Checkable +// Config configures a health handler. +// The zero value is valid: 5-second check timeout. +type Config struct { + // CheckTimeout is the per-request deadline for all health checks. + // Defaults to 5 seconds when zero. + CheckTimeout time.Duration } -// NewHandler returns an http.Handler for the health endpoint. +const defaultCheckTimeout = 5 * time.Second + +type handler struct { + logger Logger + checks []Checkable + timeout time.Duration +} + +// NewHandler returns an http.Handler for the health endpoint with default configuration. // Runs all checks concurrently with a 5-second timeout. // Returns 200 (UP/DEGRADED) or 503 (DOWN). func NewHandler(logger Logger, checks ...Checkable) http.Handler { - return &handler{logger: logger, checks: checks} + return NewHandlerWithConfig(logger, Config{}, checks...) +} + +// NewHandlerWithConfig returns an http.Handler configured by cfg. +// If cfg.CheckTimeout is zero, a 5-second default is used. +func NewHandlerWithConfig(logger Logger, cfg Config, checks ...Checkable) http.Handler { + timeout := cfg.CheckTimeout + if timeout == 0 { + timeout = defaultCheckTimeout + } + return &handler{logger: logger, checks: checks, timeout: timeout} } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { logger := h.logger.WithContext(r.Context()) logger.Debug("health: running checks") - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), h.timeout) defer cancel() type result struct { diff --git a/health_test.go b/health_test.go index caff1db..a6bef20 100644 --- a/health_test.go +++ b/health_test.go @@ -167,6 +167,30 @@ func TestHandler_JSON_Shape(t *testing.T) { } } +func TestHandlerWithConfig_CustomTimeout(t *testing.T) { + // 200ms delay check with a 50ms timeout — should be reported as DOWN. + h := NewHandlerWithConfig(&noopLogger{}, Config{CheckTimeout: 50 * time.Millisecond}, + &mockCheck{name: "slow", priority: LevelCritical, delay: 200 * time.Millisecond}, + ) + code, resp := doRequest(t, h) + if code != http.StatusServiceUnavailable { + t.Errorf("want 503, got %d", code) + } + if resp.Status != "DOWN" { + t.Errorf("want DOWN, got %s", resp.Status) + } +} + +func TestHandlerWithConfig_ZeroTimeout_UsesDefault(t *testing.T) { + h := NewHandlerWithConfig(&noopLogger{}, Config{}, + &mockCheck{name: "db", priority: LevelCritical}, + ) + code, resp := doRequest(t, h) + if code != http.StatusOK || resp.Status != "UP" { + t.Errorf("want 200 UP, got %d %s", code, resp.Status) + } +} + func TestHandler_ContextTimeout(t *testing.T) { // Check that times out faster than the 5s global timeout when client cancels. h := NewHandler(&noopLogger{},