go-health is a small Go library for building asynchronous health monitoring
in Go services. It separates the problem into four layers:
checker: how to test one dependency.probe: when to run that test.subscriber: how to keep the latest probe state.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.
This repository focuses on a few constraints:
Related background:
go get github.com/alexfalkowski/go-health/v2
The module path includes /v2:
import "github.com/alexfalkowski/go-health/v2/server"
| 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. |
probe.Start performs an immediate check before the periodic loop continues.probe.Start is idempotent while running.probe.Stop is safe before start, safe to call multiple times, cancels the
context for in-flight checks, and waits for the probe worker to exit.server.Start waits for each service’s initial checks before returning.
Observer state is updated asynchronously after those probe ticks are fanned
out.server.Start once after setup, then call server.Stop once after
Start returns, typically during process shutdown.Stop finishes cleanup; if
Stop returns the supplied context error, cleanup did not complete.server.Start again while running is
a no-op, and calling server.Stop before Start or after a prior Stop is a
no-op. The same applies to server.Service, and Service.Stop without a prior
Start still releases observers created by Observe.server.Register and server.Observe are setup-time calls; finish
registration before calling Start.server.Register replaces an existing service with the same name.server.NewOnlineRegistration builds a registration named online; use
server.NewRegistration with checker.NewOnlineChecker if you need another
probe name.subscriber.Subscriber sends ticks on a best-effort, non-blocking channel; a
slow observer can miss intermediate ticks.subscriber.Observer starts with nil errors for every tracked probe name
and updates as ticks arrive.server.Error(kind) joins the current errors for every service that has an
observer of that kind. A nil result means every matching observer is
healthy; server.ErrObserverNotFound means no service has registered that
observer kind.server.Watch(kind) streams the current error for that observer kind and
future tick-derived errors until the returned watcher is stopped. It returns
server.ErrObserverNotFound when no service has registered that observer
kind.HTTPChecker, TCPChecker, DBChecker, and OnlineChecker use a default
timeout of 30s when you pass 0.Check calls; service startup starts probes concurrently.HTTPChecker treats 2xx HTTP status codes as healthy, does not follow
redirects, and wraps checker.ErrInvalidStatusCode for other responses.DBChecker and TCPChecker use checker.ErrTimeout as the timeout cause for
their derived per-call contexts.OnlineChecker reports healthy if any configured URL returns 200 OK or
204 No Content.ReadyChecker.Ready is a one-way transition; once ready, that checker stays
healthy.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]
Observevalidates that the service exists and every named probe was registered.Startwaits for initial checks, but observer state is updated after those probe ticks are fanned out; wait for a watcher update before treating the first observer read as authoritative.- Calling
Observeagain with the same service and kind is a no-op; it does not replace the existing observer.
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),
)
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:
https://google.com/generate_204https://cp.cloudflare.com/generate_204https://connectivity-check.ubuntu.comUse 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)
}
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)
}
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.
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.
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()
}
}
}
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:
STATUS_PORT, defaulting to 6000.alexfalkowski/status:latest for that service during CI.make specs runs that need the status service, run make start
before the tests and make stop afterward. These commands use the shared
bin/ Docker helper, which may require Docker, GitHub SSH access, and a
writable sibling ../docker checkout.doc.go files and exported symbol comments.example_test.go files.github.com/alexfalkowski/go-health/v2/....