Package

health

Liveness, readiness and startup endpoints with concurrent checks (SQLite, HTTP dependencies, files) in the application/health+json format

0.1.1 MIT db fs net

Install

sspur add health@^0.1.1

or in sspur.toml:

health = { version = "^0.1.1" }

README

health

Health endpoints for SSPUR HTTP services: liveness, readiness and startup probes for Kubernetes, load balancers and monitors, answering in the format of the IETF draft Health Check Response Format for HTTP APIs (application/health+json). Checks are data (a SQLite ping, an HTTP dependency with a timeout, a writable directory, a file that must exist, or a fixed observation), they run concurrently, a non-critical check that fails degrades the service to warn instead of fail, results can be cached for a while, and a draining switch turns readiness off during graceful shutdown. It covers what terminus (Node) and Spring Boot Actuator's health endpoints do.

Install

$ sspur add health

Example

use health.{Health}

fn api(req: Request) -> Response
= http.text(200, "hello")

fn setup(c: Db) -> Health
= health.health("notes") with version := some("3"), release_id := some("3.1.0"), readiness := [health.db_check("sqlite", c), health.non_critical(health.http_check("mailer", "http://127.0.0.1:1/health", 2000)), health.writable_check("uploads", "/tmp")]

// The real service: health endpoints in front of the API.
fn serve(c: Db) -> Unit ! net, log, db, fs, time, conc
= do
  h = setup(c)
  f = health.flags()
  paths = health.default_paths()
  http.serve_with(http.server(8080), req => health.routes(h, f, paths, req, api))

fn get(h: Health, f: health.Flags, path: Str) -> Response ! db, net, fs, time, conc
= health.routes(h, f, health.default_paths(), Request{method: "GET", url: path, headers: [], body: "", timeout_ms: 0, retries: 0, redirects: 0}, api)

fn main() -> Unit ! log, db, fail[DbErr], net, fs, time, conc
= do
  h = setup(db.open(":memory:"))
  f = health.flags()
  ready = get(h, f, "/health/ready")
  log("{ready.status} {ready.body}")
  health.start_draining(f)
  drained = get(h, f, "/health/ready")
  live = get(h, f, "/health/live")
  log("{drained.status} {drained.body}")
  log("{live.status} {live.body}")

Run as a dependent (health = { path = "..." }); the interpreter gives the same output apart from the timings:

$ sspur run main.ssp
200 {"status":"warn","version":"3","releaseId":"3.1.0","serviceId":"notes","checks":{"sqlite:responseTime":[{"componentType":"datastore","observedValue":0.427,"observedUnit":"ms","status":"pass","time":"2026-10-16T17:40:05.629Z"}],"mailer:responseTime":[{"componentId":"http://127.0.0.1:1/health","componentType":"component","observedValue":19.96,"observedUnit":"ms","status":"fail","time":"2026-10-16T17:40:05.629Z","output":"connect: Failed to connect to 127.0.0.1 port 1 after 0 ms: Couldn't connect to server"}],"uploads:writable":[{"componentId":"/tmp","componentType":"system","observedValue":true,"status":"pass","time":"2026-10-16T17:40:05.629Z"}]}}
503 {"status":"fail","version":"3","releaseId":"3.1.0","serviceId":"notes","output":"draining"}
200 {"status":"pass","version":"3","releaseId":"3.1.0","serviceId":"notes"}

The mailer is down, but it is not critical, so the service is warn and readiness still answers

  1. Once draining, readiness answers 503 at once without running the checks, while liveness keeps

passing so the orchestrator doesn't restart a service that is shutting down.

Semantics

  • Status codes: 200 for pass and warn, 503 for fail, as the draft recommends. Responses carry Cache-Control: no-cache, no-store, must-revalidate, and HEAD gets the status without a body. Other methods on a health path get 405 with Allow: GET, HEAD.
  • Aggregation: the service status is the worst contribution of its checks. A failing check that is not critical contributes warn. A passing check with warn_ms that took longer contributes warn with the output slower than N ms.
  • The started and draining state is a Flags value (an Atomic[Int] every handler shares), kept apart from the Health configuration so that stays a plain value. Readiness fails with the output starting until mark_started (new flags count as started; call mark_starting first if startup takes a while) and with draining after start_draining. Startup passes once started. Liveness runs only the liveness checks, which are usually none.
  • Checks never raise: a database error, a refused connection, a timeout or an unexpected status is a fail observation with an output that says what happened (connect: ..., timeout: ..., unexpected status 500).
  • Concurrency: each check runs as its own task, so a readiness request takes about as long as the slowest check. Bound HTTP checks with their timeout_ms.
  • Caching: with_cache(h, ttl_ms) keeps the last liveness and readiness responses for ttl_ms in an in-memory SQLite database that every handler thread shares. Draining is never cached.
  • Details: with details := false, responses carry only the status (and the output of a failure), so a public endpoint doesn't list the service's dependencies. Expose details on an internal port or path.
  • Custom checks: compute an Observation in the handler and pass it as extra: req => health.readiness(h, f, [("queue:depth", my_queue_check())], req). (Checks themselves are data, not functions, because handler tasks can't share function values.)

API

Statuses and observations

DefinitionWhat it is
StatusPass, Warn, Fail
severity(s), worst(a, b)Ordering by severity
status_name(s), parse_status(s)pass/warn/fail, and reading them (with ok, up, error, down)
http_status(s)200 or 503
ValueAn observedValue: NoValue, Number, Count, Text, Flag
Observation, observation(status, output)One entry of checks, and a minimal one for custom checks
ReportA whole response

Checks

DefinitionWhat it is
ProbeDbPing, HttpGet, Writable, Exists, Static
Check, check(name, probe)A named check, critical by default
db_check, http_check, writable_check, exists_checkCommon checks with their component types
non_critical(c)Degrade to warn when it fails
check_key(c)name:measurement
run_probe(p, now), run_check(c, now)Run one
run_checks(cs, now)Run several concurrently, in order
contribution(c, o), group_checks(obs)Aggregation helpers

The service

DefinitionWhat it is
Health, health(service_id)The configuration: identity, checks, details, cache
Flags, flags()The shared started and draining state
with_cache(h, ttl_ms)Cache responses
mark_starting(f), mark_started(f), is_started(f)Startup state
start_draining(f), stop_draining(f), is_draining(f)Graceful shutdown
report(h, checks, obs, extra)Aggregate observations into a report
readiness_report, liveness_report, startup_reportEvaluate one probe
json_string(s), to_json(r, details)The application/health+json body

HTTP

DefinitionWhat it is
respond(r, details, method)A report as a response
readiness(h, f, extra, req), liveness(h, extra, req), startup(h, f, req)The three endpoints
Paths, default_paths()/health/live, /health/ready, /health/startup
routes(h, f, paths, req, next)Answer the health paths, pass the rest on

Out of scope

  • Disk space: the standard library has no statfs, and running df would add the proc capability to every health function of every dependent. Check free space with your own Static observation if you need it, or rely on the platform's disk alerts.
  • Timeouts for database pings and custom checks (SQLite has its own 5 second busy timeout); only HTTP checks can be bounded.
  • Consuming other services' health+json documents, and push-style reporting to a monitoring system.

License

MIT, see LICENSE.

API

Status type

pub type Status = Pass | Warn | Fail derive Eq, Show

The status of a check or of the whole service: pass (healthy), warn (healthy, with concerns) or fail (unhealthy).

Value type

pub type Value = NoValue | Number{n: F64} | Count{n: Int} | Text{s: Str} | Flag{b: Bool} derive Eq, Show

What a check measured, for observedValue: nothing, a number, a count, text or a flag.

Observation type

pub type Observation = {component_id: Opt[Str], component_type: Opt[Str], observed_value: Value, observed_unit: Opt[Str], status: Status, affected_endpoints: List[Str], time: Opt[Time], output: Opt[Str], links: List[(Str, Str)]} derive Eq, Show

One entry of the checks object: the draft's componentId, componentType, observedValue, observedUnit, status, affectedEndpoints, time, output and links (relation, URI pairs).

Report type

pub type Report = {status: Status, version: Opt[Str], release_id: Opt[Str], service_id: Opt[Str], description: Opt[Str], notes: List[Str], output: Opt[Str], checks: List[(Str, List[Observation])]} derive Eq, Show

A whole health response: the overall status, the service's identity and the observations keyed by component:measurement, in the order the checks were given.

severity fn

fn severity(s: Status) -> Int

The severity order of statuses: pass 0, warn 1, fail 2.

worst fn

fn worst(a: Status, b: Status) -> Status

The more severe of two statuses.

status_name fn

fn status_name(s: Status) -> Str

The status's name in responses: pass, warn or fail.

parse_status fn

fn parse_status(s: Str) -> Opt[Status]

Reads a status name. The draft's aliases are accepted too: ok and up for pass, error and down for fail (any case).

http_status fn

fn http_status(s: Status) -> Int

The HTTP status for a health status: 200 for pass and warn, 503 for fail.

observation fn

fn observation(s: Status, output: Str) -> Observation

An observation with only a status (and an output for anything but pass), for custom checks.

Probe type

pub type Probe = DbPing{db: Db, query: Str} | HttpGet{url: Str, timeout_ms: Int, expect: List[Int]} | Writable{path: Str} | Exists{path: Str} | Static{obs: Observation} derive Eq, Show

What a check does. DbPing runs query (usually select 1) and measures it; HttpGet requests url within timeout_ms and expects a status in expect (any 2xx or 3xx when empty); Writable creates and removes a file in the directory path, or appends nothing to the file path; Exists checks that path exists; Static reports a fixed observation.

Check type

pub type Check = {name: Str where _.len > 0, measurement: Str, component_type: Str, component_id: Opt[Str], critical: Bool, warn_ms: Opt[Int], affected_endpoints: List[Str], links: List[(Str, Str)], probe: Probe} derive Eq, Show

A named check: its key in the response is name:measurement (just name when measurement is empty). A check that is not critical makes the service warn instead of fail when it fails (graceful degradation). With warn_ms, a passing check slower than that warns.

check fn

fn check(name: Str where _.len > 0, p: Probe) -> Check

A critical check named name with the measurement responseTime and the component type component.

db_check fn

fn db_check(name: Str where _.len > 0, c: Db) -> Check

A check of the database c with select 1, as the component name of type datastore.

http_check fn

fn http_check(name: Str where _.len > 0, url: Str, timeout_ms: Int where _ > 0) -> Check

A check of a dependency's URL: a GET within timeout_ms that must answer 2xx or 3xx.

writable_check fn

fn writable_check(name: Str where _.len > 0, path: Str) -> Check

A check that path (a directory, or a file) can be written, as a system component.

exists_check fn

fn exists_check(name: Str where _.len > 0, path: Str) -> Check

A check that path exists, as a system component.

non_critical fn

fn non_critical(c: Check) -> Check

c as a non-critical check: its failure makes the service warn, not fail.

check_key fn

fn check_key(c: Check) -> Str

The key of a check in the response.

run_probe fn db net fs

fn run_probe(p: Probe, now: Time) -> Observation ! db, net, fs, time

Runs one probe at now and gives its observation. Failures are observations, never errors.

run_check fn db net fs

fn run_check(c: Check, now: Time) -> Observation ! db, net, fs, time

Runs a check at now: its probe, then the check's identity, links and slowness threshold.

run_checks fn db net fs

fn run_checks(cs: List[Check], now: Time) -> List[(Str, Observation)] ! db, net, fs, time, conc

Runs every check at once (one task each) at now, and gives the observations in the order of cs, each with the key of its check.

contribution fn

fn contribution(c: Check, o: Observation) -> Status

The status a check contributes to the service: a failing non-critical check only warns.

group_checks fn

fn group_checks(obs: List[(Str, Observation)]) -> List[(Str, List[Observation])]

Groups keyed observations by key, keeping the first-seen order of keys.

Health type

pub type Health = {service_id: Opt[Str], version: Opt[Str], release_id: Opt[Str], description: Opt[Str], notes: List[Str], liveness: List[Check], readiness: List[Check], details: Bool, cache_ms: Int where _ >= 0, cache: Opt[Db]} derive Eq, Show

A service's health setup: its identity for the response, its liveness and readiness checks, whether responses list the checks or give the status only (details), and the response cache (see with_cache). The started and draining state lives apart, in Flags.

health fn

fn health(service_id: Str) -> Health

A health setup for the service service_id with no checks, details shown and no caching. Add checks with with readiness := [...].

Flags type

pub type Flags = Atomic[Int]

Whether the service has started and whether it is draining, shared by every handler: bit 0 is started, bit 1 draining.

flags fn

fn flags() -> Flags ! conc

New flags: started and not draining.

with_cache fn db

fn with_cache(h: Health, ttl_ms: Int where _ > 0) -> Health ! db, fail[DbErr]

h with responses cached for ttl_ms milliseconds, in an in-memory SQLite database shared by every handler, so a busy probe doesn't run the checks on every request.

mark_starting fn

fn mark_starting(f: Flags) -> Unit ! conc

Marks the service as still starting: the startup and readiness endpoints fail until mark_started.

mark_started fn

fn mark_started(f: Flags) -> Unit ! conc

Marks the service as started.

is_started fn

fn is_started(f: Flags) -> Bool ! conc

Whether the service has started.

start_draining fn

fn start_draining(f: Flags) -> Unit ! conc

Starts draining: readiness fails at once (so load balancers stop sending traffic) while liveness still passes and requests in flight finish. Call it when shutdown begins.

stop_draining fn

fn stop_draining(f: Flags) -> Unit ! conc

Stops draining.

is_draining fn

fn is_draining(f: Flags) -> Bool ! conc

Whether the service is draining.

report fn

fn report(h: Health, checks: List[Check], obs: List[(Str, Observation)], extra: List[(Str, Observation)]) -> Report

The report for checks with their observations and extra custom observations, keyed.

readiness_report fn db net fs

fn readiness_report(h: Health, f: Flags, extra: List[(Str, Observation)], now: Time) -> Report ! db, net, fs, time, conc

Evaluates readiness at now: fails without running checks while starting or draining, otherwise runs the readiness checks and folds in extra.

liveness_report fn db net fs

fn liveness_report(h: Health, extra: List[(Str, Observation)], now: Time) -> Report ! db, net, fs, time, conc

Evaluates liveness at now: the liveness checks (often none, which passes) and extra. Draining does not affect liveness, so an orchestrator doesn't restart a service that is shutting down.

startup_report fn

fn startup_report(h: Health, f: Flags) -> Report ! conc

Evaluates startup: passes once the service is started.

json_string fn

fn json_string(s: Str) -> Str

s as a JSON string literal, with quotes, backslashes and control characters escaped.

to_json fn

fn to_json(r: Report, details: Bool) -> Str

The report as application/health+json. With details false only the status (and the output of a failure) is included, so a public endpoint doesn't reveal the service's dependencies.

respond fn

fn respond(r: Report, details: Bool, method: Str) -> Response

The response for a report: 200 or 503, application/health+json, never cached by clients and proxies, and no body for a HEAD request.

readiness fn db net fs

fn readiness(h: Health, f: Flags, extra: List[(Str, Observation)], req: Request) -> Response ! db, net, fs, time, conc

The readiness endpoint: readiness_report with the custom observations extra, cached when caching is on (the draining state is never cached: it answers at once).

liveness fn db net fs

fn liveness(h: Health, extra: List[(Str, Observation)], req: Request) -> Response ! db, net, fs, time, conc

The liveness endpoint, cached when caching is on.

startup fn

fn startup(h: Health, f: Flags, req: Request) -> Response ! conc

The startup endpoint.

Paths type

pub type Paths = {live: Str, ready: Str, startup: Str} derive Eq, Show

The paths the endpoints answer on.

default_paths fn

fn default_paths() -> Paths

/health/live, /health/ready and /health/startup.

routes fn db net fs

fn routes[e](h: Health, f: Flags, p: Paths, req: Request, next: Request -> Response ! e) -> Response ! db, net, fs, time, conc, e

Answers GET and HEAD requests for the three paths and passes everything else to next, so it goes in front of a handler: req => health.routes(h, f, health.default_paths(), req, api). Other methods on a health path get 405 with Allow.

Versions

VersionPublishedCommitSourceEffects
0.1.1 2026-10-18fea1cf47e7snapshotdb fs net
0.1.0 2026-10-1613c3c86974snapshotdb fs net