ProjectsmahiHealth

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Health checks

An application declares the things that must be working, in a provider's checks() hook, and gets both an HTTP endpoint and a CLI command over the same runner and the same result shape.

export class AppServiceProvider extends ServiceProvider {
  checks(): HealthCheck[] {
    return [
      {
        name: "stripe",
        async run() {
          const res = await fetch("https://api.stripe.com/healthcheck");
          if (!res.ok) return `Stripe returned ${res.status}`;
        },
      },
    ];
  }
}
{"core":{"cache":true,"database":true,"filesystem":true},
 "app":{"daemon":true,"stripe":"Failed to connect"}}

true passed, a string is why it failed, null was skipped. GET /health returns 200 when everything passed and 503 when anything failed; ./artisan health prints a table and exits 0 or 1.

Liveness vs readiness

This is the part to understand before anything else, because the two endpoints look redundant and are not.

@mahiframework/http registers an opt-in GET /up that does no I/O at all. @mahiframework/health adds an opt-in GET /health that runs every check. They are the two halves of the standard orchestrator split:

/up/health
QuestionIs the process alive?Should this instance receive traffic?
Kubernetes probelivenessProbereadinessProbe
Failure meansRestart the podDrain it, leave it running
I/ONoneEvery registered check
During maintenance200 (exempt)503 (not exempt)
Confighttp.livenesshttp.healthCheck

Do not collapse these into one endpoint. If /up started doing real I/O, a Redis blip would fail the liveness probe and Kubernetes would restart every pod in the deployment, converting a recoverable dependency outage into a full outage, plus a thundering-herd reconnect against the dependency that was already struggling. The blast radius of a wrong readiness answer is "this instance stops receiving traffic"; the blast radius of a wrong liveness answer is the entire deployment. That asymmetry is the whole reason two probes exist.

For the same reason /health is deliberately not exempt from maintenance mode while /up is. An orchestrator must be able to tell a down-for-maintenance app from a dead one, so /up keeps answering 200. But a readiness probe answering "ready" while an operator has explicitly taken the app down would put traffic straight back onto it.

Writing a check

A check is a plain object, a name and a function. There is no base class to extend and no file-per-check convention.

import type { HealthCheck } from "@mahiframework/health";

const stripe: HealthCheck = {
  name: "stripe",
  run: () => stripeClient.ping(),
};
Field
nameResult-object key within the group. Lowercase and stable. It goes in the JSON.
groupResult-object group. Defaults to "app".
timeoutSecondsPer-check deadline. Defaults to health.timeoutSeconds (5).
run(app)The probe. Receives the Application.

The three ways to report

These look like three ways to say one thing. They are not.

// Throw — what a real dependency failure does on its own.
run: () => cache.put("k", "v"),           // ECONNREFUSED propagates as the message

// Return a string — completed normally, but the result is wrong.
run: () => disk.usage() > 0.9 ? "disk 94% full" : undefined,

// Return null — skip. This app doesn't use this dependency.
run: (app) => app.has(STRIPE_TOKEN) ? stripe.ping() : null,

Returning nothing (or true) passes.

Throwing is not an error you need to catch. A driver rejecting with ECONNREFUSED is the check working correctly; the registry converts it to that check's failure message. Wrapping every check in try/catch to return a string instead is pure ceremony.

Skipped is not passed. A database check in an app with no database bound has verified nothing, and reporting true there is a lie that will eventually be believed. A skipped check does not fail the report.

Checks must be cheap

run() executes on every probe interval, on every instance. A check that does a table scan is a self-inflicted load source that gets worse exactly when the system is already unwell. Probe one trivial round trip; never aggregate, never scan.

The built-in checks

@mahiframework/health registers three under the core group. Each skips itself when its package isn't installed, so an app without @mahiframework/storage reports "filesystem": null rather than failing to start.

CheckWhat it does
cachePuts a random value on the default store, reads it back, asserts equality, forgets it.
databaseselect 1 on the default connection.
filesystemWrites a unique file to the default disk under health-check/, reads it back, compares, deletes it.

The cache and filesystem checks are deliberately round trips, not writes. A put that succeeds against a store whose reads are broken, a full disk, a replica accepting writes it discards, a filesystem that went read-only after the mount was cached, reports healthy. Reading back a value only this invocation could have written is the only assertion that catches it.

The database check uses select 1 rather than introspecting tables: it is portable across sqlite/MySQL/Postgres, touches no application table, and cannot be affected by schema state. It checks the default connection only, checking every configured connection means a probe whose cost scales with your config file and which fails on a deliberately-offline analytics replica. Register a second check if you need a second connection probed.

Replacing a built-in

Register a check with the same group and name. Later registrations win, and framework providers are collected first, so your app always gets the last word.

checks(): HealthCheck[] {
  return [
    { group: "core", name: "database", run: () => myManagedDb.ping() },
  ];
}

The endpoint

Opt in from config/http.ts. Nothing is mounted unless the key is set.

export function httpConfig(env: Env): HttpConfig {
  return {
    liveness: {},                              // GET /up
    healthCheck: { secret: env.HEALTH_SECRET }, // GET /health
  };
}
Key
pathRoute path. Defaults to /health.
failureStatusStatus when any check fails. Defaults to 503.
secretX-Health-Secret value that un-redacts messages in production.

503 rather than 500 is deliberate: a load balancer drains a 503 and pages on a 500, and a failing dependency is the former.

The route is registered by @mahiframework/http when @mahiframework/health is installed and the config key is set. @mahiframework/health itself depends only on @mahiframework/core, so ./artisan health works in an app with no HTTP package at all.

Redaction

Failure messages are driver errors, and they name internal topology:

connect ECONNREFUSED 10.0.1.4:5432
getaddrinfo ENOTFOUND prod-redis.internal
SQLITE_CANTOPEN: unable to open database file /srv/app/storage/prod.sqlite

/health is by definition reachable from whatever probes it, often a load balancer, sometimes the internet, and always before anyone remembers to put an ACL on it. So in production every failure message is replaced with "Check failed" unless the request carries X-Health-Secret matching the configured secret:

curl -H "X-Health-Secret: $HEALTH_SECRET" https://example.com/health

Which checks exist and which failed stays visible; only the message goes. Redaction never applies outside production, and never on the CLI. That runs inside the trust boundary, and an operator who has SSH'd into the box needs the real message.

The header mirrors X-Maintenance-Secret. Unlike that one it is header-only: accepting a secret in the URL path is right for a browser being pointed at a downed site, and wrong for a machine probe that would write it into every access log.

The command

./artisan health
./artisan health --json
  Group   Check        Status
  core    cache        ✔ ok
  core    database     ✔ ok
  core    filesystem   ○ skipped
  app     stripe       ✘ Failed to connect

  4 checks, 1 failed (124ms)

Exits 1 if any check failed, 0 otherwise, so it works as a deployment gate or a smoke test:

./artisan health || exit 1

--json emits exactly the same payload the endpoint serializes, so CI and your load balancer are looking at the same bytes:

./artisan health --json | jq '.core.database'

Configuration

The optional health namespace:

export function healthConfig(): HealthConfig {
  return {
    timeoutSeconds: 5,
    concurrency: 1,
  };
}
Key
timeoutSecondsDefault per-check deadline. Defaults to 5.
concurrencyHow many checks run at once. Defaults to 1.

Why checks run sequentially

Parallel is the tempting default and it is wrong for a probe. Every check is I/O against a dependency that is already suspected of being unwell. That is why it is being probed. Firing all of them at once, once per probe interval, from every instance, is a synchronised burst of connection attempts at exactly the moment the dependency can least absorb it. A health check that amplifies the outage it was installed to detect is a well-known operational hazard.

Sequential also keeps the timeout arithmetic legible: N checks at a 5s deadline is a worst case of N×5s, which you can compare against your probe interval. Raise concurrency only once you have measured the worst case and found it too slow.

Timeouts

Every check races a deadline, and a check that exceeds it reports Timed out after 5s rather than hanging. Without this, one check on a TCP connection with no socket timeout would hang the request until the load balancer's own timeout fired, at which point the balancer has learned nothing, and the app is holding one open request per probe interval, forever.

Kubernetes

livenessProbe:
  httpGet: { path: /up, port: 8000 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /health, port: 8000 }
  periodSeconds: 10
  httpHeaders:
    - name: X-Health-Secret
      valueFrom: { secretKeyRef: { name: app-secrets, key: health-secret } }

Set periodSeconds above your worst-case run (checks × timeoutSeconds), or probes will overlap.

See also