Health checks
This guide covers health checks in Tinka. You will learn how to:
- Tell liveness from readiness, and expose both
- Check dependencies such as a database, with deadlines
- Watch disk space and memory
- Cache slow checks, and write checks of your own
- Stop traffic before the App shuts down
- Keep the readiness report private
Overview
Load balancers and orchestrators such as Kubernetes probe an App to decide two things: whether to send it requests, and whether to restart it. The health package (github.com/mucolabs/tinka/health) answers both, with two routes the App registers for you.
A liveness probe asks whether the process is alive. If it fails repeatedly, the orchestrator restarts the App. It checks nothing but the App's ability to answer, so a database outage never sends the App into a restart loop.
A readiness probe asks whether the App can serve requests right now. It runs your checks, such as reaching the database. If it fails, the load balancer stops sending requests to this instance until it passes again, without restarting it.
| Probe | Route | Checks | On failure |
|---|---|---|---|
| Liveness | GET /health/live | Nothing | The App is restarted |
| Readiness | GET /health/ready | Every registered check | No requests are sent to it |
Register checks
Create the checks, and register the routes with app.Health. health.Ping turns any function that returns an error, such as *sql.DB.PingContext, into a check:
checks := health.New(
health.Ping("database", db.PingContext),
health.DiskSpace("/").CacheFor(time.Minute),
health.Memory(),
)
app.Health(checks)Checks run at the same time, so the readiness probe takes as long as the slowest one. Each has a deadline, 5 seconds by default: a check that hangs, such as a ping to an unreachable database, fails at its deadline instead of holding the probe until the orchestrator gives up. A check that panics fails too. Neither affects the other checks. A check runs once at a time: probes that arrive while it runs share its result, so frequent probes never pile up work on a struggling dependency.
health.Ping("search", search.Ping).Timeout(time.Second)Read the readiness report
The readiness route answers 200 when every check passes, and 503 when one fails, with each check's name and status. Messages and details, such as the error a database returned, can name hosts and addresses, so they appear only once you keep the route private and call ShowDetails. The full report then reads:
{
"healthy": true,
"status": "warning",
"finished_at": "2026-10-20T14:30:00.123Z",
"checks": [
{
"name": "database",
"status": "ok",
"message": "database responds",
"cached": false,
"duration_ns": 1203000
},
{
"name": "disk space /",
"status": "warning",
"message": "Disk usage is 77%, above the warning threshold of 75%",
"meta": {"used_percent": 77.1, "used_bytes": 82734563328, "total_bytes": 107374182400, "warn_above": 75, "fail_above": 80},
"cached": true,
"duration_ns": 0
}
]
}A check's status is ok, warning or error. A warning, such as a disk filling up, keeps the App ready: it's for your monitoring, which can alert on the report's status. Only an error makes the App unready.
Watch disk space and memory
health.DiskSpace checks how full the disk holding a path is. It warns above 75% and fails above 80%; health.WarnAbove and health.FailAbove change the thresholds:
health.DiskSpace("/var/lib/uploads", health.WarnAbove(85), health.FailAbove(95))health.Memory checks the memory the Go runtime holds against its memory limit, GOMEMLIMIT. It warns above 80% and fails above 90%. Set GOMEMLIMIT a little below your container's memory limit, so the Go runtime collects garbage harder before the container is killed. Without a limit, the check warns that none is set.
Cache slow checks
A check runs on every readiness probe, which may come every few seconds. CacheFor reuses a check's result for a while, for one that changes slowly, such as disk space, or costs much, such as a query against a large table. A cached result is marked "cached": true:
health.DiskSpace("/").CacheFor(5 * time.Minute)Write a check
health.NewCheck takes a name and a function that returns a health.Result. Build one with health.Pass, health.Warn or health.Fail, and add details with With. The function receives a context that ends at the check's deadline:
package main
import (
"context"
"fmt"
"time"
"github.com/mucolabs/tinka/health"
)
func paymentsCheck(gateway *Gateway) *health.Check {
return health.NewCheck("payments", func(ctx context.Context) health.Result {
start := time.Now()
if err := gateway.Ping(ctx); err != nil {
return health.Fail(fmt.Errorf("the payment gateway is unreachable: %w", err))
}
latency := time.Since(start)
meta := map[string]any{"latency_ms": latency.Milliseconds()}
if latency > 2*time.Second {
return health.Warn("the payment gateway is slow").With(meta)
}
return health.Pass("the payment gateway responds").With(meta)
})
}Stop traffic before shutting down
When a deployment replaces an instance, the load balancer keeps sending it requests until its next readiness probe fails. DrainDelay covers that gap: when the App receives a shutdown signal, readiness answers 503 with the status draining, the App keeps serving for the delay, and only then does it close the server and shut down. Set it a little longer than your load balancer's probe interval:
app := tinka.New(tinka.HTTPConfig{
DrainDelay: 5 * time.Second,
ShutdownTimeout: 10 * time.Second,
})The drain delay comes before the shutdown timeout, so keep their sum below your platform's own deadline, such as the 30-second grace period Kubernetes gives a pod.
Keep the readiness report private
The readiness report describes your infrastructure: which databases you use and how full your disks are. Pass middleware to app.Health to guard it; it applies to the readiness route only, so liveness stays open for the orchestrator. Behind it, ShowDetails adds each check's message and details to the report:
monitoringToken := e.Secret("MONITORING_TOKEN")
app.Health(checks.ShowDetails(), func(c *tinka.Ctx, next tinka.Next) error {
token := c.Request().Header.Get("X-Monitoring-Token")
if subtle.ConstantTimeCompare([]byte(token), []byte(monitoringToken.Reveal())) != 1 {
return tinka.Unauthorized("A monitoring token is required.")
}
return next(c)
})Configure the probe to send the header, such as httpHeaders in a Kubernetes readinessProbe.
See also: Application lifecycle, Middleware, App