go-health

Gopher CircleCI codecov Go Report Card Go Reference Stability: Active

🩺 go-health

go-health is a small Go library for building asynchronous health monitoring in Go services. It separates the problem into four layers:

  1. checker: how to test one dependency.
  2. probe: when to run that test.
  3. subscriber: how to keep the latest probe state.
  4. server: how to orchestrate probes and observers for a service.

This keeps the pieces reusable. You can use a single checker directly, wire your own probe loop, or use the server package for a complete “register, observe, start, stop” workflow.

🎯 Why this library

This repository focuses on a few constraints:

Related background:

📦 Installation

go get github.com/alexfalkowski/go-health/v2

The module path includes /v2:

import "github.com/alexfalkowski/go-health/v2/server"

🧭 Package overview

Package Purpose
checker Reusable health checks for HTTP, TCP, SQL ping, online connectivity, readiness gates, and no-op checks.
probe Periodically runs a checker and emits ticks.
subscriber Best-effort tick fan-out and latest-error tracking for selected probe names.
server Registers probes per service, creates observers, and manages lifecycle.
watcher Shared subscription interface returned by observer and server watch entry points.
net Small interfaces used to customize network dialing.
sql Small interfaces used to customize database pinging.

🔑 Key behaviors

⏱️ Probe and server lifecycle

🧩 Registration and observers

✅ Checker defaults

🚦 End-to-end example

The server package is the usual entry point when you want one or more health views for a service:

package main

import (
	"context"
	"errors"
	"log"
	"time"

	"github.com/alexfalkowski/go-health/v2/checker"
	"github.com/alexfalkowski/go-health/v2/server"
)

func main() {
	const period = 100 * time.Millisecond

	errNotReady := errors.New("starting up")
	readyChecker := checker.NewReadyChecker(errNotReady)

	s := server.NewServer()

	liveRegistration := server.NewRegistration("live", period, checker.NewNoopChecker())
	readyRegistration := server.NewRegistration("ready", period, readyChecker)

	s.Register("payments", liveRegistration, readyRegistration)

	if err := s.Observe(
		"payments",
		"livez",
		liveRegistration.Name,
	); err != nil {
		log.Fatal(err)
	}

	if err := s.Observe(
		"payments",
		"readyz",
		liveRegistration.Name,
		readyRegistration.Name,
	); err != nil {
		log.Fatal(err)
	}

	if err := s.Start(context.Background()); err != nil {
		log.Fatal(err)
	}
	defer func() {
		_ = s.Stop(context.Background())
	}()

	readyzWatcher, err := s.Watch("readyz")
	if err != nil {
		log.Fatal(err)
	}
	defer readyzWatcher.Close()

	if !waitForUpdate(readyzWatcher.Receive(), func(err error) bool {
		return errors.Is(err, errNotReady)
	}) {
		log.Fatal("readyz did not report startup state")
	}

	readyChecker.Ready()

	if !waitForUpdate(readyzWatcher.Receive(), func(err error) bool {
		return err == nil
	}) {
		log.Fatal("readyz did not become healthy")
	}

	if err := s.Error("livez"); err != nil {
		log.Printf("livez unhealthy: %v", err)
	}

	if err := s.Error("readyz"); err != nil {
		log.Printf("readyz unhealthy: %v", err)
	}

	readyz, err := s.Observer("payments", "readyz")
	if err != nil {
		log.Fatal(err)
	}

	for name, err := range readyz.Errors() {
		log.Printf("readyz %s = %v", name, err)
	}
}

func waitForUpdate(updates <-chan error, match func(error) bool) bool {
	timeout := time.After(time.Second)
	for {
		select {
		case err := <-updates:
			if match(err) {
				return true
			}
		case <-timeout:
			return false
		}
	}
}

[!NOTE]

🧪 Direct checker examples

🌐 HTTP checker

HTTPChecker performs a GET request and evaluates the direct response from that endpoint. It does not follow redirects. 2xx status codes are considered healthy; other responses are unhealthy.

check := checker.NewHTTPChecker("https://example.com/health", 5*time.Second)

if err := check.Check(context.Background()); err != nil {
	log.Printf("endpoint unhealthy: %v", err)
}

Use checker.WithRoundTripper when you need a custom transport:

transport := &http.Transport{}
check := checker.NewHTTPChecker(
	"https://example.com/health",
	5*time.Second,
	checker.WithRoundTripper(transport),
)

Use checker.WithHeader when the health endpoint requires static request headers:

check := checker.NewHTTPChecker(
	"https://example.com/health",
	5*time.Second,
	checker.WithHeader("X-Health-Token", token),
)

🌍 Online checker

OnlineChecker is useful when the question is “can this process reach the outside world?” rather than “is this exact upstream healthy?”

By default it checks:

Use checker.WithURLs with at least one URL to replace that default list. An empty replacement keeps the built-in defaults. The checker sends requests to all configured URLs concurrently and reports healthy as soon as any one returns 200 OK or 204 No Content; remaining requests are canceled.

check := checker.NewOnlineChecker(
	5*time.Second,
	checker.WithURLs(
		"https://google.com/generate_204",
		"https://cp.cloudflare.com/generate_204",
	),
)

if err := check.Check(context.Background()); err != nil {
	log.Printf("no outbound connectivity: %v", err)
}

🗄️ Database checker

DBChecker depends on the small sql.Pinger interface. A standard *database/sql.DB satisfies that interface, so you can use it directly:

var db *sql.DB // initialized by your application

check := checker.NewDBChecker(db, 5*time.Second)

if err := check.Check(context.Background()); err != nil {
	log.Printf("database unhealthy: %v", err)
}

🚪 Readiness gate

ReadyChecker lets your application control readiness explicitly:

ready := checker.NewReadyChecker(errors.New("warming caches"))

if err := ready.Check(context.Background()); err != nil {
	log.Printf("not ready yet: %v", err)
}

// Later, once startup work is complete.
ready.Ready()

Pass a non-nil sentinel error when the gate should start unhealthy. Passing nil makes the checker report healthy before Ready is called.

⏱️ Manual probe usage

If you do not need the server package, you can work directly with a probe:

p := probe.NewProbe("api", 10*time.Second, checker.NewNoopChecker())
ticks, err := p.Start(context.Background())
if err != nil {
	log.Fatal(err)
}
defer p.Stop(context.Background())

tick := <-ticks
log.Printf("%s healthy=%t", tick.Name(), tick.Error() == nil)

If the probe should keep running, continue receiving from ticks until you call Stop; the direct probe channel is buffered but does not drop unread ticks. Stop cancels the context for any check still running and waits for the probe goroutine to exit, or returns the supplied context error if that wait is canceled.

👀 Working with observers

Observers give you both a summary and a detailed view:

summary := observer.Error()  // joined error across unhealthy probes
details := observer.Errors() // copy of the latest per-probe error map

Errors() returns a copy, so callers can inspect or mutate the returned map without affecting the observer’s internal state.

When you manage multiple services, Server.Observers(kind) iterates the services that have that observer kind and skips services that do not:

for name, observer := range s.Observers("readyz") {
	log.Printf("%s ready=%t", name, observer.Error() == nil)
}

The iteration order is unspecified. Collect and sort the service names if you need stable endpoint output, logs, or tests.

When a transport needs a single answer for all services with the same observer kind, use Server.Error(kind). It returns server.ErrObserverNotFound when no service has registered that kind.

if err := s.Error("readyz"); err != nil {
	log.Printf("readyz unhealthy: %v", err)
}

To avoid polling, watch the observer kind and report each update. The stream sends the current state first, then future tick-derived states. Close the watcher when the receiver is done; the receive channel closes when the watcher is closed, not when the server stops. Server.Watch returns server.ErrObserverNotFound when no service has registered that kind.

func streamReady(ctx context.Context, s *server.Server, send func(bool) error) error {
	watcher, err := s.Watch("readyz")
	if err != nil {
		return err
	}
	defer watcher.Close()

	for {
		select {
		case err, ok := <-watcher.Receive():
			if !ok {
				return nil
			}
			if err := send(err == nil); err != nil {
				return err
			}
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

🛠️ Development

This repository uses a bin/ git submodule for shared build tooling. Before running the make targets, initialize the submodule:

git submodule sync
git submodule update --init

Common commands:

make dep
make lint
make sec
make specs
make coverage

Benchmarks:

make package=server benchmark
make benchmarks
make server-benchmarks

Fuzz smoke tests:

make fuzzes
make checker-fuzz
make subscriber-fuzz
make server-fuzz
make package=checker name=FuzzHTTPCheckerRequestAndStatus fuzztime=1000x fuzz

Local test notes:

📚 Documentation