servant-health
Kubernetes liveness and readiness probe endpoints for servant.
Every service that runs under Kubernetes writes the same eighty lines: two
routes, a JSON status body, a 503 mapping, and the wiring that keeps them
apart. This package provides that surface once — a fixed probe wire type, typed
200/503 responses via MultiVerb, an injectable check seam, path constants, and
a conformance kit — and deliberately ships no checks of its own. What
"healthy" means for your service is yours to decide.
GET /health/live → 200 {"status":"ok","check":"all","failingSince":null}
GET /health/ready → 503 {"status":"failed","check":"database","failingSince":"2026-07-24T12:03:41Z"}
Installation
build-depends:
, servant-health ^>=0.1
Requires GHC 9.12 (base >=4.21, GHC2024) and servant 0.20.
servant-health depends on openapi-hs (>=5.0 && <5.1), a fork
of openapi3, because the ToSchema instance for the probe body is non-orphan.
Upstream servant-openapi3 has no HasOpenApi instance for MultiVerb at all,
so a service deriving an OpenAPI document for these routes needs
servant-openapi-hs (>=5.1 && <5.2) rather than the
upstream package. Both forks are on Hackage. See
ADR 7 for why only this cohort is
supported.
Quick start
Mount HealthApi under "health" on your umbrella route record and hand
healthServer a liveness check and a readiness check, in that order:
{-# LANGUAGE DataKinds #-}
import GHC.Generics (Generic)
import Network.Wai (Application)
import Servant.API (NamedRoutes, (:>))
import Servant.API.Generic ((:-))
import Servant.Health (HealthApi, ProbeCheck, healthServer)
import Servant.Server.Generic (genericServe)
data MyServiceApi mode = MyServiceApi
{ health :: mode :- "health" :> NamedRoutes HealthApi
, orders :: mode :- "orders" :> NamedRoutes OrdersApi
}
deriving stock (Generic)
mkApp :: ProbeCheck -> ProbeCheck -> IO Application
mkApp livenessCheck readinessCheck =
pure $
genericServe
MyServiceApi
{ health = healthServer livenessCheck readinessCheck
, orders = ordersServer
}
Both checks are run afresh on every request; nothing here caches a verdict.
The wire contract
Both probes answer with the same body shape — exactly status, check, and
failingSince, with failingSince null while healthy — so dashboards and
runbooks can read either route the same way:
data ProbeStatus = ProbeStatus
{ status :: !Text -- "ok" | "failed"
, check :: !Text -- "all" when passing, else the failed check's name
, failingSince :: !(Maybe UTCTime)
}
A passing check answers 200, a failing one answers 503 with the same
shape. That mapping lives in exactly one place — the AsUnion ProbeResponses ProbeResult instance — because both alternatives carry the same body type.
The body is not extensible per service. Diagnostics belong in logs and metrics;
see ADR 2.
Writing checks
A check is just type ProbeCheck = IO ProbeVerdict, where a verdict is
Healthy or Unhealthy carrying the failed check's name and when the failure
began. Servant.Health.Check supplies the generic glue every service otherwise
reinvents:
| Combinator |
What it does |
boolCheck name test |
Name a boolean test; False becomes Unhealthy name. |
sequenceChecks checks |
Run left to right, report the first failure, skip the rest. An empty list is Healthy. |
safeCheck name action |
Turn a thrown synchronous exception into a failed probe instead of a 500. Async exceptions are re-thrown. |
withProbeTimeout micros name action |
Bound a check; on expiry it reports Unhealthy. |
newFailureTracker |
Build a wrapper that makes failingSince report the original onset of a failing run, not the current observation. |
Assembling them for production, with the service's own databaseCheck and
friends:
import Servant.Health.Check (safeCheck, sequenceChecks, withProbeTimeout)
runService :: ConnectionPool -> IO ()
runService pool = do
application <-
mkApp
(withProbeTimeout 2_000_000 "liveness" (safeCheck "liveness" (eventLoopCheck pool)))
(safeCheck "readiness" (sequenceChecks [databaseCheck pool, cacheCheck]))
Warp.run 8080 application
Two things worth getting right:
- Wrap every check in
safeCheck. An exception escaping a check becomes a
500, which pages an operator with a stack trace instead of telling them which
dependency is down.
- Put
withProbeTimeout outside safeCheck. The timeout interrupts by
throwing an asynchronous exception, and safeCheck deliberately lets those
through. Nested the other way round, a deadlocked process would hang its
liveness probe instead of failing it — the opposite of what liveness is for.
timeout also cannot interrupt a foreign call unless the program is built
with -threaded.
To report the onset of a failure rather than the moment it was last observed,
give liveness and readiness their own trackers:
trackLiveness <- newFailureTracker
trackReadiness <- newFailureTracker
pure (healthServer (trackLiveness livenessCheck) (trackReadiness readinessCheck))
Path constants
Servant.Health.Paths exports healthMountSegment, liveRawPath,
readyRawPath, and healthRawPaths — for request-logger exclusion predicates
and conformance-test exemption lists, instead of restating string literals.
Proving your wiring: the testkit sublibrary
Mounting HealthApi leaves one thing unproven: that your assembled
application really answers both paths, and that the two routes were not wired to
each other's checks. Both probe routes have the same handler type, so an
umbrella record that swaps them compiles cleanly — only a request that
distinguishes them catches it.
servant-health:testkit is a public sublibrary shipped inside this package.
Depend on it from your test stanza only, so production builds never acquire its
tasty and warp dependencies:
test-suite myservice-test
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: Main.hs
ghc-options: -threaded -rtsopts -with-rtsopts=-N
build-depends:
, base
, servant-health
, servant-health:testkit
, tasty
The kit supplies the checks it wants mounted, so it takes your app builder
rather than a finished application:
main :: IO ()
main = defaultMain (probeContractTests "myservice probes" mkApp)
It drives a real Warp server on an ephemeral port and asserts the whole matrix:
both checks healthy → both probes 200; readiness failing → /health/ready 503
while /health/live stays 200; liveness failing → the mirror image. Those two
"still answers 200" assertions are the dispatch proof. The -threaded flag is
checked as its own test case, because without it Warp's timer manager fails with
an error that says nothing about the missing flag.
What this package does not do
servant-health provides the probe surface and nothing that decides what a
probe means. Deliberately out of scope
(ADR 3):
- Checks themselves — no database ping, no lag check. The
ProbeCheck seam
is how you supply your own.
- Readiness policy — whether a degraded cache should remove a pod from load
balancing is a per-service judgment.
- Kubernetes manifests and probe tuning — periods, thresholds, and the
Kubernetes-side timeouts are deployment configuration.
- A bare
/health route — a single combined route cannot express the
distinction Kubernetes acts on: liveness failure restarts the container,
readiness failure only removes it from load balancing. A test pins GET /health at 404.
- RFC 9457 problem details — a probe body is a status report, not an error
document.
The library's dependency budget is base, aeson, bytestring, openapi-hs,
servant, servant-server, sop-core, text, and time. A proposal to add
one should be read as a proposal to cross one of these boundaries.
Development
nix develop # dev shell (GHC 9.12, cabal, HLS, fourmolu)
cabal build all # library, testkit sublibrary, and test suite
just test # cabal test — the single entry point CI runs
just show-versions # which openapi cohort the build actually resolved to
just build-nix # nix build: packages.default, with the pinned openapi overlay
nix fmt # treefmt: nixpkgs-fmt + fourmolu + cabal-fmt
nix flake check # treefmt + pre-commit gates
nix build evaluates the git tree, so new files must be git add-ed before it
can see them.
Design decisions are recorded in docs/adr/, and the plans that
produced them in docs/plans/.
License
BSD-3-Clause.