packages feed

servant-health (empty) → 0.1.0.0

raw patch · 8 files changed

+1475/−0 lines, 8 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, http-client, http-types, openapi-hs, servant, servant-health, servant-openapi-hs, servant-server, sop-core, tasty, tasty-hunit, text, time, wai, warp

Files

+ CHANGELOG.md view
@@ -0,0 +1,45 @@+# Changelog for servant-health++This project follows the [Haskell PVP](https://pvp.haskell.org/).++## 0.1.0.0 — 2026-07-24++First release.++### New Features++- **Probe routes and wire contract.** `Servant.Health` exports `HealthApi`, a+  `NamedRoutes` record mounted under `"health"` that answers `GET /health/live`+  and `GET /health/ready`. Responses are typed via `MultiVerb`+  (`ProbeResponses`), and the passed/failed → 200/503 mapping lives in the one+  `AsUnion ProbeResponses ProbeResult` instance. The `ProbeStatus` body is a+  fixed key set — `status`, `check`, `failingSince` — with `failingSince` null+  while healthy.+- **Injectable check seam.** `type ProbeCheck = IO ProbeVerdict` plus+  `healthServer` and `runProbe`. The package ships no checks of its own; checks+  are run afresh on every request and nothing caches a verdict.+- **Check combinators.** `Servant.Health.Check` provides `boolCheck`,+  `sequenceChecks` (short-circuiting, empty list is healthy), `safeCheck`+  (synchronous exceptions become failed probes, asynchronous ones are re-thrown),+  `withProbeTimeout`, and `newFailureTracker` for reporting the original onset of+  a failing run rather than the latest observation.+- **Path constants.** `Servant.Health.Paths` exports `healthMountSegment`,+  `liveRawPath`, `readyRawPath`, and `healthRawPaths` for request-logger+  exclusions and conformance-test exemption lists.+- **OpenAPI schema.** A non-orphan `ToSchema ProbeStatus` instance derived from+  the same `probeJsonOptions` value as the JSON codec, so the published schema+  and the bytes on the wire cannot disagree.+- **`testkit` public sublibrary.** `servant-health:testkit` exports+  `probeContractTests`, which drives a real Warp server built from the+  consumer's own application builder and asserts the full probe matrix —+  including the cross-assertions that catch an umbrella record wiring the two+  probe routes to each other's checks. Depend on it from test stanzas only.++### Other Changes++- Supports a single OpenAPI cohort: `openapi-hs >=5.0 && <5.1`, with+  `servant-openapi-hs >=5.1 && <5.2` used test-only. See+  [ADR 7](https://github.com/shinzui/servant-health/blob/master/docs/adr/7-single-cohort-openapi-support.md); the 4.1 cohort was+  dropped before this first release.+- Added `README.md`, plus `homepage`, `bug-reports`, and a `source-repository+  head` stanza to the cabal file.
+ README.md view
@@ -0,0 +1,233 @@+# 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++```cabal+build-depends:+  , servant-health  ^>=0.1+```++Requires GHC 9.12 (`base >=4.21`, `GHC2024`) and servant 0.20.++`servant-health` depends on [`openapi-hs`][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`][servant-openapi-hs] (`>=5.1 && <5.2`) rather than the+upstream package. Both forks are on Hackage. See+[ADR 7](https://github.com/shinzui/servant-health/blob/master/docs/adr/7-single-cohort-openapi-support.md) 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:++```haskell+{-# 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:++```haskell+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](https://github.com/shinzui/servant-health/blob/master/docs/adr/2-probe-wire-contract-and-status-mapping.md).++## 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:++```haskell+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:++```haskell+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:++```cabal+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:++```haskell+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](https://github.com/shinzui/servant-health/blob/master/docs/adr/3-scope-boundaries-and-deliberate-exclusions.md)):++- **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++```sh+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/`](https://github.com/shinzui/servant-health/blob/master/docs/adr/), and the plans that+produced them in [`docs/plans/`](https://github.com/shinzui/servant-health/blob/master/docs/plans/).++## License++BSD-3-Clause.++[servant]: https://hackage.haskell.org/package/servant+[openapi-hs]: https://hackage.haskell.org/package/openapi-hs+[servant-openapi-hs]: https://hackage.haskell.org/package/servant-openapi-hs
+ servant-health.cabal view
@@ -0,0 +1,107 @@+cabal-version:   3.4+name:            servant-health+version:         0.1.0.0+synopsis:+  Kubernetes liveness and readiness probe endpoints for servant++description:+  Standard Kubernetes probe endpoints as a reusable servant surface: a fixed+  probe wire type, typed 200/503 responses via MultiVerb, an injectable check+  seam, and path constants. See the README for the fleet standard it encodes.++license:         BSD-3-Clause+author:          Nadeem Bitar+maintainer:      nadeem@gmail.com+homepage:        https://github.com/shinzui/servant-health+bug-reports:     https://github.com/shinzui/servant-health/issues+category:        Web, Servant+build-type:      Simple+tested-with:     GHC ==9.12.4+extra-doc-files:+  CHANGELOG.md+  README.md++source-repository head+  type:     git+  location: https://github.com/shinzui/servant-health.git++common common+  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options:        -Wall -Werror=missing-fields -Werror=missing-methods++library+  import:          common+  hs-source-dirs:  src+  exposed-modules:+    Servant.Health+    Servant.Health.Check+    Servant.Health.Paths++  build-depends:+    , aeson           >=2.2   && <2.4+    , base            >=4.21  && <5+    , bytestring      >=0.12  && <0.13+    , openapi-hs      >=5.0   && <5.1+    , servant         ^>=0.20+    , servant-server  ^>=0.20+    , sop-core        >=0.5   && <0.6+    , text            >=2.1   && <2.2+    , time            >=1.12  && <1.15++-- The conformance kit consumers use to prove their own probe wiring. A public+-- sublibrary rather than a separate package: one Hackage unit for a tiny+-- surface, and consumers reference it only from test stanzas so production+-- builds never acquire its tasty/warp dependencies.+library testkit+  import:          common+  visibility:      public+  hs-source-dirs:  testkit+  exposed-modules: Servant.Health.TestKit+  build-depends:+    , aeson           >=2.2  && <2.4+    , base            >=4.21 && <5+    , bytestring      >=0.12 && <0.13+    , http-client     >=0.7  && <0.8+    , http-types      >=0.12 && <0.13+    , servant-health+    , tasty           >=1.4  && <1.6+    , tasty-hunit     >=0.10 && <0.11+    , text            >=2.1  && <2.2+    , time            >=1.12 && <1.15+    , wai             >=3.2  && <3.3+    , warp            >=3.3  && <3.5++test-suite servant-health-test+  import:         common+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs++  -- The round-trip tests run a real Warp server; Warp's timer manager+  -- requires the threaded runtime.+  ghc-options:    -threaded -rtsopts -with-rtsopts=-N+  build-depends:+    , aeson+    , base+    , bytestring+    , http-client+    , http-types+    , openapi-hs+    , servant+    , servant-health+    , servant-health:testkit+    , servant-openapi-hs      >=5.1   && <5.2+    , servant-server          ^>=0.20+    , sop-core+    , tasty+    , tasty-hunit+    , text+    , time+    , wai+    , warp
+ src/Servant/Health.hs view
@@ -0,0 +1,133 @@+-- | Kubernetes liveness and readiness probe endpoints for servant.+--+-- A service mounts 'HealthApi' under @"health"@ on its umbrella route record+-- and supplies two 'ProbeCheck' actions. It then answers @GET \/health\/live@+-- and @GET \/health\/ready@ with HTTP 200 and a 'ProbeStatus' body when the+-- check passes, and HTTP 503 with the same body shape when it fails.+module Servant.Health+  ( -- * Wire contract+    ProbeStatus (..),+    probeJsonOptions,++    -- * Check seam+    ProbeVerdict (..),+    ProbeCheck,++    -- * Routes+    ProbeResponses,+    ProbeResult (..),+    HealthApi (..),++    -- * Server+    healthServer,+    runProbe,+  )+where++import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson (FromJSON (..), Options, ToJSON (..), defaultOptions, genericParseJSON, genericToJSON)+import Data.OpenApi (ToSchema (..), fromAesonOptions, genericDeclareNamedSchema)+import Data.SOP (I (..), NS (S, Z))+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Servant.API (JSON, StdMethod (GET), (:>))+import Servant.API.Generic ((:-))+import Servant.API.MultiVerb (AsUnion (..), MultiVerb, Respond)+import Servant.Server.Generic (AsServerT)++-- | The probe report every fleet service answers with. The key set is a wire+-- contract consumed by dashboards and runbooks: exactly @status@, @check@,+-- @failingSince@, with @failingSince@ null while healthy.+data ProbeStatus = ProbeStatus+  { status :: !Text,+    check :: !Text,+    failingSince :: !(Maybe UTCTime)+  }+  deriving stock (Generic, Eq, Show)++-- | The one Options value driving the codec (and, in a later plan, the+-- schema). Field names are used verbatim.+probeJsonOptions :: Options+probeJsonOptions = defaultOptions++instance ToJSON ProbeStatus where+  toJSON = genericToJSON probeJsonOptions++instance FromJSON ProbeStatus where+  parseJSON = genericParseJSON probeJsonOptions++-- | Derived from the same 'Options' value as the codec above, so the published+-- schema and the bytes on the wire cannot disagree. Non-orphan on purpose: an+-- instance next to its type can never be forgotten or duplicated by a+-- consumer generating a document.+instance ToSchema ProbeStatus where+  declareNamedSchema = genericDeclareNamedSchema (fromAesonOptions probeJsonOptions)++-- | What a check reports. @since@ is when the failure began, which feeds the+-- wire type's @failingSince@.+data ProbeVerdict+  = Healthy+  | Unhealthy {failedCheck :: !Text, since :: !UTCTime}+  deriving stock (Generic, Eq, Show)++-- | The injectable seam: services supply these, this library never runs+-- anything it invented itself.+type ProbeCheck = IO ProbeVerdict++-- | The two alternatives a probe route can answer with.+type ProbeResponses =+  '[ Respond 200 "Probe passed" ProbeStatus,+     Respond 503 "Probe failed" ProbeStatus+   ]++-- | The handler-side sum matching 'ProbeResponses'.+data ProbeResult+  = ProbePassed !ProbeStatus+  | ProbeFailed !ProbeStatus+  deriving stock (Generic, Eq, Show)++-- | Hand-written on purpose. Both alternatives carry the same body type, so+-- this instance is the only place that decides passed=200, failed=503.+instance AsUnion ProbeResponses ProbeResult where+  toUnion = \case+    ProbePassed body -> Z (I body)+    ProbeFailed body -> S (Z (I body))+  fromUnion = \case+    Z (I body) -> ProbePassed body+    S (Z (I body)) -> ProbeFailed body+    S (S impossible) -> case impossible of {}++-- | Mount under @"health"@ on the service's umbrella NamedRoutes record.+data HealthApi mode = HealthApi+  { live :: mode :- "live" :> MultiVerb 'GET '[JSON] ProbeResponses ProbeResult,+    ready :: mode :- "ready" :> MultiVerb 'GET '[JSON] ProbeResponses ProbeResult+  }+  deriving stock (Generic)++-- | Build the probe server from a liveness check and a readiness check, in+-- that order.+--+-- Both checks are run afresh on every request; nothing here caches a verdict.+--+-- Wrap every check you mount in @Servant.Health.Check.safeCheck@. An exception+-- escaping a check becomes a 500 rather than a probe report, which pages an+-- operator with a stack trace instead of telling them which dependency is+-- down. The same module has combinators for sequencing several readiness+-- checks, bounding a liveness check with a timeout, and reporting when a+-- failure began.+healthServer :: (MonadIO m) => ProbeCheck -> ProbeCheck -> HealthApi (AsServerT m)+healthServer liveCheck readyCheck =+  HealthApi+    { live = runProbe liveCheck,+      ready = runProbe readyCheck+    }++-- | Run one check and turn its verdict into the response sum.+runProbe :: (MonadIO m) => ProbeCheck -> m ProbeResult+runProbe checkAction =+  liftIO checkAction >>= \case+    Healthy ->+      pure (ProbePassed (ProbeStatus "ok" "all" Nothing))+    Unhealthy failed began ->+      pure (ProbeFailed (ProbeStatus "failed" failed (Just began)))
+ src/Servant/Health/Check.hs view
@@ -0,0 +1,142 @@+-- | Combinators for building the 'ProbeCheck' actions a service hands to+-- 'Servant.Health.healthServer'.+--+-- This module contains only the generic glue every service otherwise+-- reinvents: running several checks in order, bounding a check with a timeout,+-- turning a thrown exception into a failed probe rather than a 500, and+-- remembering when a failure began. The checks themselves — pinging a+-- database, measuring subscription lag — belong to the service.+module Servant.Health.Check+  ( -- * Building checks+    boolCheck,++    -- * Composing checks+    sequenceChecks,++    -- * Hardening checks+    safeCheck,+    withProbeTimeout,++    -- * Reporting failure onset+    newFailureTracker,+  )+where++import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)+import Data.IORef (atomicModifyIORef', atomicWriteIORef, newIORef)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Time (getCurrentTime)+import Servant.Health (ProbeCheck, ProbeVerdict (Healthy, Unhealthy))+import System.Timeout (timeout)++-- | Report a failure of the named check, observed now.+unhealthyNow :: Text -> IO ProbeVerdict+unhealthyNow name = Unhealthy name <$> getCurrentTime++-- | Name a boolean test. 'True' is 'Healthy'; 'False' is 'Unhealthy' under the+-- given name, with the failure onset recorded as the moment of observation.+--+-- To report the /original/ onset across repeated probe calls rather than the+-- most recent observation, wrap the result with a tracker from+-- 'newFailureTracker'.+boolCheck :: Text -> IO Bool -> ProbeCheck+boolCheck name test = do+  passed <- test+  if passed+    then pure Healthy+    else unhealthyNow name++-- | Run checks left to right and report the first failure, skipping the rest.+--+-- An empty list is 'Healthy', which makes this a sensible default for a+-- service that has no readiness dependencies yet. Short-circuiting is part of+-- the contract: a readiness check that has already failed should not spend a+-- database round trip confirming the next dependency is also unhappy.+sequenceChecks :: [ProbeCheck] -> ProbeCheck+sequenceChecks = go+  where+    go [] = pure Healthy+    go (action : rest) =+      action >>= \case+        Healthy -> go rest+        failure -> pure failure++-- | Convert a thrown synchronous exception into a failed probe under the given+-- name. Every check a service mounts should be wrapped in this: an exception+-- escaping a probe handler becomes a 500, which Kubernetes reads as a probe+-- failure anyway but which pages an operator with a stack trace instead of a+-- status report.+--+-- Asynchronous exceptions are re-thrown rather than swallowed, so a probe never+-- delays process shutdown. This is also what lets 'withProbeTimeout' work from+-- the outside: 'System.Timeout.timeout' interrupts by throwing an asynchronous+-- exception, and a 'safeCheck' nested inside it must let that through.+--+-- The distinction is the standard one drawn by 'SomeAsyncException': an+-- exception type whose 'Control.Exception.toException' goes through+-- 'Control.Exception.asyncExceptionToException' is asynchronous. A synchronous+-- exception type delivered by 'Control.Concurrent.throwTo' is indistinguishable+-- from an ordinary failure and will be reported as one.+safeCheck :: Text -> ProbeCheck -> ProbeCheck+safeCheck name action = do+  outcome <- try action+  case outcome of+    Right verdict -> pure verdict+    Left err+      | isAsync err -> throwIO err+      | otherwise -> unhealthyNow name+  where+    isAsync :: SomeException -> Bool+    isAsync err = case fromException err of+      Just (_ :: SomeAsyncException) -> True+      Nothing -> False++-- | Bound a check with a timeout, in microseconds. On expiry the check reports+-- 'Unhealthy' under the given name.+--+-- This is what the fleet standard asks for on liveness: a deadlocked process+-- should /fail/ its liveness probe so Kubernetes restarts it, rather than hang+-- the probe and leave the pod wedged until the kubelet's own timeout fires.+--+-- The microsecond unit matches kiroku's @livenessTimeoutUs@ convention.+--+-- Note that 'System.Timeout.timeout' cannot interrupt a foreign call unless the+-- program is built with the threaded runtime. Fleet executables already are —+-- the OpenTelemetry standard requires it — but a test suite that exercises this+-- combinator needs @-threaded@ in its @ghc-options@.+withProbeTimeout :: Int -> Text -> ProbeCheck -> ProbeCheck+withProbeTimeout micros name action =+  timeout micros action >>= \case+    Just verdict -> pure verdict+    Nothing -> unhealthyNow name++-- | Build a wrapper that makes the wire type's @failingSince@ field mean what+-- it says.+--+-- A bare check reports the onset it observed on /this/ call, so a probe that+-- has been failing for an hour reports a fresh onset every time Kubernetes+-- asks. The wrapper returned here remembers the first failure of a consecutive+-- run and rewrites later failures to carry that original onset, so a dashboard+-- reads @failingSince: 12:03:41Z@ rather than @failingSince: now@. A 'Healthy'+-- result clears the memory, so the next failure starts a new run.+--+-- Each call to 'newFailureTracker' creates one independent memory, so liveness+-- and readiness need their own:+--+-- > trackLiveness <- newFailureTracker+-- > trackReadiness <- newFailureTracker+-- > pure (healthServer (trackLiveness livenessCheck) (trackReadiness readinessCheck))+newFailureTracker :: IO (ProbeCheck -> ProbeCheck)+newFailureTracker = do+  onsetRef <- newIORef Nothing+  pure $ \action ->+    action >>= \case+      Healthy -> do+        atomicWriteIORef onsetRef Nothing+        pure Healthy+      Unhealthy failed observed -> do+        onset <- atomicModifyIORef' onsetRef $ \stored ->+          let effective = fromMaybe observed stored+           in (Just effective, effective)+        pure (Unhealthy failed onset)
+ src/Servant/Health/Paths.hs view
@@ -0,0 +1,28 @@+-- | Raw paths of the probe routes, assuming the standard "health" mount.+-- Use these in request-logger exclusion predicates and conformance-test+-- exemption lists instead of restating string literals.+module Servant.Health.Paths+  ( healthMountSegment,+    liveRawPath,+    readyRawPath,+    healthRawPaths,+  )+where++import Data.ByteString (ByteString)++-- | The path segment the 'Servant.Health.HealthApi' record is mounted under.+healthMountSegment :: ByteString+healthMountSegment = "health"++-- | Raw path of the liveness probe.+liveRawPath :: ByteString+liveRawPath = "/health/live"++-- | Raw path of the readiness probe.+readyRawPath :: ByteString+readyRawPath = "/health/ready"++-- | Both probe paths, for exclusion predicates that treat them alike.+healthRawPaths :: [ByteString]+healthRawPaths = [liveRawPath, readyRawPath]
+ test/Main.hs view
@@ -0,0 +1,506 @@+module Main (main) where++import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (AsyncException (ThreadKilled), SomeException, fromException, throwIO, try)+import Data.Aeson (Value (Null, Object), decode, toJSON)+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as LazyByteString+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)+import Data.List qualified as List+import Data.OpenApi (validateToJSON)+import Data.Proxy (Proxy (Proxy))+import Data.SOP (I (..), NS (S, Z))+import Data.Text (Text)+import Data.Time (UTCTime (..), addUTCTime, fromGregorian, secondsToDiffTime)+import GHC.Generics (Generic)+import Network.HTTP.Client+  ( Manager,+    Response,+    checkResponse,+    defaultManagerSettings,+    httpLbs,+    newManager,+    parseRequest,+    responseBody,+    responseHeaders,+    responseStatus,+  )+import Network.HTTP.Types.Header (hContentType)+import Network.HTTP.Types.Status (statusCode)+import Network.Wai (Application)+import Network.Wai.Handler.Warp (Port, testWithApplication)+import Servant.API (NamedRoutes, (:>))+import Servant.API.Generic ((:-))+import Servant.API.MultiVerb (AsUnion (..))+import Servant.Health+  ( HealthApi,+    ProbeCheck,+    ProbeResponses,+    ProbeResult (..),+    ProbeStatus (..),+    ProbeVerdict (..),+    healthServer,+    runProbe,+  )+import Servant.Health.Check+  ( boolCheck,+    newFailureTracker,+    safeCheck,+    sequenceChecks,+    withProbeTimeout,+  )+import Servant.Health.Paths (healthRawPaths, liveRawPath, readyRawPath)+import Servant.Health.TestKit (probeContractTests)+import Servant.OpenApi (toOpenApi)+import Servant.Server.Generic (genericServe)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++main :: IO ()+main = defaultMain tests++tests :: TestTree+tests =+  testGroup+    "servant-health"+    [ pathTests,+      wireContractTests,+      unionMappingTests,+      runProbeTests,+      probeContractTests "probe contract (via servant-health:testkit)" mkTestApp,+      roundTripTests,+      checkCombinatorTests,+      failureTrackerTests,+      schemaTests,+      documentTests+    ]++-- | The published schema and the JSON codec are derived from one shared+-- 'Servant.Health.probeJsonOptions' value, so they cannot disagree by+-- construction. 'validateToJSON' is what proves the construction actually+-- holds: it checks a real encoded value against the declared schema and+-- returns the mismatches, so an empty list means the document a service+-- publishes describes the bytes it really sends.+schemaTests :: TestTree+schemaTests =+  testGroup+    "ToSchema ProbeStatus"+    [ testCase "a healthy body validates against the declared schema" $+        validateToJSON (ProbeStatus "ok" "all" Nothing) @?= [],+      testCase "a failing body validates against the declared schema" $+        validateToJSON (ProbeStatus "failed" "postgres" (Just failureOnset)) @?= []+    ]++-- | The OpenAPI document a consumer gets by mounting the probe routes under+-- @"health"@, as the JSON it would actually publish.+--+-- Asserting against the encoded JSON rather than the @OpenApi@ data model is+-- deliberate. It is what a service's generated artifact really contains, and it+-- avoids the one place the two fleet openapi cohorts genuinely differ: the 4.1+-- cohort ships optics-based accessors in @Data.OpenApi.Optics@ that the 5.x+-- cohort deletes. Navigating JSON touches neither accessor set, so this test+-- compiles and passes identically under both.+probeDocument :: Value+probeDocument = toJSON (toOpenApi (Proxy @("health" :> NamedRoutes HealthApi)))++-- | The document shape a consumer publishes. These assertions are what catch a+-- future change silently dropping the 503 from the published contract — a+-- service's generated document would then promise callers that a probe only+-- ever succeeds.+documentTests :: TestTree+documentTests =+  testGroup+    "generated OpenAPI document"+    [ testCase "declares exactly the two probe paths" $+        objectKeys (objectField "paths" probeDocument)+          @?= ["/health/live", "/health/ready"],+      testCase "the liveness path exposes a GET and nothing else" $+        objectKeys (pathItem "/health/live") @?= ["get"],+      testCase "the readiness path exposes a GET and nothing else" $+        objectKeys (pathItem "/health/ready") @?= ["get"],+      testCase "the liveness GET declares exactly the 200 and 503 responses" $+        responseCodes "/health/live" @?= ["200", "503"],+      testCase "the readiness GET declares exactly the 200 and 503 responses" $+        responseCodes "/health/ready" @?= ["200", "503"]+    ]+  where+    pathItem path = objectField path (objectField "paths" probeDocument)+    responseCodes path = objectKeys (objectField "responses" (objectField "get" (pathItem path)))++-- | A fixed instant used wherever a failure onset is needed.+failureOnset :: UTCTime+failureOnset =+  UTCTime (fromGregorian 2026 7 24) (secondsToDiffTime (12 * 3600 + 34 * 60 + 56))++-- | The keys of a JSON object, sorted so assertions do not depend on the+-- encoder's ordering.+objectKeys :: Value -> [String]+objectKeys = \case+  Object keyMap -> List.sort (map Key.toString (KeyMap.keys keyMap))+  other -> error ("expected a JSON object, got: " <> show other)++-- | Look up a key in a JSON object, absent or not.+objectLookup :: String -> Value -> Maybe Value+objectLookup key = \case+  Object keyMap -> KeyMap.lookup (Key.fromString key) keyMap+  other -> error ("expected a JSON object, got: " <> show other)++-- | Look up a key that the caller expects to be present.+objectField :: String -> Value -> Value+objectField key value =+  case objectLookup key value of+    Just found -> found+    Nothing -> error ("expected a " <> show key <> " key in: " <> show value)++pathTests :: TestTree+pathTests =+  testGroup+    "Servant.Health.Paths"+    [ testCase "liveRawPath is /health/live" $+        liveRawPath @?= "/health/live",+      testCase "readyRawPath is /health/ready" $+        readyRawPath @?= "/health/ready",+      testCase "healthRawPaths lists both probe paths" $+        healthRawPaths @?= [liveRawPath, readyRawPath]+    ]++-- | The probe body's key set is a wire contract. These tests fail if a later+-- change to 'Servant.Health.probeJsonOptions' renames, drops, or conditionally+-- omits a member.+wireContractTests :: TestTree+wireContractTests =+  testGroup+    "ProbeStatus JSON wire contract"+    [ testCase "healthy body has exactly status, check, failingSince" $+        objectKeys (toJSON (ProbeStatus "ok" "all" Nothing))+          @?= ["check", "failingSince", "status"],+      testCase "failingSince is present and null when healthy" $+        objectLookup "failingSince" (toJSON (ProbeStatus "ok" "all" Nothing))+          @?= Just Null,+      testCase "failing body has the same key set" $+        objectKeys (toJSON (ProbeStatus "failed" "postgres" (Just failureOnset)))+          @?= ["check", "failingSince", "status"],+      testCase "failing body encodes the onset as an ISO-8601 string" $+        objectLookup "failingSince" (toJSON (ProbeStatus "failed" "postgres" (Just failureOnset)))+          @?= Just (toJSON failureOnset)+    ]++-- | The status mapping. Both alternatives carry the same body type, so the+-- 'AsUnion' instance is the only thing standing between a failing probe and a+-- 200 response. These tests assert union /positions/, not just round-tripping:+-- swapping the two arms of 'toUnion' must break them.+unionMappingTests :: TestTree+unionMappingTests =+  testGroup+    "AsUnion ProbeResponses ProbeResult"+    [ testCase "ProbePassed maps to the first alternative (200)" $+        case toUnion @ProbeResponses (ProbePassed passedBody) of+          Z (I body) -> body @?= passedBody+          S _ -> assertFailure "ProbePassed did not map to the first (200) alternative",+      testCase "ProbeFailed maps to the second alternative (503)" $+        case toUnion @ProbeResponses (ProbeFailed failedBody) of+          S (Z (I body)) -> body @?= failedBody+          Z _ -> assertFailure "ProbeFailed mapped to the first (200) alternative"+          S (S impossible) -> case impossible of {},+      testCase "fromUnion . toUnion is identity on ProbePassed" $+        fromUnion @ProbeResponses (toUnion @ProbeResponses (ProbePassed passedBody))+          @?= ProbePassed passedBody,+      testCase "fromUnion . toUnion is identity on ProbeFailed" $+        fromUnion @ProbeResponses (toUnion @ProbeResponses (ProbeFailed failedBody))+          @?= ProbeFailed failedBody+    ]+  where+    passedBody = ProbeStatus "ok" "all" Nothing+    failedBody = ProbeStatus "failed" "postgres" (Just failureOnset)++-- | The verdict-to-body translation, independent of HTTP.+runProbeTests :: TestTree+runProbeTests =+  testGroup+    "runProbe"+    [ testCase "Healthy becomes a passed probe reporting all checks ok" $ do+        result <- runProbe (pure Healthy)+        result @?= ProbePassed (ProbeStatus "ok" "all" Nothing),+      testCase "Unhealthy becomes a failed probe naming the check and onset" $ do+        result <- runProbe (pure (Unhealthy "postgres" failureOnset))+        result @?= ProbeFailed (ProbeStatus "failed" "postgres" (Just failureOnset))+    ]++-- | A stand-in for a consumer's umbrella route record: one field mounting the+-- probe routes under @"health"@, exactly as the fleet standard prescribes.+newtype TestApi mode = TestApi+  { health :: mode :- "health" :> NamedRoutes HealthApi+  }+  deriving stock (Generic)++-- | The application builder this package hands to the test kit, and the one+-- 'withProbeServer' serves for the cases the kit does not cover. It is exactly+-- what a consuming service writes: an umbrella record with one field mounting+-- the probe routes under @"health"@, parameterized over the two checks so a+-- test can decide their verdicts.+mkTestApp :: ProbeCheck -> ProbeCheck -> IO Application+mkTestApp liveCheck readyCheck =+  pure $+    genericServe+      TestApi+        { health = healthServer liveCheck readyCheck+        }++-- | Run a real Warp server on an ephemeral port for the duration of the+-- action, handing it the two verdict cells and an HTTP client manager.+withProbeServer ::+  ProbeVerdict ->+  ProbeVerdict ->+  (IORef ProbeVerdict -> IORef ProbeVerdict -> Manager -> Port -> IO a) ->+  IO a+withProbeServer liveVerdict readyVerdict action = do+  liveRef <- newIORef liveVerdict+  readyRef <- newIORef readyVerdict+  manager <- newManager defaultManagerSettings+  testWithApplication+    (mkTestApp (readIORef liveRef) (readIORef readyRef))+    (action liveRef readyRef manager)++-- | GET a raw probe path off the running server. The response check is+-- disabled so that a 503 comes back as an ordinary response rather than an+-- exception.+getProbe :: Manager -> Port -> ByteString -> IO (Response LazyByteString.ByteString)+getProbe manager port rawPath = do+  request <- parseRequest ("http://127.0.0.1:" <> show port <> Char8.unpack rawPath)+  httpLbs request {checkResponse = \_ _ -> pure ()} manager++-- | Assert the status code, JSON body, and content type of one probe response.+-- The expected body is given as literal JSON so the assertion states the wire+-- shape independently of this library's encoder.+assertProbe :: Int -> LazyByteString.ByteString -> Response LazyByteString.ByteString -> IO ()+assertProbe expectedStatus expectedBody response = do+  statusCode (responseStatus response) @?= expectedStatus+  decode (responseBody response) @?= (decode expectedBody :: Maybe Value)+  mediaType (lookup hContentType (responseHeaders response)) @?= Just "application/json"+  where+    mediaType = fmap (Char8.takeWhile (/= ';'))++healthyBody :: LazyByteString.ByteString+healthyBody = "{\"status\":\"ok\",\"check\":\"all\",\"failingSince\":null}"++-- | The failing body for 'failureOnset', with the instant spelled out as the+-- ISO-8601 string a dashboard or a human curling the probe would read.+failingBody :: LazyByteString.ByteString -> LazyByteString.ByteString+failingBody failedCheckName =+  "{\"status\":\"failed\",\"check\":\""+    <> failedCheckName+    <> "\",\"failingSince\":\"2026-07-24T12:34:56Z\"}"++-- | End-to-end proof over a real Warp server for the two properties the test+-- kit does not cover.+--+-- The 200/503 matrix and the dispatch cross-assertions moved into+-- 'Servant.Health.TestKit.probeContractTests', which this suite runs above+-- against the same 'mkTestApp' builder a consuming service would write — so+-- the kit is proven by the package it ships with rather than only by its own+-- documentation.+--+-- What stays here is what the kit deliberately does not assert, because both+-- are statements about this library rather than about a consumer's wiring: that+-- a verdict is never cached across requests, and that no bare @\/health@ route+-- exists (see docs/adr/3-scope-boundaries-and-deliberate-exclusions.md).+roundTripTests :: TestTree+roundTripTests =+  testGroup+    "Warp round trip (beyond the test kit's matrix)"+    [ testCase "checks are re-run per request, not cached" $+        withProbeServer Healthy Healthy $ \_ readyRef manager port -> do+          getProbe manager port readyRawPath >>= assertProbe 200 healthyBody+          writeIORef readyRef (Unhealthy "postgres" failureOnset)+          getProbe manager port readyRawPath+            >>= assertProbe 503 (failingBody "postgres")+          writeIORef readyRef Healthy+          getProbe manager port readyRawPath >>= assertProbe 200 healthyBody,+      testCase "an unmounted path under the health prefix is a 404" $+        withProbeServer Healthy Healthy $ \_ _ manager port -> do+          response <- getProbe manager port "/health"+          statusCode (responseStatus response) @?= 404+    ]++-- | The failing check's name, or 'Nothing' when healthy. Most combinator+-- assertions are about /which/ check failed, not when it was observed, and the+-- observation time of a combinator that calls 'getCurrentTime' is not+-- something a test can pin.+verdictName :: ProbeVerdict -> Maybe Text+verdictName = \case+  Healthy -> Nothing+  Unhealthy failed _ -> Just failed++-- | A check that records the fact it ran, so a test can prove which checks a+-- combinator did and did not reach.+tracingCheck :: IORef [Text] -> Text -> ProbeVerdict -> ProbeCheck+tracingCheck traceRef name verdict = do+  modifyIORef' traceRef (<> [name])+  pure verdict++checkCombinatorTests :: TestTree+checkCombinatorTests =+  testGroup+    "Servant.Health.Check"+    [ testGroup+        "boolCheck"+        [ testCase "True is healthy" $ do+            verdict <- boolCheck "db" (pure True)+            verdict @?= Healthy,+          testCase "False is unhealthy under the given name" $ do+            verdict <- boolCheck "db" (pure False)+            verdictName verdict @?= Just "db"+        ],+      testGroup+        "sequenceChecks"+        [ testCase "an empty list is healthy" $ do+            verdict <- sequenceChecks []+            verdict @?= Healthy,+          testCase "all healthy is healthy, and every check runs" $ do+            traceRef <- newIORef []+            verdict <-+              sequenceChecks+                [ tracingCheck traceRef "first" Healthy,+                  tracingCheck traceRef "second" Healthy,+                  tracingCheck traceRef "third" Healthy+                ]+            verdict @?= Healthy+            readIORef traceRef >>= (@?= ["first", "second", "third"]),+          testCase "the first failure wins and its successors do not run" $ do+            traceRef <- newIORef []+            verdict <-+              sequenceChecks+                [ tracingCheck traceRef "first" Healthy,+                  tracingCheck traceRef "second" (Unhealthy "second" failureOnset),+                  tracingCheck traceRef "third" (Unhealthy "third" failureOnset)+                ]+            verdict @?= Unhealthy "second" failureOnset+            readIORef traceRef >>= (@?= ["first", "second"]),+          testCase "a failure in the head short-circuits everything" $ do+            traceRef <- newIORef []+            verdict <-+              sequenceChecks+                [ tracingCheck traceRef "first" (Unhealthy "first" failureOnset),+                  tracingCheck traceRef "second" Healthy+                ]+            verdictName verdict @?= Just "first"+            readIORef traceRef >>= (@?= ["first"])+        ],+      testGroup+        "safeCheck"+        [ testCase "a healthy check passes through untouched" $ do+            verdict <- safeCheck "wrapper" (pure Healthy)+            verdict @?= Healthy,+          testCase "a failing check passes through with its own name" $ do+            verdict <- safeCheck "wrapper" (pure (Unhealthy "postgres" failureOnset))+            verdict @?= Unhealthy "postgres" failureOnset,+          testCase "an imprecise error becomes a failure under the wrapper's name" $ do+            verdict <- safeCheck "wrapper" (error "boom")+            verdictName verdict @?= Just "wrapper",+          testCase "a thrown IOException becomes a failure, not an escape" $ do+            verdict <- safeCheck "wrapper" (throwIO (userError "connection refused"))+            verdictName verdict @?= Just "wrapper",+          testCase "an asynchronous exception is not swallowed" $ do+            outcomeVar <- newEmptyMVar+            checkStarted <- newEmptyMVar+            threadId <- forkIO $ do+              outcome <-+                try $+                  safeCheck "wrapper" $ do+                    putMVar checkStarted ()+                    threadDelay 5_000_000+                    pure Healthy+              putMVar outcomeVar (outcome :: Either SomeException ProbeVerdict)+            takeMVar checkStarted+            killThread threadId+            outcome <- takeMVar outcomeVar+            case outcome of+              Right verdict ->+                assertFailure+                  ("safeCheck swallowed the async exception and returned: " <> show verdict)+              Left err ->+                fromException err @?= Just ThreadKilled+        ],+      testGroup+        "withProbeTimeout"+        [ testCase "a prompt check passes through untouched" $ do+            verdict <- withProbeTimeout 1_000_000 "liveness" (pure Healthy)+            verdict @?= Healthy,+          testCase "a check that overruns fails under the timeout's name" $ do+            verdict <-+              withProbeTimeout 10_000 "liveness" $ do+                threadDelay 500_000+                pure Healthy+            verdictName verdict @?= Just "liveness",+          testCase "a nested safeCheck does not defeat the timeout" $ do+            -- timeout interrupts by throwing an asynchronous exception, so a+            -- safeCheck inside it must re-throw rather than report a failure+            -- under its own name. If this regresses, the verdict comes back+            -- named "inner" and a deadlocked process hangs its liveness probe.+            verdict <-+              withProbeTimeout 10_000 "liveness" $+                safeCheck "inner" $ do+                  threadDelay 500_000+                  pure Healthy+            verdictName verdict @?= Just "liveness"+        ]+    ]++-- | The failure tracker turns "failing now" into "failing since". Every case+-- here drives the wrapped check from a mutable cell holding a fabricated+-- verdict, so the assertions compare exact timestamps rather than wall-clock+-- reads.+failureTrackerTests :: TestTree+failureTrackerTests =+  testGroup+    "newFailureTracker"+    [ testCase "a healthy check is passed through" $ do+        track <- newFailureTracker+        verdict <- track (pure Healthy)+        verdict @?= Healthy,+      testCase "consecutive failures all report the first onset" $ do+        track <- newFailureTracker+        verdictRef <- newIORef (Unhealthy "postgres" onsetT0)+        first <- track (readIORef verdictRef)+        writeIORef verdictRef (Unhealthy "postgres" onsetT1)+        second <- track (readIORef verdictRef)+        writeIORef verdictRef (Unhealthy "postgres" onsetT2)+        third <- track (readIORef verdictRef)+        [first, second, third]+          @?= [ Unhealthy "postgres" onsetT0,+                Unhealthy "postgres" onsetT0,+                Unhealthy "postgres" onsetT0+              ],+      testCase "an intervening healthy result resets the onset" $ do+        track <- newFailureTracker+        verdictRef <- newIORef (Unhealthy "postgres" onsetT0)+        before <- track (readIORef verdictRef)+        writeIORef verdictRef Healthy+        recovered <- track (readIORef verdictRef)+        writeIORef verdictRef (Unhealthy "postgres" onsetT2)+        after <- track (readIORef verdictRef)+        before @?= Unhealthy "postgres" onsetT0+        recovered @?= Healthy+        after @?= Unhealthy "postgres" onsetT2,+      testCase "the failing check's own name is preserved, only the onset is rewritten" $ do+        track <- newFailureTracker+        verdictRef <- newIORef (Unhealthy "postgres" onsetT0)+        _ <- track (readIORef verdictRef)+        writeIORef verdictRef (Unhealthy "redis" onsetT1)+        second <- track (readIORef verdictRef)+        second @?= Unhealthy "redis" onsetT0,+      testCase "separate trackers do not share memory" $ do+        trackLiveness <- newFailureTracker+        trackReadiness <- newFailureTracker+        _ <- trackLiveness (pure (Unhealthy "deadlock" onsetT0))+        readinessVerdict <- trackReadiness (pure (Unhealthy "postgres" onsetT2))+        readinessVerdict @?= Unhealthy "postgres" onsetT2+    ]+  where+    onsetT0 = failureOnset+    onsetT1 = addUTCTime 30 failureOnset+    onsetT2 = addUTCTime 90 failureOnset
+ testkit/Servant/Health/TestKit.hs view
@@ -0,0 +1,281 @@+-- | A ready-made conformance test for a service that mounts this package's+-- probe routes.+--+-- Mounting @Servant.Health.HealthApi@ leaves a service one thing to prove: that+-- /its own/ assembled application really answers @GET \/health\/live@ and+-- @GET \/health\/ready@, that a failing readiness check produces a 503 while+-- liveness stays 200, and that the two routes were not wired to each other's+-- checks. That last hazard is the reason this kit exists. Both probe routes+-- have the same handler type, so an umbrella route record that swaps them+-- compiles cleanly and type-checks cleanly; only a request that distinguishes+-- them catches it.+--+-- 'probeContractTests' drives a real HTTP server built from the consumer's own+-- application and asserts the whole matrix, including the two cross-assertions+-- that fail when the routes are transposed.+--+-- == Wiring it into a service+--+-- Depend on the kit from the test stanza only, so production builds never+-- acquire its @tasty@ and @warp@ dependencies. The test suite must be built+-- with the threaded runtime: Warp's timer manager requires it, and without+-- @-threaded@ every request dies with @getSystemTimerManager: the TimerManager+-- requires linking against the threaded runtime@.+--+-- > 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 two checks it wants mounted, so it needs a /builder/ for+-- the application rather than a finished one. Parameterizing the service's app+-- builder over its two probe checks is the only change most services need:+--+-- > module Main (main) where+-- >+-- > import GHC.Generics (Generic)+-- > import Network.Wai (Application)+-- > import Servant.API (NamedRoutes, (:>))+-- > import Servant.API.Generic ((:-))+-- > import Servant.Health (HealthApi, ProbeCheck, healthServer)+-- > import Servant.Health.TestKit (probeContractTests)+-- > import Servant.Server.Generic (genericServe)+-- > import Test.Tasty (defaultMain)+-- >+-- > -- Your service's umbrella route record. Normally this and 'mkApp' are+-- > -- imported from the service's own library rather than defined in the test+-- > -- file; they are inlined here so the example is complete.+-- > 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+-- >         }+-- >+-- > main :: IO ()+-- > main = defaultMain (probeContractTests "myservice probes" mkApp)+--+-- In production the same builder is called with the service's real checks,+-- assembled from "Servant.Health.Check". Note the nesting order: the timeout+-- goes /outside/ the exception wrapper, because+-- 'Servant.Health.Check.withProbeTimeout' interrupts by throwing an+-- asynchronous exception and 'Servant.Health.Check.safeCheck' deliberately lets+-- those through rather than swallowing them. Nested the other way round, a+-- deadlocked process would hang its liveness probe instead of failing it.+--+-- @ConnectionPool@, @eventLoopCheck@, @databaseCheck@, and @cacheCheck@ below+-- are the service's own — this package deliberately ships no checks:+--+-- > 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+--+-- == What the builder must do+--+-- The builder must mount the checks it is handed, in the order it is handed+-- them — liveness first, readiness second — and it must mount them under+-- @"health"@ so the routes answer at 'Servant.Health.Paths.liveRawPath' and+-- 'Servant.Health.Paths.readyRawPath'. A builder that ignores its arguments and+-- mounts the service's real checks proves nothing and will fail these tests.+--+-- The builder is called once per test case, each time against a fresh server on+-- an ephemeral port, so cases are independent and safe to run in parallel. Keep+-- it cheap: a builder that opens a real database connection will open one per+-- case.+module Servant.Health.TestKit+  ( probeContractTests,+  )+where++import Control.Concurrent (rtsSupportsBoundThreads)+import Data.Aeson (Value, decode, object, (.=))+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as LazyByteString+import Data.IORef (newIORef, readIORef)+import Data.Text (Text)+import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)+import Network.HTTP.Client+  ( Manager,+    Response,+    checkResponse,+    defaultManagerSettings,+    httpLbs,+    newManager,+    parseRequest,+    responseBody,+    responseHeaders,+    responseStatus,+  )+import Network.HTTP.Types.Header (hContentType)+import Network.HTTP.Types.Status (statusCode)+import Network.Wai (Application)+import Network.Wai.Handler.Warp (Port, testWithApplication)+import Servant.Health (ProbeCheck, ProbeVerdict (Healthy, Unhealthy))+import Servant.Health.Paths (liveRawPath, readyRawPath)+import Test.Tasty (TestName, TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++-- | Prove that an assembled application answers the probe contract.+--+-- The second argument builds the application under test from a liveness check+-- and a readiness check, in that order. The kit owns those checks so that it+-- can decide their verdicts case by case; see the module documentation for a+-- complete example.+--+-- The returned group contains one case per scenario. Both checks healthy: both+-- probes answer 200 with the healthy body. Readiness failing: @\/health\/ready@+-- answers 503 naming the failed check while @\/health\/live@ still answers 200.+-- Liveness failing: the mirror image. The two \"still answers 200\" assertions+-- are the dispatch proof — they are what fails when an umbrella record wires+-- the probe routes to each other's checks.+probeContractTests ::+  TestName ->+  -- | Build the consumer's application from a liveness check and a readiness+  -- check. The kit owns the checks so it can flip them between cases.+  (ProbeCheck -> ProbeCheck -> IO Application) ->+  TestTree+probeContractTests groupName mkApp =+  testGroup+    groupName+    [ testCase "the test suite is built with the threaded runtime" $+        assertBool threadedRuntimeAdvice rtsSupportsBoundThreads,+      testCase "both checks healthy: both probes answer 200" $+        withProbeApp mkApp Healthy Healthy $ \manager port -> do+          getProbe manager port liveRawPath+            >>= assertProbe liveRawPath 200 healthyBody+          getProbe manager port readyRawPath+            >>= assertProbe readyRawPath 200 healthyBody,+      testCase "readiness failing: ready answers 503, live still 200" $+        withProbeApp mkApp Healthy (failing readinessCheckName) $ \manager port -> do+          getProbe manager port readyRawPath+            >>= assertProbe readyRawPath 503 (failingBody readinessCheckName)+          getProbe manager port liveRawPath+            >>= assertProbe liveRawPath 200 healthyBody,+      testCase "liveness failing: live answers 503, ready still 200" $+        withProbeApp mkApp (failing livenessCheckName) Healthy $ \manager port -> do+          getProbe manager port liveRawPath+            >>= assertProbe liveRawPath 503 (failingBody livenessCheckName)+          getProbe manager port readyRawPath+            >>= assertProbe readyRawPath 200 healthyBody+    ]+  where+    failing name = Unhealthy name probeFailureOnset++-- | What to say when the suite was linked without @-threaded@. Warp's timer+-- manager needs the threaded runtime, so without the flag every request in+-- every case below dies with a @getSystemTimerManager@ error that says nothing+-- about the missing flag.+threadedRuntimeAdvice :: String+threadedRuntimeAdvice =+  "This test suite was linked without the threaded runtime, so the probe "+    <> "server cannot run. Add -threaded -rtsopts -with-rtsopts=-N to the test "+    <> "suite's ghc-options in your .cabal file."++-- | The name the kit fails the liveness check under. Distinct from the+-- readiness name so a transposed wiring is named in the failure message rather+-- than merely counted.+livenessCheckName :: Text+livenessCheckName = "testkit-live"++-- | The name the kit fails the readiness check under.+readinessCheckName :: Text+readinessCheckName = "testkit-ready"++-- | The instant every failure the kit fabricates is dated with. Fixed rather+-- than read from the clock, so a failed assertion prints a stable expected+-- value.+probeFailureOnset :: UTCTime+probeFailureOnset =+  UTCTime (fromGregorian 2026 7 24) (secondsToDiffTime (12 * 3600 + 34 * 60 + 56))++-- | The body both probes answer with while their checks pass.+healthyBody :: Value+healthyBody =+  object+    [ "status" .= ("ok" :: Text),+      "check" .= ("all" :: Text),+      "failingSince" .= (Nothing :: Maybe UTCTime)+    ]++-- | The body a probe answers with when its check failed under the given name.+failingBody :: Text -> Value+failingBody failedCheckName =+  object+    [ "status" .= ("failed" :: Text),+      "check" .= failedCheckName,+      "failingSince" .= probeFailureOnset+    ]++-- | Build the consumer's application with checks that report the given+-- verdicts, serve it on an ephemeral port for the duration of the action, and+-- hand the action an HTTP client manager and the port.+--+-- The checks are backed by mutable cells read on every call, so an application+-- that caches a verdict at build time fails the cases above rather than passing+-- them by accident.+withProbeApp ::+  (ProbeCheck -> ProbeCheck -> IO Application) ->+  ProbeVerdict ->+  ProbeVerdict ->+  (Manager -> Port -> IO a) ->+  IO a+withProbeApp mkApp liveVerdict readyVerdict action = do+  liveRef <- newIORef liveVerdict+  readyRef <- newIORef readyVerdict+  application <- mkApp (readIORef liveRef) (readIORef readyRef)+  manager <- newManager defaultManagerSettings+  testWithApplication (pure application) (action manager)++-- | GET a raw probe path off the running server. The client's response check is+-- disabled so that a 503 arrives as an ordinary response rather than an+-- exception.+getProbe :: Manager -> Port -> ByteString -> IO (Response LazyByteString.ByteString)+getProbe manager port rawPath = do+  request <- parseRequest ("http://127.0.0.1:" <> show port <> Char8.unpack rawPath)+  httpLbs request {checkResponse = \_ _ -> pure ()} manager++-- | Assert the status code, JSON body, and content type of one probe response,+-- labelling each assertion with the path it was made against so a failure names+-- the probe rather than just the expected value.+assertProbe ::+  ByteString ->+  Int ->+  Value ->+  Response LazyByteString.ByteString ->+  IO ()+assertProbe rawPath expectedStatus expectedBody response = do+  assertEqual+    (label "status code")+    expectedStatus+    (statusCode (responseStatus response))+  assertEqual+    (label "JSON body")+    (Just expectedBody)+    (decode (responseBody response))+  assertEqual+    (label "content type")+    (Just "application/json")+    (mediaType (lookup hContentType (responseHeaders response)))+  where+    label what = "GET " <> Char8.unpack rawPath <> ": " <> what+    mediaType = fmap (Char8.takeWhile (/= ';'))