# Health checks

> Report liveness and readiness to load balancers and orchestrators, check dependencies with deadlines and caching, and drain traffic before shutting down.

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:

```go title="main.go"
	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.

```go
	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](#keep-the-readiness-report-private) and call `ShowDetails`. The full report then reads:

```json title="Response"
{
  "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:

```go
	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`:

```go
	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:

```go title="health.go"
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](../concepts/application-lifecycle.md#the-shutdown-phase). Set it a little longer than your load balancer's probe interval:

```go title="main.go"
	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](../basics/middleware.md) 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:

```go title="main.go"
	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](../concepts/application-lifecycle.md), [Middleware](../basics/middleware.md), [App](../../reference/app.md)
