Skip to main content

ullun/
health.rs

1//! Liveness and readiness endpoints
2//!
3//! Orchestrators ask two different questions. *Liveness*: is the process
4//! stuck and should it be restarted? *Readiness*: should traffic be sent here
5//! right now? [`Health`] answers both:
6//!
7//! * [`Health::liveness`] only proves the server answers requests; it never
8//!   touches dependencies, so a database outage does not restart every instance
9//! * [`Health::readiness`] runs the registered checks (each with a timeout,
10//!   results cached briefly so probes cannot hammer the database) and answers
11//!   `503` when a critical one fails. Optional checks only mark the service as
12//!   `degraded`. During a graceful shutdown it answers `503` immediately: with
13//!   [`App::shutdown_delay`](crate::app::App::shutdown_delay) the load balancer
14//!   stops routing to the instance before connections are refused.
15//!
16//! ```no_run
17//! use std::time::Duration;
18//! use ullun::health::Health;
19//! use ullun::prelude::*;
20//!
21//! # async fn run() {
22//! let health = Health::new()
23//!     .check("database", || async { Ok::<(), String>(()) })
24//!     .optional("recommendations", || async { Err::<(), _>("slow upstream") });
25//! App::new()
26//!     .shutdown_delay(Duration::from_secs(5))
27//!     .get("/livez", health.liveness())
28//!     .get("/readyz", health.readiness())
29//!     .run("127.0.0.1:3000")
30//!     .await
31//!     .unwrap();
32//! # }
33//! ```
34//!
35//! Error messages of failed checks are not included in the response (it is
36//! usually public); call [`Health::expose_errors`] for internal endpoints.
37
38use crate::error::Result;
39use crate::handler::BoxFuture;
40use crate::request::Request;
41use crate::response::Response;
42use std::collections::BTreeMap;
43use std::fmt::Display;
44use std::future::Future;
45use std::sync::Arc;
46use std::time::{Duration, Instant};
47use tokio::sync::Mutex;
48
49type CheckFn = dyn Fn() -> BoxFuture<'static, std::result::Result<(), String>> + Send + Sync;
50
51struct Check {
52    name: String,
53    critical: bool,
54    run: Arc<CheckFn>,
55}
56
57#[derive(Clone)]
58struct Outcome {
59    ok: bool,
60    critical: bool,
61    millis: u64,
62    error: Option<String>,
63}
64
65struct Cached {
66    at: Instant,
67    outcomes: Arc<BTreeMap<String, Outcome>>,
68}
69
70struct Inner {
71    checks: Vec<Check>,
72    timeout: Duration,
73    cache_for: Duration,
74    expose_errors: bool,
75    cache: Mutex<Option<Cached>>,
76}
77
78/// Health check registry, see the [module documentation](self). Cloning gives
79/// another handle to the same registry.
80#[derive(Clone)]
81pub struct Health {
82    inner: Arc<Inner>,
83}
84
85impl Default for Health {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91impl Health {
92    pub fn new() -> Self {
93        Self {
94            inner: Arc::new(Inner {
95                checks: Vec::new(),
96                timeout: Duration::from_secs(2),
97                cache_for: Duration::from_secs(1),
98                expose_errors: false,
99                cache: Mutex::new(None),
100            }),
101        }
102    }
103
104    fn edit(mut self, f: impl FnOnce(&mut Inner)) -> Self {
105        let inner = Arc::get_mut(&mut self.inner).expect("configure Health before sharing it");
106        f(inner);
107        self
108    }
109
110    fn add<F, Fut, E>(self, name: &str, critical: bool, check: F) -> Self
111    where
112        F: Fn() -> Fut + Send + Sync + 'static,
113        Fut: Future<Output = std::result::Result<(), E>> + Send + 'static,
114        E: Display,
115    {
116        let name = name.to_string();
117        self.edit(|i| {
118            i.checks.push(Check {
119                name,
120                critical,
121                run: Arc::new(move || {
122                    let fut = check();
123                    Box::pin(async move { fut.await.map_err(|e| e.to_string()) })
124                }),
125            })
126        })
127    }
128
129    /// A dependency the service cannot work without: when it fails, readiness is `503`
130    pub fn check<F, Fut, E>(self, name: &str, check: F) -> Self
131    where
132        F: Fn() -> Fut + Send + Sync + 'static,
133        Fut: Future<Output = std::result::Result<(), E>> + Send + 'static,
134        E: Display,
135    {
136        self.add(name, true, check)
137    }
138
139    /// A dependency the service can live without: when it fails, readiness stays
140    /// `200` but reports `degraded`
141    pub fn optional<F, Fut, E>(self, name: &str, check: F) -> Self
142    where
143        F: Fn() -> Fut + Send + Sync + 'static,
144        Fut: Future<Output = std::result::Result<(), E>> + Send + 'static,
145        E: Display,
146    {
147        self.add(name, false, check)
148    }
149
150    /// Longest a single check may take before it counts as failed (default 2 seconds)
151    pub fn timeout(self, timeout: Duration) -> Self {
152        self.edit(|i| i.timeout = timeout)
153    }
154
155    /// Reuse the last result for this long (default 1 second, zero disables).
156    /// Concurrent probes share one run of the checks either way.
157    pub fn cache_for(self, ttl: Duration) -> Self {
158        self.edit(|i| i.cache_for = ttl)
159    }
160
161    /// Include the error text of failed checks in the response. Off by default
162    /// because messages can reveal hostnames and internals.
163    pub fn expose_errors(self) -> Self {
164        self.edit(|i| i.expose_errors = true)
165    }
166
167    /// Handler for liveness probes: `200 {"status":"up"}` as long as the server responds
168    pub fn liveness(
169        &self,
170    ) -> impl Fn(Request) -> BoxFuture<'static, Result<Response>> + Send + Sync + Clone + 'static
171    {
172        |_req| {
173            Box::pin(async {
174                Ok(Response::json(serde_json::json!({ "status": "up" }))
175                    .with_header("cache-control", "no-store"))
176            })
177        }
178    }
179
180    /// Handler for readiness probes, see the [module documentation](self)
181    pub fn readiness(
182        &self,
183    ) -> impl Fn(Request) -> BoxFuture<'static, Result<Response>> + Send + Sync + Clone + 'static
184    {
185        let health = self.clone();
186        move |req: Request| {
187            let health = health.clone();
188            Box::pin(async move { Ok(health.report(&req).await) })
189        }
190    }
191
192    async fn outcomes(&self) -> Arc<BTreeMap<String, Outcome>> {
193        let inner = &self.inner;
194        // holding the lock while checks run makes concurrent probes wait for one run
195        let mut cache = inner.cache.lock().await;
196        if let Some(c) = cache.as_ref() {
197            if c.at.elapsed() < inner.cache_for {
198                return c.outcomes.clone();
199            }
200        }
201        let runs = inner.checks.iter().map(|check| async move {
202            let started = Instant::now();
203            let result = tokio::time::timeout(inner.timeout, (check.run)()).await;
204            let (ok, error) = match result {
205                Ok(Ok(())) => (true, None),
206                Ok(Err(e)) => (false, Some(e)),
207                Err(_) => (false, Some(format!("timed out after {:?}", inner.timeout))),
208            };
209            (
210                check.name.clone(),
211                Outcome {
212                    ok,
213                    critical: check.critical,
214                    millis: started.elapsed().as_millis() as u64,
215                    error,
216                },
217            )
218        });
219        let outcomes: BTreeMap<_, _> = join_all(runs).await.into_iter().collect();
220        let outcomes = Arc::new(outcomes);
221        *cache = Some(Cached {
222            at: Instant::now(),
223            outcomes: outcomes.clone(),
224        });
225        outcomes
226    }
227
228    async fn report(&self, req: &Request) -> Response {
229        let no_store = |r: Response| r.with_header("cache-control", "no-store");
230        if req.shutdown_signal().is_draining() {
231            return no_store(
232                Response::json(serde_json::json!({ "status": "draining" }))
233                    .with_status(http::StatusCode::SERVICE_UNAVAILABLE),
234            );
235        }
236        let outcomes = self.outcomes().await;
237        let down = outcomes.values().any(|o| !o.ok && o.critical);
238        let degraded = outcomes.values().any(|o| !o.ok && !o.critical);
239        let status = if down {
240            "down"
241        } else if degraded {
242            "degraded"
243        } else {
244            "up"
245        };
246        let checks: serde_json::Map<_, _> = outcomes
247            .iter()
248            .map(|(name, o)| {
249                let mut entry = serde_json::json!({
250                    "status": if o.ok { "up" } else { "down" },
251                    "critical": o.critical,
252                    "ms": o.millis,
253                });
254                if let (false, Some(err), true) = (o.ok, &o.error, self.inner.expose_errors) {
255                    entry["error"] = err.clone().into();
256                }
257                (name.clone(), entry)
258            })
259            .collect();
260        let code = if down {
261            http::StatusCode::SERVICE_UNAVAILABLE
262        } else {
263            http::StatusCode::OK
264        };
265        no_store(
266            Response::json(serde_json::json!({ "status": status, "checks": checks }))
267                .with_status(code),
268        )
269    }
270}
271
272/// Run futures concurrently and collect their outputs in order
273async fn join_all<F: Future>(futures: impl IntoIterator<Item = F>) -> Vec<F::Output> {
274    let mut futures: Vec<_> = futures.into_iter().map(Box::pin).collect();
275    let mut done: Vec<Option<F::Output>> = futures.iter().map(|_| None).collect();
276    std::future::poll_fn(|cx| {
277        let mut pending = false;
278        for (i, f) in futures.iter_mut().enumerate() {
279            if done[i].is_none() {
280                match f.as_mut().poll(cx) {
281                    std::task::Poll::Ready(v) => done[i] = Some(v),
282                    std::task::Poll::Pending => pending = true,
283                }
284            }
285        }
286        if pending {
287            std::task::Poll::Pending
288        } else {
289            std::task::Poll::Ready(())
290        }
291    })
292    .await;
293    done.into_iter().flatten().collect()
294}