Skip to main content

rainier_framework/
health.rs

1//! Health checks — [`Health`] and the endpoint that renders them.
2//!
3//! ```ignore
4//! // In a provider.
5//! let health = Health::new()
6//!     .register("database", |app| async move {
7//!         app.resolve::<Database>()?.query("SELECT 1").execute().await?;
8//!         Ok(())
9//!     })
10//!     .register("cache", |app| async move {
11//!         app.resolve::<CacheManager>()?.store().has("health").await?;
12//!         Ok(())
13//!     });
14//!
15//! // And a route.
16//! router.get("/health/ready", health::endpoint).name("health.ready");
17//! ```
18//!
19//! ```json
20//! {
21//!   "status": "ok",
22//!   "checks": { "database": { "status": "ok", "duration_ms": 3 } },
23//!   "build": { "name": "identity", "version": "2.4.1", "profile": "release" }
24//! }
25//! ```
26//!
27//! Roughly the same three hundred lines in every service anybody deploys, and
28//! they diverge in the ways that cost an outage: one returns `200` while its
29//! database is unreachable, another has no timeout and hangs the probe, a
30//! third reports a version that is a hardcoded string somebody forgot.
31//!
32//! # Liveness and readiness are different questions
33//!
34//! | | Asks | Answering wrong |
35//! |---|---|---|
36//! | **liveness** | is this process running? | a restart loop |
37//! | **readiness** | can it serve a request? | traffic to a replica that cannot |
38//!
39//! A liveness probe that checks the database is a liveness probe that restarts
40//! every replica when the database blips — turning a degradation into an
41//! outage, and taking away the processes that would have recovered. That is
42//! why [`endpoint`] is readiness and why a liveness route should be two lines
43//! that do no I/O.
44//!
45//! # Every check has a deadline
46//!
47//! A probe that hangs is worse than one that fails: the orchestrator waits,
48//! decides nothing, and the replica sits in an unknown state. So each check
49//! runs under [`timeout`](Health::timeout) and a check that overruns is a
50//! failed check with a reason.
51
52use std::collections::BTreeMap;
53use std::sync::Arc;
54use std::time::{Duration, Instant};
55
56use rainier_container::{facade_application, Application};
57use rainier_http::{Response, StatusCode};
58use rainier_support::{BoxedFuture, Error, Result};
59use serde::Serialize;
60use serde_json::{json, Value};
61
62/// One check: a name and something that either works or does not.
63type Check = Arc<dyn Fn(Arc<Application>) -> BoxedFuture<Result<()>> + Send + Sync>;
64
65/// What one check reported.
66#[derive(Debug, Clone, Serialize)]
67pub struct Outcome {
68    /// `"ok"` or `"failing"`.
69    pub status: &'static str,
70    /// How long it took.
71    pub duration_ms: u64,
72    /// Why it failed, when it did.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub error: Option<String>,
75}
76
77impl Outcome {
78    /// Whether this check passed.
79    pub fn is_ok(&self) -> bool {
80        self.status == "ok"
81    }
82}
83
84/// The whole report.
85#[derive(Debug, Clone, Serialize)]
86pub struct Report {
87    /// `"ok"` when every check passed.
88    pub status: &'static str,
89    /// Each check, by name.
90    pub checks: BTreeMap<String, Outcome>,
91}
92
93impl Report {
94    /// Whether every check passed.
95    pub fn is_healthy(&self) -> bool {
96        self.status == "ok"
97    }
98
99    /// The status a probe should read.
100    ///
101    /// `503`, not `500`: a dependency being down means "try another replica,
102    /// try again shortly", and that is what a load balancer and a retrying
103    /// client both need to hear. A `500` says this service is broken, which
104    /// invites a different and slower response from whoever is paged.
105    pub fn status_code(&self) -> StatusCode {
106        if self.is_healthy() {
107            StatusCode::OK
108        } else {
109            StatusCode::SERVICE_UNAVAILABLE
110        }
111    }
112
113    /// The names that failed.
114    pub fn failing(&self) -> Vec<&str> {
115        self.checks
116            .iter()
117            .filter(|(_, outcome)| !outcome.is_ok())
118            .map(|(name, _)| name.as_str())
119            .collect()
120    }
121}
122
123/// A registry of health checks.
124#[derive(Clone, Default)]
125pub struct Health {
126    checks: Vec<(String, Check)>,
127    timeout: Option<Duration>,
128    build: Option<Value>,
129}
130
131impl Health {
132    /// An empty registry, with a five-second deadline per check.
133    pub fn new() -> Self {
134        Self { checks: Vec::new(), timeout: Some(Duration::from_secs(5)), build: None }
135    }
136
137    /// Add a check.
138    ///
139    /// It receives the application, so it can resolve whatever it needs to
140    /// prove — which is the point: a check that does not touch the dependency
141    /// is a check that reports the dependency is fine when it is not.
142    #[must_use = "this returns a registry with the check added"]
143    pub fn register<F, Fut>(mut self, name: impl Into<String>, check: F) -> Self
144    where
145        F: Fn(Arc<Application>) -> Fut + Send + Sync + 'static,
146        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
147    {
148        self.checks.push((name.into(), Arc::new(move |app| Box::pin(check(app)))));
149        self
150    }
151
152    /// How long a single check may take before it counts as failed.
153    ///
154    /// `None` removes the deadline, which is almost always wrong: a probe that
155    /// hangs leaves the replica in an unknown state, and an orchestrator that
156    /// cannot decide does nothing at all.
157    #[must_use = "this returns a configured registry rather than configuring in place"]
158    pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
159        self.timeout = timeout;
160        self
161    }
162
163    /// Include this build description in the report.
164    ///
165    /// Pass [`build_info!()`](macro@rainier_support::build_info) — the version and
166    /// commit belong in the same document as the health, because the first two
167    /// questions of an incident are "is it up" and "which one is it".
168    #[must_use = "this returns a configured registry rather than configuring in place"]
169    pub fn describing_build(mut self, build: impl Serialize) -> Self {
170        self.build = serde_json::to_value(build).ok();
171        self
172    }
173
174    /// The names registered, in order.
175    pub fn names(&self) -> Vec<&str> {
176        self.checks.iter().map(|(name, _)| name.as_str()).collect()
177    }
178
179    /// Run every check.
180    ///
181    /// Concurrently: a report of six checks that take a second each should
182    /// take a second, not six, because a probe has a deadline of its own and
183    /// a slow report is a failed one.
184    pub async fn run(&self, app: Arc<Application>) -> Report {
185        let running = self.checks.iter().map(|(name, check)| {
186            let app = Arc::clone(&app);
187            let name = name.clone();
188            let check = Arc::clone(check);
189            let timeout = self.timeout;
190
191            async move { (name, run_one(check, app, timeout).await) }
192        });
193
194        let checks: BTreeMap<String, Outcome> =
195            futures_util::future::join_all(running).await.into_iter().collect();
196
197        let status = if checks.values().all(Outcome::is_ok) { "ok" } else { "failing" };
198
199        Report { status, checks }
200    }
201
202    /// Run every check and render the document a probe reads.
203    pub async fn render(&self, app: Arc<Application>) -> Response {
204        let report = self.run(app).await;
205        let status = report.status_code();
206
207        let mut document = json!({
208            "status": report.status,
209            "checks": report.checks,
210        });
211
212        if let Some(build) = &self.build {
213            document["build"] = build.clone();
214        }
215
216        Response::json(&document).with_status(status)
217    }
218}
219
220/// Run one check, under its deadline and in its own task.
221///
222/// Spawned for two reasons. A panicking check is contained rather than taking
223/// the whole probe with it — a check is somebody else's closure and one of
224/// them will panic eventually, and a probe that returns nothing leaves the
225/// replica in exactly the unknown state the deadline exists to avoid.
226///
227/// And [`spawn_with_facades`] carries the application scope in, so a check
228/// that reaches for a facade finds the same application the report is about.
229async fn run_one(check: Check, app: Arc<Application>, timeout: Option<Duration>) -> Outcome {
230    let started = Instant::now();
231
232    let running = rainier_container::spawn_with_facades(async move {
233        match timeout {
234            Some(timeout) => match tokio::time::timeout(timeout, check(app)).await {
235                Ok(result) => result,
236                Err(_) => Err(Error::service_unavailable(format!(
237                    "did not answer within {}ms",
238                    timeout.as_millis()
239                ))),
240            },
241            None => check(app).await,
242        }
243    });
244
245    let result = match running.await {
246        Ok(result) => result,
247        Err(e) if e.is_panic() => Err(Error::internal("the check panicked".to_string())),
248        Err(_) => Err(Error::internal("the check was cancelled")),
249    };
250
251    let duration_ms = started.elapsed().as_millis() as u64;
252
253    match result {
254        Ok(()) => Outcome { status: "ok", duration_ms, error: None },
255        // The message, not the whole error chain: this document is served to
256        // whoever can reach the endpoint, and a connection string in a probe
257        // response is a connection string in somebody's monitoring system.
258        Err(e) => Outcome { status: "failing", duration_ms, error: Some(e.message().to_string()) },
259    }
260}
261
262impl std::fmt::Debug for Health {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("Health")
265            .field("checks", &self.names())
266            .field("timeout", &self.timeout)
267            .finish()
268    }
269}
270
271/// The readiness endpoint, resolving [`Health`] from the container.
272///
273/// ```ignore
274/// router.get("/health/ready", health::endpoint).name("health.ready");
275/// ```
276///
277/// Answers `503` when anything is failing — see
278/// [`Report::status_code`].
279pub async fn endpoint() -> Response {
280    let app = facade_application();
281
282    match app.resolve::<Health>() {
283        Ok(health) => health.render(app).await,
284        // Nothing registered. Saying so beats reporting healthy, which is what
285        // an empty registry would otherwise mean — a probe that passes because
286        // it checks nothing.
287        Err(_) => Response::json(&json!({
288            "status": "unknown",
289            "checks": {},
290            "error": "no health checks are registered — bind a `Health` in a provider",
291        }))
292        .with_status(StatusCode::SERVICE_UNAVAILABLE),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn app() -> Arc<Application> {
301        Arc::new(Application::new("."))
302    }
303
304    fn healthy() -> Health {
305        Health::new()
306            .register("database", |_| async { Ok(()) })
307            .register("cache", |_| async { Ok(()) })
308    }
309
310    #[tokio::test]
311    async fn every_check_passing_is_ok() {
312        let report = healthy().run(app()).await;
313
314        assert!(report.is_healthy());
315        assert_eq!(report.status_code(), StatusCode::OK);
316        assert_eq!(report.checks.len(), 2);
317        assert!(report.failing().is_empty());
318    }
319
320    #[tokio::test]
321    async fn one_failing_check_fails_the_report() {
322        let health =
323            healthy().register("search", |_| async { Err(Error::internal("connection refused")) });
324
325        let report = health.run(app()).await;
326
327        assert!(!report.is_healthy());
328        assert_eq!(report.failing(), vec!["search"]);
329        // The others still report, so a reader can see what is and is not
330        // working rather than only that something is wrong.
331        assert!(report.checks["database"].is_ok());
332    }
333
334    #[tokio::test]
335    async fn a_failing_check_says_why() {
336        let health = Health::new()
337            .register("search", |_| async { Err(Error::internal("connection refused")) });
338
339        let report = health.run(app()).await;
340
341        assert_eq!(report.checks["search"].error.as_deref(), Some("connection refused"));
342    }
343
344    #[tokio::test]
345    async fn the_status_is_503_rather_than_500() {
346        // "Try another replica, try again shortly" — which is what a load
347        // balancer and a retrying client both need to hear.
348        let health = Health::new().register("db", |_| async { Err(Error::internal("down")) });
349
350        assert_eq!(health.run(app()).await.status_code(), StatusCode::SERVICE_UNAVAILABLE);
351    }
352
353    #[tokio::test]
354    async fn a_hanging_check_fails_at_its_deadline() {
355        // A probe that hangs is worse than one that fails: the orchestrator
356        // waits and decides nothing.
357        let health =
358            Health::new().timeout(Some(Duration::from_millis(30))).register("slow", |_| async {
359                tokio::time::sleep(Duration::from_secs(30)).await;
360                Ok(())
361            });
362
363        let report = health.run(app()).await;
364
365        assert!(!report.is_healthy());
366        assert!(report.checks["slow"].error.as_deref().unwrap().contains("30ms"));
367    }
368
369    #[tokio::test]
370    async fn checks_run_concurrently() {
371        // Six slow checks should take about as long as one, because the probe
372        // reading this has a deadline of its own.
373        let health = (0..6).fold(Health::new(), |health, i| {
374            health.register(format!("slow-{i}"), |_| async {
375                tokio::time::sleep(Duration::from_millis(80)).await;
376                Ok(())
377            })
378        });
379
380        let started = Instant::now();
381        let report = health.run(app()).await;
382
383        assert!(report.is_healthy());
384        assert!(
385            started.elapsed() < Duration::from_millis(400),
386            "took {:?}, so they ran one after another",
387            started.elapsed()
388        );
389    }
390
391    #[tokio::test]
392    async fn each_check_is_timed() {
393        let health = Health::new().register("slow", |_| async {
394            tokio::time::sleep(Duration::from_millis(40)).await;
395            Ok(())
396        });
397
398        assert!(health.run(app()).await.checks["slow"].duration_ms >= 30);
399    }
400
401    #[tokio::test]
402    async fn the_build_travels_with_the_report() {
403        let health = healthy().describing_build(json!({ "version": "2.4.1" }));
404
405        let body = health.render(app()).await.into_string().await.unwrap();
406        let document: Value = serde_json::from_str(&body).unwrap();
407
408        assert_eq!(document["build"]["version"], "2.4.1");
409        assert_eq!(document["status"], "ok");
410    }
411
412    #[tokio::test]
413    async fn an_empty_registry_is_healthy_and_says_nothing() {
414        // Worth pinning: an empty registry passing is only acceptable because
415        // the *endpoint* refuses when nothing is bound at all.
416        let report = Health::new().run(app()).await;
417
418        assert!(report.is_healthy());
419        assert!(report.checks.is_empty());
420    }
421
422    #[tokio::test]
423    async fn a_check_can_resolve_from_the_application() {
424        struct Thing(&'static str);
425
426        let app = app();
427        app.instance(Thing("bound"));
428
429        let health = Health::new().register("thing", |app| async move {
430            match app.resolve::<Thing>() {
431                Ok(thing) if thing.0 == "bound" => Ok(()),
432                _ => Err(Error::internal("not bound")),
433            }
434        });
435
436        assert!(health.run(app).await.is_healthy());
437    }
438
439    #[tokio::test]
440    async fn a_panicking_check_does_not_take_the_probe_with_it() {
441        // A check is somebody else's closure and one of them will panic
442        // eventually. A probe that returns nothing leaves the replica in
443        // exactly the unknown state the deadline exists to avoid, so the
444        // panic has to become a failed check like any other.
445        let health = Health::new()
446            .register("fine", |_| async { Ok(()) })
447            .register("panics", |_| async { panic!("this check is broken") });
448
449        let report = health.run(app()).await;
450
451        assert!(!report.is_healthy());
452        assert_eq!(report.failing(), vec!["panics"]);
453        assert!(report.checks["fine"].is_ok(), "the others still reported");
454        assert_eq!(report.checks["panics"].error.as_deref(), Some("the check panicked"));
455    }
456}