Skip to main content

praxis_core/config/cluster/
health_check.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Health check configuration for upstream clusters.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10// -----------------------------------------------------------------------------
11// HealthCheckType
12// -----------------------------------------------------------------------------
13
14/// Supported health check probe types.
15///
16/// ```
17/// use praxis_core::config::HealthCheckType;
18///
19/// let http: HealthCheckType = serde_yaml::from_str("http").unwrap();
20/// assert!(matches!(http, HealthCheckType::Http));
21///
22/// let tcp: HealthCheckType = serde_yaml::from_str("tcp").unwrap();
23/// assert!(matches!(tcp, HealthCheckType::Tcp));
24///
25/// let grpc: HealthCheckType = serde_yaml::from_str("grpc").unwrap();
26/// assert!(matches!(grpc, HealthCheckType::Grpc));
27///
28/// let bad: Result<HealthCheckType, _> = serde_yaml::from_str("websocket");
29/// assert!(bad.is_err());
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum HealthCheckType {
34    /// HTTP GET probe.
35    Http,
36    /// TCP connect probe.
37    Tcp,
38    /// gRPC health check (not yet supported).
39    Grpc,
40}
41
42impl fmt::Display for HealthCheckType {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Http => f.write_str("http"),
46            Self::Tcp => f.write_str("tcp"),
47            Self::Grpc => f.write_str("grpc"),
48        }
49    }
50}
51
52// -----------------------------------------------------------------------------
53// HealthCheckConfig
54// -----------------------------------------------------------------------------
55
56/// Per-cluster health check settings (active and passive).
57///
58/// Active checking configures periodic probing of upstream
59/// endpoints. Passive checking observes real request outcomes
60/// (5xx responses, connection errors) to update health state.
61///
62/// ```
63/// # use praxis_core::config::{HealthCheckConfig, HealthCheckType};
64/// let yaml = r#"
65/// type: http
66/// path: "/healthz"
67/// expected_status: 200
68/// interval_ms: 5000
69/// timeout_ms: 2000
70/// healthy_threshold: 2
71/// unhealthy_threshold: 3
72/// passive_unhealthy_threshold: 5
73/// passive_healthy_threshold: 3
74/// "#;
75/// let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
76/// assert_eq!(hc.check_type, HealthCheckType::Http);
77/// assert_eq!(hc.path, "/healthz");
78/// assert_eq!(hc.interval_ms, 5000);
79/// assert_eq!(hc.passive_unhealthy_threshold, Some(5));
80/// assert_eq!(hc.passive_healthy_threshold, Some(3));
81/// ```
82#[derive(Debug, Clone, Deserialize, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct HealthCheckConfig {
85    /// Probe type: [`Http`], [`Tcp`], or [`Grpc`].
86    ///
87    /// [`Http`]: HealthCheckType::Http
88    /// [`Tcp`]: HealthCheckType::Tcp
89    /// [`Grpc`]: HealthCheckType::Grpc
90    #[serde(rename = "type")]
91    pub check_type: HealthCheckType,
92
93    /// Expected HTTP status code for a healthy response.
94    #[serde(default = "default_expected_status")]
95    pub expected_status: u16,
96
97    /// Consecutive successes required to mark an endpoint healthy.
98    #[serde(default = "default_healthy_threshold")]
99    pub healthy_threshold: u32,
100
101    /// Probe interval in milliseconds.
102    #[serde(default = "default_interval_ms")]
103    pub interval_ms: u64,
104
105    /// Consecutive successes to mark an endpoint healthy again
106    /// via passive observation. `None` disables passive recovery
107    /// (active checks must recover it).
108    #[serde(default)]
109    pub passive_healthy_threshold: Option<u32>,
110
111    /// Consecutive response failures (5xx or connect error) to
112    /// mark an endpoint unhealthy via passive observation.
113    /// `None` disables passive checking.
114    #[serde(default)]
115    pub passive_unhealthy_threshold: Option<u32>,
116
117    /// HTTP path to probe (only used for `http` type).
118    #[serde(default = "default_path")]
119    pub path: String,
120
121    /// Probe timeout in milliseconds. Must be less than `interval_ms`.
122    #[serde(default = "default_timeout_ms")]
123    pub timeout_ms: u64,
124
125    /// Consecutive failures required to mark an endpoint unhealthy.
126    #[serde(default = "default_unhealthy_threshold")]
127    pub unhealthy_threshold: u32,
128}
129
130/// Default HTTP probe path.
131fn default_path() -> String {
132    "/".to_owned()
133}
134
135/// Default expected HTTP status code.
136fn default_expected_status() -> u16 {
137    200
138}
139
140/// Default probe interval (5 seconds).
141fn default_interval_ms() -> u64 {
142    5000
143}
144
145/// Default probe timeout (2 seconds).
146fn default_timeout_ms() -> u64 {
147    2000
148}
149
150/// Default consecutive successes to mark healthy.
151fn default_healthy_threshold() -> u32 {
152    2
153}
154
155/// Default consecutive failures to mark unhealthy.
156fn default_unhealthy_threshold() -> u32 {
157    3
158}
159
160// -----------------------------------------------------------------------------
161// Tests
162// -----------------------------------------------------------------------------
163
164#[cfg(test)]
165#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
166#[allow(
167    clippy::unwrap_used,
168    clippy::expect_used,
169    clippy::indexing_slicing,
170    clippy::needless_raw_strings,
171    clippy::needless_raw_string_hashes,
172    reason = "tests use unwrap/expect/indexing/raw strings for brevity"
173)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_full_config() {
179        let yaml = r#"
180type: http
181path: "/healthz"
182expected_status: 200
183interval_ms: 5000
184timeout_ms: 2000
185healthy_threshold: 2
186unhealthy_threshold: 3
187"#;
188        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
189        assert_eq!(hc.check_type, HealthCheckType::Http, "type should be http");
190        assert_eq!(hc.path, "/healthz", "path mismatch");
191        assert_eq!(hc.expected_status, 200, "expected_status mismatch");
192        assert_eq!(hc.interval_ms, 5000, "interval_ms mismatch");
193        assert_eq!(hc.timeout_ms, 2000, "timeout_ms mismatch");
194        assert_eq!(hc.healthy_threshold, 2, "healthy_threshold mismatch");
195        assert_eq!(hc.unhealthy_threshold, 3, "unhealthy_threshold mismatch");
196    }
197
198    #[test]
199    fn defaults_applied() {
200        let yaml = "type: http\n";
201        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
202        assert_eq!(hc.path, "/", "default path should be /");
203        assert_eq!(hc.expected_status, 200, "default expected_status should be 200");
204        assert_eq!(hc.interval_ms, 5000, "default interval_ms should be 5000");
205        assert_eq!(hc.timeout_ms, 2000, "default timeout_ms should be 2000");
206        assert_eq!(hc.healthy_threshold, 2, "default healthy_threshold should be 2");
207        assert_eq!(hc.unhealthy_threshold, 3, "default unhealthy_threshold should be 3");
208    }
209
210    #[test]
211    fn tcp_type_parses() {
212        let yaml = "type: tcp\n";
213        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
214        assert_eq!(hc.check_type, HealthCheckType::Tcp, "type should be tcp");
215    }
216
217    #[test]
218    fn roundtrip_via_serde() {
219        let hc = HealthCheckConfig {
220            check_type: HealthCheckType::Http,
221            expected_status: 204,
222            healthy_threshold: 3,
223            interval_ms: 10000,
224            passive_healthy_threshold: Some(3),
225            passive_unhealthy_threshold: Some(5),
226            path: "/health".to_owned(),
227            timeout_ms: 3000,
228            unhealthy_threshold: 5,
229        };
230        let value = serde_yaml::to_value(&hc).unwrap();
231        let back: HealthCheckConfig = serde_yaml::from_value(value).unwrap();
232        assert_eq!(back.check_type, hc.check_type, "type should roundtrip");
233        assert_eq!(back.path, hc.path, "path should roundtrip");
234        assert_eq!(back.expected_status, hc.expected_status, "status should roundtrip");
235        assert_eq!(back.interval_ms, hc.interval_ms, "interval should roundtrip");
236        assert_eq!(back.timeout_ms, hc.timeout_ms, "timeout should roundtrip");
237        assert_eq!(
238            back.passive_unhealthy_threshold, hc.passive_unhealthy_threshold,
239            "passive_unhealthy should roundtrip"
240        );
241        assert_eq!(
242            back.passive_healthy_threshold, hc.passive_healthy_threshold,
243            "passive_healthy should roundtrip"
244        );
245    }
246
247    #[test]
248    fn unknown_type_rejected_by_serde() {
249        let yaml = "type: websocket\n";
250        let result: Result<HealthCheckConfig, _> = serde_yaml::from_str(yaml);
251        assert!(result.is_err(), "unknown type should be rejected by serde");
252    }
253
254    #[test]
255    fn custom_expected_status() {
256        let yaml = r#"
257type: http
258expected_status: 204
259"#;
260        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
261        assert_eq!(hc.expected_status, 204, "custom expected_status should be 204");
262    }
263
264    #[test]
265    fn passive_thresholds_default_to_none() {
266        let yaml = "type: http\n";
267        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
268        assert_eq!(
269            hc.passive_unhealthy_threshold, None,
270            "passive_unhealthy_threshold should default to None"
271        );
272        assert_eq!(
273            hc.passive_healthy_threshold, None,
274            "passive_healthy_threshold should default to None"
275        );
276    }
277
278    #[test]
279    fn passive_thresholds_parse() {
280        let yaml = r#"
281type: http
282passive_unhealthy_threshold: 5
283passive_healthy_threshold: 3
284"#;
285        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
286        assert_eq!(
287            hc.passive_unhealthy_threshold,
288            Some(5),
289            "passive_unhealthy_threshold mismatch"
290        );
291        assert_eq!(
292            hc.passive_healthy_threshold,
293            Some(3),
294            "passive_healthy_threshold mismatch"
295        );
296    }
297
298    #[test]
299    fn passive_only_unhealthy_threshold() {
300        let yaml = r#"
301type: http
302passive_unhealthy_threshold: 3
303"#;
304        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
305        assert_eq!(
306            hc.passive_unhealthy_threshold,
307            Some(3),
308            "should parse passive_unhealthy_threshold alone"
309        );
310        assert_eq!(
311            hc.passive_healthy_threshold, None,
312            "passive_healthy_threshold should be None"
313        );
314    }
315
316    // -----------------------------------------------------------------------
317    // HealthCheckType Display
318    // -----------------------------------------------------------------------
319
320    #[test]
321    fn display_http() {
322        assert_eq!(
323            HealthCheckType::Http.to_string(),
324            "http",
325            "Http display should be 'http'"
326        );
327    }
328
329    #[test]
330    fn display_tcp() {
331        assert_eq!(HealthCheckType::Tcp.to_string(), "tcp", "Tcp display should be 'tcp'");
332    }
333
334    #[test]
335    fn display_grpc() {
336        assert_eq!(
337            HealthCheckType::Grpc.to_string(),
338            "grpc",
339            "Grpc display should be 'grpc'"
340        );
341    }
342}