Skip to main content

liminal_server/health/
endpoint.rs

1use std::io::{Read, Write};
2use std::net::{SocketAddr, TcpListener, TcpStream};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8use crate::ServerError;
9
10use super::checks::{SharedReadinessState, health_check, readiness_check};
11
12use super::metrics_route;
13
14const HEALTH_PATH: &str = "/health";
15const READY_PATH: &str = "/ready";
16const METRICS_PATH: &str = "/metrics";
17const APPLICATION_JSON: &str = "application/json";
18const READ_BUFFER_BYTES: usize = 2048;
19
20/// Handle for a running health endpoint server.
21#[derive(Debug)]
22pub struct HealthServerHandle {
23    local_addr: SocketAddr,
24    shutdown: Arc<AtomicBool>,
25    worker: Option<JoinHandle<Result<(), ServerError>>>,
26}
27
28impl HealthServerHandle {
29    /// Returns the bound address for the health endpoint server.
30    #[must_use]
31    pub const fn local_addr(&self) -> SocketAddr {
32        self.local_addr
33    }
34
35    /// Stops the health endpoint server and waits for its worker thread to exit.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`ServerError::HealthEndpoint`] if the worker thread cannot be
40    /// joined cleanly or if the server loop recorded a serving error.
41    pub fn shutdown(mut self) -> Result<(), ServerError> {
42        self.stop_worker()
43    }
44
45    fn stop_worker(&mut self) -> Result<(), ServerError> {
46        self.shutdown.store(true, Ordering::SeqCst);
47        let Some(worker) = self.worker.take() else {
48            return Ok(());
49        };
50
51        worker.join().map_err(|_| ServerError::HealthEndpoint {
52            message: "health endpoint worker thread terminated unexpectedly".to_owned(),
53        })?
54    }
55}
56
57impl Drop for HealthServerHandle {
58    fn drop(&mut self) {
59        if let Err(error) = self.stop_worker() {
60            tracing::debug!(%error, "health endpoint shutdown during drop failed");
61        }
62    }
63}
64
65/// Starts the health endpoint HTTP server on a distinct health bind address.
66///
67/// The returned server handle is independent from the main wire protocol
68/// listener. Binding the health endpoint does not mark the main listener ready.
69///
70/// # Errors
71///
72/// Returns [`ServerError::HealthEndpoint`] when the health listener cannot bind,
73/// cannot be configured for non-blocking accepts, or cannot report its local
74/// address.
75pub fn start_health_server(
76    bind_address: SocketAddr,
77    readiness: SharedReadinessState,
78) -> Result<HealthServerHandle, ServerError> {
79    let listener =
80        TcpListener::bind(bind_address).map_err(|error| ServerError::HealthEndpoint {
81            message: format!("failed to bind health endpoint at {bind_address}: {error}"),
82        })?;
83    listener
84        .set_nonblocking(true)
85        .map_err(|error| ServerError::HealthEndpoint {
86            message: format!("failed to configure health endpoint listener: {error}"),
87        })?;
88    let local_addr = listener
89        .local_addr()
90        .map_err(|error| ServerError::HealthEndpoint {
91            message: format!("failed to inspect health endpoint listener address: {error}"),
92        })?;
93    let shutdown = Arc::new(AtomicBool::new(false));
94    let worker_shutdown = Arc::clone(&shutdown);
95    let worker = thread::spawn(move || serve(&listener, &readiness, &worker_shutdown));
96
97    Ok(HealthServerHandle {
98        local_addr,
99        shutdown,
100        worker: Some(worker),
101    })
102}
103
104fn serve(
105    listener: &TcpListener,
106    readiness: &SharedReadinessState,
107    shutdown: &AtomicBool,
108) -> Result<(), ServerError> {
109    while !shutdown.load(Ordering::SeqCst) {
110        match listener.accept() {
111            Ok((stream, ..)) => {
112                // A per-connection error (e.g. a TCP probe that connects but sends no HTTP
113                // data within the read timeout) must NOT terminate the serve loop — otherwise
114                // a single port probe kills the health server for the process lifetime and
115                // subsequent liveness/readiness probes get connection-refused. Only fatal
116                // listener-level accept errors (below) terminate serving.
117                if let Err(error) = handle_connection(stream, readiness) {
118                    tracing::debug!(%error, "health endpoint connection error");
119                }
120            }
121            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
122                thread::sleep(Duration::from_millis(10));
123            }
124            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
125            Err(error) => {
126                return Err(ServerError::HealthEndpoint {
127                    message: format!("health endpoint accept failed: {error}"),
128                });
129            }
130        }
131    }
132
133    Ok(())
134}
135
136fn handle_connection(
137    mut stream: TcpStream,
138    readiness: &SharedReadinessState,
139) -> Result<(), ServerError> {
140    stream
141        .set_nonblocking(false)
142        .map_err(|error| ServerError::HealthEndpoint {
143            message: format!("failed to configure health request stream: {error}"),
144        })?;
145    stream
146        .set_read_timeout(Some(Duration::from_secs(2)))
147        .map_err(|error| ServerError::HealthEndpoint {
148            message: format!("failed to set health request read timeout: {error}"),
149        })?;
150
151    let mut buffer = [0_u8; READ_BUFFER_BYTES];
152    let bytes_read = stream
153        .read(&mut buffer)
154        .map_err(|error| ServerError::HealthEndpoint {
155            message: format!("failed to read health request: {error}"),
156        })?;
157
158    if bytes_read == 0 {
159        return Ok(());
160    }
161
162    let response = response_for_request(&buffer[..bytes_read], readiness)?;
163    stream
164        .write_all(&response)
165        .map_err(|error| ServerError::HealthEndpoint {
166            message: format!("failed to write health response: {error}"),
167        })?;
168    stream.flush().map_err(|error| ServerError::HealthEndpoint {
169        message: format!("failed to flush health response: {error}"),
170    })
171}
172
173fn response_for_request(
174    request: &[u8],
175    readiness: &SharedReadinessState,
176) -> Result<Vec<u8>, ServerError> {
177    let Ok(request) = std::str::from_utf8(request) else {
178        return Ok(empty_response(StatusCode::BadRequest));
179    };
180    let Some((method, path)) = parse_request_line(request) else {
181        return Ok(empty_response(StatusCode::BadRequest));
182    };
183
184    match (method, path) {
185        ("GET", HEALTH_PATH) => json_response(StatusCode::Ok, &health_check()),
186        ("GET", READY_PATH) => {
187            let status = readiness_check(&readiness.snapshot());
188            let status_code = if status.ready {
189                StatusCode::Ok
190            } else {
191                StatusCode::ServiceUnavailable
192            };
193            json_response(status_code, &status)
194        }
195        ("GET", METRICS_PATH) => Ok(response(
196            StatusCode::Ok,
197            Some(metrics_route::CONTENT_TYPE),
198            metrics_route::render_body().as_bytes(),
199        )),
200        (_, HEALTH_PATH | READY_PATH | METRICS_PATH) => {
201            Ok(empty_response(StatusCode::MethodNotAllowed))
202        }
203        _ => Ok(empty_response(StatusCode::NotFound)),
204    }
205}
206
207fn parse_request_line(request: &str) -> Option<(&str, &str)> {
208    let request_line = request.lines().next()?;
209    let mut parts = request_line.split_whitespace();
210    let method = parts.next()?;
211    let path = parts.next()?;
212    parts.next()?;
213
214    Some((method, path))
215}
216
217fn json_response<T>(status: StatusCode, value: &T) -> Result<Vec<u8>, ServerError>
218where
219    T: serde::Serialize,
220{
221    let body = serde_json::to_vec(value).map_err(|error| ServerError::HealthEndpoint {
222        message: format!("failed to serialize health response: {error}"),
223    })?;
224    Ok(response(status, Some(APPLICATION_JSON), &body))
225}
226
227fn empty_response(status: StatusCode) -> Vec<u8> {
228    response(status, None, &[])
229}
230
231fn response(status: StatusCode, content_type: Option<&str>, body: &[u8]) -> Vec<u8> {
232    let mut response = Vec::new();
233    let status_line = format!("HTTP/1.1 {} {}\r\n", status.code(), status.reason());
234    response.extend_from_slice(status_line.as_bytes());
235    response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
236    response.extend_from_slice(b"Connection: close\r\n");
237    if let Some(content_type) = content_type {
238        response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
239    }
240    response.extend_from_slice(b"\r\n");
241    response.extend_from_slice(body);
242    response
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246enum StatusCode {
247    Ok,
248    BadRequest,
249    NotFound,
250    MethodNotAllowed,
251    ServiceUnavailable,
252}
253
254impl StatusCode {
255    const fn code(self) -> u16 {
256        match self {
257            Self::Ok => 200,
258            Self::BadRequest => 400,
259            Self::NotFound => 404,
260            Self::MethodNotAllowed => 405,
261            Self::ServiceUnavailable => 503,
262        }
263    }
264
265    const fn reason(self) -> &'static str {
266        match self {
267            Self::Ok => "OK",
268            Self::BadRequest => "Bad Request",
269            Self::NotFound => "Not Found",
270            Self::MethodNotAllowed => "Method Not Allowed",
271            Self::ServiceUnavailable => "Service Unavailable",
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use std::io::{Read, Write};
279    use std::net::{SocketAddr, TcpStream};
280    use std::time::Duration;
281
282    use serde_json::Value;
283
284    use super::{response_for_request, start_health_server};
285    use crate::health::checks::{
286        ClusterReadiness, ReadinessCondition, ReadinessState, SharedReadinessState,
287    };
288
289    fn loopback_ephemeral() -> Result<SocketAddr, Box<dyn std::error::Error>> {
290        Ok("127.0.0.1:0".parse()?)
291    }
292
293    fn get(address: SocketAddr, path: &str) -> Result<String, Box<dyn std::error::Error>> {
294        let mut stream = TcpStream::connect(address)?;
295        stream.set_read_timeout(Some(Duration::from_secs(2)))?;
296        let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n");
297        stream.write_all(request.as_bytes())?;
298
299        let mut response = String::new();
300        stream.read_to_string(&mut response)?;
301        Ok(response)
302    }
303
304    fn assert_status(response: &str, status: u16) {
305        let expected = format!("HTTP/1.1 {status} ");
306        assert!(
307            response.starts_with(&expected),
308            "response status did not start with {expected}: {response}"
309        );
310    }
311
312    fn body(response: &str) -> Result<&str, Box<dyn std::error::Error>> {
313        let Some((_headers, body)) = response.split_once("\r\n\r\n") else {
314            return Err("response did not contain a header/body separator".into());
315        };
316        Ok(body)
317    }
318
319    fn json_body(response: &str) -> Result<Value, Box<dyn std::error::Error>> {
320        Ok(serde_json::from_str(body(response)?)?)
321    }
322
323    #[test]
324    fn health_endpoint_returns_json_200_regardless_of_readiness()
325    -> Result<(), Box<dyn std::error::Error>> {
326        let readiness = SharedReadinessState::new(ReadinessState::default());
327        let server = start_health_server(loopback_ephemeral()?, readiness)?;
328
329        let response = get(server.local_addr(), "/health")?;
330        server.shutdown()?;
331
332        assert_status(&response, 200);
333        assert!(response.contains("Content-Type: application/json\r\n"));
334        let body = json_body(&response)?;
335        assert_eq!(body["status"], "healthy");
336
337        Ok(())
338    }
339
340    #[test]
341    fn ready_endpoint_returns_503_before_main_listener_binds()
342    -> Result<(), Box<dyn std::error::Error>> {
343        let readiness = SharedReadinessState::new(ReadinessState::new(
344            true,
345            false,
346            ClusterReadiness::NotConfigured,
347        ));
348        let server = start_health_server(loopback_ephemeral()?, readiness)?;
349
350        let response = get(server.local_addr(), "/ready")?;
351        server.shutdown()?;
352
353        assert_status(&response, 503);
354        assert!(response.contains("Content-Type: application/json\r\n"));
355        let body = json_body(&response)?;
356        assert_eq!(body["ready"], false);
357        assert_eq!(body["unmet_conditions"][0], "listener_bound");
358
359        Ok(())
360    }
361
362    #[test]
363    fn ready_endpoint_returns_200_after_all_startup_gates() -> Result<(), Box<dyn std::error::Error>>
364    {
365        let readiness = SharedReadinessState::new(ReadinessState::ready_without_cluster());
366        let server = start_health_server(loopback_ephemeral()?, readiness)?;
367
368        let response = get(server.local_addr(), "/ready")?;
369        server.shutdown()?;
370
371        assert_status(&response, 200);
372        let body = json_body(&response)?;
373        assert_eq!(body["ready"], true);
374        let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
375            return Err("unmet_conditions should be an array".into());
376        };
377        assert!(unmet_conditions.is_empty());
378
379        Ok(())
380    }
381
382    #[test]
383    fn ready_endpoint_updates_from_shared_readiness_state() -> Result<(), Box<dyn std::error::Error>>
384    {
385        let readiness = SharedReadinessState::new(ReadinessState::default());
386        let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
387
388        let response = get(server.local_addr(), "/ready")?;
389        assert_status(&response, 503);
390
391        readiness.set_config_loaded(true);
392        readiness.set_listener_bound(true);
393        let response = get(server.local_addr(), "/ready")?;
394        server.shutdown()?;
395
396        assert_status(&response, 200);
397
398        Ok(())
399    }
400
401    #[test]
402    fn clustered_ready_transitions_503_to_200_when_membership_established()
403    -> Result<(), Box<dyn std::error::Error>> {
404        // A clustered server starts with the cluster gate unmet: config loaded and
405        // listener bound, but membership not yet established (G2). /ready is 503.
406        let readiness = SharedReadinessState::new(ReadinessState::new(
407            true,
408            true,
409            ClusterReadiness::Configured {
410                membership_established: false,
411            },
412        ));
413        let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
414
415        let response = get(server.local_addr(), "/ready")?;
416        assert_status(&response, 503);
417        let body = json_body(&response)?;
418        assert_eq!(body["ready"], false);
419        assert_eq!(
420            body["unmet_conditions"][0],
421            serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
422        );
423
424        // The cluster start's on_established hook flips exactly this flag; once set,
425        // /ready must transition to 200 with no unmet conditions.
426        readiness.set_cluster_membership_established(true);
427        let response = get(server.local_addr(), "/ready")?;
428        server.shutdown()?;
429
430        assert_status(&response, 200);
431        let body = json_body(&response)?;
432        assert_eq!(body["ready"], true);
433        let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
434            return Err("unmet_conditions should be an array".into());
435        };
436        assert!(unmet_conditions.is_empty());
437
438        Ok(())
439    }
440
441    #[test]
442    fn cluster_readiness_is_listed_when_configured_but_not_joined()
443    -> Result<(), Box<dyn std::error::Error>> {
444        let readiness = SharedReadinessState::new(ReadinessState::new(
445            true,
446            true,
447            ClusterReadiness::Configured {
448                membership_established: false,
449            },
450        ));
451        let response = response_for_request(b"GET /ready HTTP/1.1\r\n\r\n", &readiness)?;
452        let response = String::from_utf8(response)?;
453
454        assert_status(&response, 503);
455        let body = json_body(&response)?;
456        assert_eq!(
457            body["unmet_conditions"][0],
458            serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
459        );
460
461        Ok(())
462    }
463
464    #[test]
465    fn unsupported_paths_are_not_served() -> Result<(), Box<dyn std::error::Error>> {
466        let readiness = SharedReadinessState::default();
467        let response = response_for_request(b"GET /unknown HTTP/1.1\r\n\r\n", &readiness)?;
468        let response = String::from_utf8(response)?;
469
470        assert_status(&response, 404);
471
472        Ok(())
473    }
474
475    #[test]
476    fn unsupported_methods_on_health_paths_are_rejected() -> Result<(), Box<dyn std::error::Error>>
477    {
478        let readiness = SharedReadinessState::default();
479        let response = response_for_request(b"POST /health HTTP/1.1\r\n\r\n", &readiness)?;
480        let response = String::from_utf8(response)?;
481
482        assert_status(&response, 405);
483
484        Ok(())
485    }
486}