Status type
pub type Status = Pass | Warn | Fail derive Eq, ShowThe status of a check or of the whole service: pass (healthy), warn (healthy, with concerns) or fail (unhealthy).
Package
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
sspur add health@^0.1.1or in sspur.toml:
health = { version = "^0.1.1" }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.
$ sspur add health
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
passing so the orchestrator doesn't restart a service that is shutting down.
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.warn. A passing check with warn_ms that took longer contributes warn with the output slower than N ms.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.fail observation with an output that says what happened (connect: ..., timeout: ..., unexpected status 500).timeout_ms.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 := 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.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.)| Definition | What it is |
|---|---|
Status | Pass, 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 |
Value | An observedValue: NoValue, Number, Count, Text, Flag |
Observation, observation(status, output) | One entry of checks, and a minimal one for custom checks |
Report | A whole response |
| Definition | What it is |
|---|---|
Probe | DbPing, HttpGet, Writable, Exists, Static |
Check, check(name, probe) | A named check, critical by default |
db_check, http_check, writable_check, exists_check | Common 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 |
| Definition | What 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_report | Evaluate one probe |
json_string(s), to_json(r, details) | The application/health+json body |
| Definition | What 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 |
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.MIT, see LICENSE.
pub type Status = Pass | Warn | Fail derive Eq, ShowThe status of a check or of the whole service: pass (healthy), warn (healthy, with concerns) or fail (unhealthy).
pub type Value = NoValue | Number{n: F64} | Count{n: Int} | Text{s: Str} | Flag{b: Bool} derive Eq, ShowWhat a check measured, for observedValue: nothing, a number, a count, text or a flag.
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, ShowOne entry of the checks object: the draft's componentId, componentType, observedValue, observedUnit, status, affectedEndpoints, time, output and links (relation, URI pairs).
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, ShowA whole health response: the overall status, the service's identity and the observations keyed by component:measurement, in the order the checks were given.
fn severity(s: Status) -> IntThe severity order of statuses: pass 0, warn 1, fail 2.
fn worst(a: Status, b: Status) -> StatusThe more severe of two statuses.
fn status_name(s: Status) -> StrThe status's name in responses: pass, warn or fail.
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).
fn http_status(s: Status) -> IntThe HTTP status for a health status: 200 for pass and warn, 503 for fail.
fn observation(s: Status, output: Str) -> ObservationAn observation with only a status (and an output for anything but pass), for custom checks.
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, ShowWhat 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.
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, ShowA 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.
fn check(name: Str where _.len > 0, p: Probe) -> CheckA critical check named name with the measurement responseTime and the component type component.
fn db_check(name: Str where _.len > 0, c: Db) -> CheckA check of the database c with select 1, as the component name of type datastore.
fn http_check(name: Str where _.len > 0, url: Str, timeout_ms: Int where _ > 0) -> CheckA check of a dependency's URL: a GET within timeout_ms that must answer 2xx or 3xx.
fn writable_check(name: Str where _.len > 0, path: Str) -> CheckA check that path (a directory, or a file) can be written, as a system component.
fn exists_check(name: Str where _.len > 0, path: Str) -> CheckA check that path exists, as a system component.
fn non_critical(c: Check) -> Checkc as a non-critical check: its failure makes the service warn, not fail.
fn check_key(c: Check) -> StrThe key of a check in the response.
fn run_probe(p: Probe, now: Time) -> Observation ! db, net, fs, timeRuns one probe at now and gives its observation. Failures are observations, never errors.
fn run_check(c: Check, now: Time) -> Observation ! db, net, fs, timeRuns a check at now: its probe, then the check's identity, links and slowness threshold.
fn run_checks(cs: List[Check], now: Time) -> List[(Str, Observation)] ! db, net, fs, time, concRuns 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.
fn contribution(c: Check, o: Observation) -> StatusThe status a check contributes to the service: a failing non-critical check only warns.
fn group_checks(obs: List[(Str, Observation)]) -> List[(Str, List[Observation])]Groups keyed observations by key, keeping the first-seen order of keys.
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, ShowA 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.
fn health(service_id: Str) -> HealthA health setup for the service service_id with no checks, details shown and no caching. Add checks with with readiness := [...].
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.
fn flags() -> Flags ! concNew flags: started and not draining.
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.
fn mark_starting(f: Flags) -> Unit ! concMarks the service as still starting: the startup and readiness endpoints fail until mark_started.
fn mark_started(f: Flags) -> Unit ! concMarks the service as started.
fn is_started(f: Flags) -> Bool ! concWhether the service has started.
fn start_draining(f: Flags) -> Unit ! concStarts 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.
fn stop_draining(f: Flags) -> Unit ! concStops draining.
fn is_draining(f: Flags) -> Bool ! concWhether the service is draining.
fn report(h: Health, checks: List[Check], obs: List[(Str, Observation)], extra: List[(Str, Observation)]) -> ReportThe report for checks with their observations and extra custom observations, keyed.
fn readiness_report(h: Health, f: Flags, extra: List[(Str, Observation)], now: Time) -> Report ! db, net, fs, time, concEvaluates readiness at now: fails without running checks while starting or draining, otherwise runs the readiness checks and folds in extra.
fn liveness_report(h: Health, extra: List[(Str, Observation)], now: Time) -> Report ! db, net, fs, time, concEvaluates 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.
fn startup_report(h: Health, f: Flags) -> Report ! concEvaluates startup: passes once the service is started.
fn json_string(s: Str) -> Strs as a JSON string literal, with quotes, backslashes and control characters escaped.
fn to_json(r: Report, details: Bool) -> StrThe 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.
fn respond(r: Report, details: Bool, method: Str) -> ResponseThe response for a report: 200 or 503, application/health+json, never cached by clients and proxies, and no body for a HEAD request.
fn readiness(h: Health, f: Flags, extra: List[(Str, Observation)], req: Request) -> Response ! db, net, fs, time, concThe readiness endpoint: readiness_report with the custom observations extra, cached when caching is on (the draining state is never cached: it answers at once).
fn liveness(h: Health, extra: List[(Str, Observation)], req: Request) -> Response ! db, net, fs, time, concThe liveness endpoint, cached when caching is on.
fn startup(h: Health, f: Flags, req: Request) -> Response ! concThe startup endpoint.
pub type Paths = {live: Str, ready: Str, startup: Str} derive Eq, ShowThe paths the endpoints answer on.
fn default_paths() -> Paths/health/live, /health/ready and /health/startup.
fn routes[e](h: Health, f: Flags, p: Paths, req: Request, next: Request -> Response ! e) -> Response ! db, net, fs, time, conc, eAnswers 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.
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.1.1 | 2026-10-18 | fea1cf47e7 | snapshot | db fs net |
| 0.1.0 | 2026-10-16 | 13c3c86974 | snapshot | db fs net |