praxis_core/config/cluster/
health_check.rs1use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum HealthCheckType {
34 Http,
36 Tcp,
38 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#[derive(Debug, Clone, Deserialize, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct HealthCheckConfig {
85 #[serde(rename = "type")]
91 pub check_type: HealthCheckType,
92
93 #[serde(default = "default_expected_status")]
95 pub expected_status: u16,
96
97 #[serde(default = "default_healthy_threshold")]
99 pub healthy_threshold: u32,
100
101 #[serde(default = "default_interval_ms")]
103 pub interval_ms: u64,
104
105 #[serde(default)]
109 pub passive_healthy_threshold: Option<u32>,
110
111 #[serde(default)]
115 pub passive_unhealthy_threshold: Option<u32>,
116
117 #[serde(default = "default_path")]
119 pub path: String,
120
121 #[serde(default = "default_timeout_ms")]
123 pub timeout_ms: u64,
124
125 #[serde(default = "default_unhealthy_threshold")]
127 pub unhealthy_threshold: u32,
128}
129
130fn default_path() -> String {
132 "/".to_owned()
133}
134
135fn default_expected_status() -> u16 {
137 200
138}
139
140fn default_interval_ms() -> u64 {
142 5000
143}
144
145fn default_timeout_ms() -> u64 {
147 2000
148}
149
150fn default_healthy_threshold() -> u32 {
152 2
153}
154
155fn default_unhealthy_threshold() -> u32 {
157 3
158}
159
160#[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 #[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}