Skip to main content

praxis_core/config/cluster/
health_check.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2// Copyright (c) 2024 Shane Utt
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 active health check settings.
57///
58/// Configures periodic probing of upstream endpoints to detect
59/// failures before routing traffic to them.
60///
61/// ```
62/// # use praxis_core::config::{HealthCheckConfig, HealthCheckType};
63/// let yaml = r#"
64/// type: http
65/// path: "/healthz"
66/// expected_status: 200
67/// interval_ms: 5000
68/// timeout_ms: 2000
69/// healthy_threshold: 2
70/// unhealthy_threshold: 3
71/// "#;
72/// let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
73/// assert_eq!(hc.check_type, HealthCheckType::Http);
74/// assert_eq!(hc.path, "/healthz");
75/// assert_eq!(hc.interval_ms, 5000);
76/// ```
77#[derive(Debug, Clone, Deserialize, Serialize)]
78pub struct HealthCheckConfig {
79    /// Probe type: [`Http`], [`Tcp`], or [`Grpc`].
80    ///
81    /// [`Http`]: HealthCheckType::Http
82    /// [`Tcp`]: HealthCheckType::Tcp
83    /// [`Grpc`]: HealthCheckType::Grpc
84    #[serde(rename = "type")]
85    pub check_type: HealthCheckType,
86
87    /// HTTP path to probe (only used for `http` type).
88    #[serde(default = "default_path")]
89    pub path: String,
90
91    /// Expected HTTP status code for a healthy response.
92    #[serde(default = "default_expected_status")]
93    pub expected_status: u16,
94
95    /// Probe interval in milliseconds.
96    #[serde(default = "default_interval_ms")]
97    pub interval_ms: u64,
98
99    /// Probe timeout in milliseconds. Must be less than `interval_ms`.
100    #[serde(default = "default_timeout_ms")]
101    pub timeout_ms: u64,
102
103    /// Consecutive successes required to mark an endpoint healthy.
104    #[serde(default = "default_healthy_threshold")]
105    pub healthy_threshold: u32,
106
107    /// Consecutive failures required to mark an endpoint unhealthy.
108    #[serde(default = "default_unhealthy_threshold")]
109    pub unhealthy_threshold: u32,
110}
111
112/// Default HTTP probe path.
113fn default_path() -> String {
114    "/".to_owned()
115}
116
117/// Default expected HTTP status code.
118fn default_expected_status() -> u16 {
119    200
120}
121
122/// Default probe interval (5 seconds).
123fn default_interval_ms() -> u64 {
124    5000
125}
126
127/// Default probe timeout (2 seconds).
128fn default_timeout_ms() -> u64 {
129    2000
130}
131
132/// Default consecutive successes to mark healthy.
133fn default_healthy_threshold() -> u32 {
134    2
135}
136
137/// Default consecutive failures to mark unhealthy.
138fn default_unhealthy_threshold() -> u32 {
139    3
140}
141
142// -----------------------------------------------------------------------------
143// Tests
144// -----------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn parse_full_config() {
152        let yaml = r#"
153type: http
154path: "/healthz"
155expected_status: 200
156interval_ms: 5000
157timeout_ms: 2000
158healthy_threshold: 2
159unhealthy_threshold: 3
160"#;
161        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
162        assert_eq!(hc.check_type, HealthCheckType::Http, "type should be http");
163        assert_eq!(hc.path, "/healthz", "path mismatch");
164        assert_eq!(hc.expected_status, 200, "expected_status mismatch");
165        assert_eq!(hc.interval_ms, 5000, "interval_ms mismatch");
166        assert_eq!(hc.timeout_ms, 2000, "timeout_ms mismatch");
167        assert_eq!(hc.healthy_threshold, 2, "healthy_threshold mismatch");
168        assert_eq!(hc.unhealthy_threshold, 3, "unhealthy_threshold mismatch");
169    }
170
171    #[test]
172    fn defaults_applied() {
173        let yaml = "type: http\n";
174        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
175        assert_eq!(hc.path, "/", "default path should be /");
176        assert_eq!(hc.expected_status, 200, "default expected_status should be 200");
177        assert_eq!(hc.interval_ms, 5000, "default interval_ms should be 5000");
178        assert_eq!(hc.timeout_ms, 2000, "default timeout_ms should be 2000");
179        assert_eq!(hc.healthy_threshold, 2, "default healthy_threshold should be 2");
180        assert_eq!(hc.unhealthy_threshold, 3, "default unhealthy_threshold should be 3");
181    }
182
183    #[test]
184    fn tcp_type_parses() {
185        let yaml = "type: tcp\n";
186        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
187        assert_eq!(hc.check_type, HealthCheckType::Tcp, "type should be tcp");
188    }
189
190    #[test]
191    fn roundtrip_via_serde() {
192        let hc = HealthCheckConfig {
193            check_type: HealthCheckType::Http,
194            path: "/health".to_owned(),
195            expected_status: 204,
196            interval_ms: 10000,
197            timeout_ms: 3000,
198            healthy_threshold: 3,
199            unhealthy_threshold: 5,
200        };
201        let value = serde_yaml::to_value(&hc).unwrap();
202        let back: HealthCheckConfig = serde_yaml::from_value(value).unwrap();
203        assert_eq!(back.check_type, hc.check_type, "type should roundtrip");
204        assert_eq!(back.path, hc.path, "path should roundtrip");
205        assert_eq!(back.expected_status, hc.expected_status, "status should roundtrip");
206        assert_eq!(back.interval_ms, hc.interval_ms, "interval should roundtrip");
207        assert_eq!(back.timeout_ms, hc.timeout_ms, "timeout should roundtrip");
208    }
209
210    #[test]
211    fn unknown_type_rejected_by_serde() {
212        let yaml = "type: websocket\n";
213        let result: Result<HealthCheckConfig, _> = serde_yaml::from_str(yaml);
214        assert!(result.is_err(), "unknown type should be rejected by serde");
215    }
216
217    #[test]
218    fn custom_expected_status() {
219        let yaml = r#"
220type: http
221expected_status: 204
222"#;
223        let hc: HealthCheckConfig = serde_yaml::from_str(yaml).unwrap();
224        assert_eq!(hc.expected_status, 204, "custom expected_status should be 204");
225    }
226}