Skip to main content

nntp_proxy/pool/
health_check.rs

1//! Health check implementation for pooled connections
2//!
3//! This module provides health checking functionality for NNTP connections:
4//! - TCP-level checks using non-blocking peek
5//! - Application-level checks using DATE command
6//! - Health check metrics tracking
7
8use deadpool::managed;
9use std::sync::atomic::{AtomicU64, Ordering};
10use thiserror::Error;
11use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
12use tokio::time::timeout;
13
14use crate::constants::pool::{
15    DATE_COMMAND, EXPECTED_DATE_RESPONSE_PREFIX, HEALTH_CHECK_BUFFER_SIZE, HEALTH_CHECK_TIMEOUT,
16    TCP_PEEK_BUFFER_SIZE,
17};
18use crate::stream::ConnectionStream;
19
20#[allow(clippy::cast_precision_loss)] // Failure rates are approximate monitoring values derived from exact counters.
21const fn count_as_f64_for_rate(value: u64) -> f64 {
22    // Health-check failure rate is an approximate monitoring value. The exact
23    // checked/failed counters remain stored as u64.
24    value as f64
25}
26
27/// Errors that can occur during connection health checks
28#[derive(Debug, Error)]
29pub enum HealthCheckError {
30    /// TCP connection is closed
31    #[error("TCP connection closed")]
32    TcpClosed,
33
34    /// Unexpected data found in the buffer before health check
35    #[error("Unexpected data in buffer")]
36    UnexpectedData,
37
38    /// TCP-level error occurred
39    #[error("TCP error: {0}")]
40    TcpError(std::io::Error),
41
42    /// Failed to write DATE command to the connection
43    #[error("Failed to write health check: {0}")]
44    WriteError(std::io::Error),
45
46    /// Failed to read response from the connection
47    #[error("Failed to read health check response: {0}")]
48    ReadError(std::io::Error),
49
50    /// Health check operation timed out
51    #[error("Health check timeout")]
52    Timeout,
53
54    /// Server returned unexpected response to DATE command
55    #[error("Unexpected health check response: {0}")]
56    UnexpectedResponse(String),
57
58    /// Connection closed while waiting for health check response
59    #[error("Connection closed during health check")]
60    ConnectionClosedDuringCheck,
61}
62
63impl From<HealthCheckError> for managed::RecycleError<crate::connection_error::ConnectionError> {
64    fn from(err: HealthCheckError) -> Self {
65        Self::Message(err.to_string().into())
66    }
67}
68
69/// Metrics for periodic health checks (lock-free)
70#[derive(Debug, Default)]
71pub struct HealthCheckMetrics {
72    /// Total number of health check cycles run
73    cycles_run: AtomicU64,
74    /// Total number of connections checked
75    connections_checked: AtomicU64,
76    /// Total number of connections that failed health checks
77    connections_failed: AtomicU64,
78}
79
80impl HealthCheckMetrics {
81    /// Create a new metrics instance
82    #[must_use]
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Record a health check cycle
88    pub fn record_cycle(&self, checked: u64, failed: u64) {
89        self.cycles_run.fetch_add(1, Ordering::Relaxed);
90        self.connections_checked
91            .fetch_add(checked, Ordering::Relaxed);
92        self.connections_failed.fetch_add(failed, Ordering::Relaxed);
93    }
94
95    /// Get the failure rate (0.0 to 1.0)
96    pub fn failure_rate(&self) -> f64 {
97        let checked = self.connections_checked.load(Ordering::Relaxed);
98        if checked == 0 {
99            0.0
100        } else {
101            let failed = self.connections_failed.load(Ordering::Relaxed);
102            count_as_f64_for_rate(failed) / count_as_f64_for_rate(checked)
103        }
104    }
105
106    /// Get total cycles run
107    pub fn cycles_run(&self) -> u64 {
108        self.cycles_run.load(Ordering::Relaxed)
109    }
110
111    /// Get total connections checked
112    pub fn connections_checked(&self) -> u64 {
113        self.connections_checked.load(Ordering::Relaxed)
114    }
115
116    /// Get total connections failed
117    pub fn connections_failed(&self) -> u64 {
118        self.connections_failed.load(Ordering::Relaxed)
119    }
120}
121
122/// Fast TCP-level check for obviously dead connections
123///
124/// Uses non-blocking peek to detect closed connections without consuming data.
125/// Only applicable to plain TCP connections; TLS connections skip this check.
126///
127/// # How it works
128/// - `try_read()` attempts a non-blocking read of 1 byte
129/// - `Ok(0)` means the connection is closed (EOF)
130/// - `Ok(n)` means data is available (unexpected - should be idle)
131/// - `Err(WouldBlock)` means no data available - this is **expected** for an idle,
132///   healthy connection, as there should be no data to read between commands
133/// - Other errors indicate TCP-level problems
134///
135/// # Errors
136/// Returns a recycle error when the connection is closed, has queued backend bytes,
137/// or the underlying TCP socket reports a health-check failure.
138pub fn check_tcp_alive(
139    conn: &mut ConnectionStream,
140) -> managed::RecycleResult<crate::connection_error::ConnectionError> {
141    if conn.has_pending_bytes() {
142        return Err(HealthCheckError::UnexpectedData.into());
143    }
144
145    let mut peek_buf = [0u8; TCP_PEEK_BUFFER_SIZE];
146
147    // Check the underlying TCP stream regardless of TLS/compression layers
148    let tcp_stream = conn.underlying_tcp_stream();
149    match tcp_stream.try_read(&mut peek_buf) {
150        Ok(0) => return Err(HealthCheckError::TcpClosed.into()),
151        Ok(_) => {
152            // Data available on TCP socket that we haven't consumed — reject it
153            return Err(HealthCheckError::UnexpectedData.into());
154        }
155        Err(e) if e.kind() != std::io::ErrorKind::WouldBlock => {
156            return Err(
157                HealthCheckError::TcpError(std::io::Error::new(e.kind(), e.to_string())).into(),
158            );
159        }
160        // WouldBlock is the expected case - no data available on idle connection
161        Err(_) => {}
162    }
163
164    Ok(())
165}
166
167/// Validate DATE command response
168///
169/// Returns Ok(()) if the response starts with "111 " (`EXPECTED_DATE_RESPONSE_PREFIX`),
170/// otherwise returns an error with the actual response.
171///
172/// This is a pure function extracted for testability.
173#[inline]
174pub(crate) fn validate_date_response(response: &str) -> Result<(), HealthCheckError> {
175    if response.starts_with(EXPECTED_DATE_RESPONSE_PREFIX) {
176        Ok(())
177    } else {
178        Err(HealthCheckError::UnexpectedResponse(response.to_string()))
179    }
180}
181
182async fn read_date_response<C>(conn: &mut C) -> Result<String, HealthCheckError>
183where
184    C: AsyncRead + Unpin,
185{
186    let mut response_buf = [0u8; HEALTH_CHECK_BUFFER_SIZE];
187    let request = crate::protocol::RequestContext::from_verb_args(b"DATE", b"");
188
189    // Keep DATE response framing behind the backend facade. Health checks care
190    // only about the final reply string or the typed failure, never about
191    // partial line state.
192    crate::session::backend::read_single_line_reply(conn, &request, &mut response_buf)
193        .await
194        .map_err(|err| match err {
195            crate::session::backend::SingleLineReplyReadError::Full { bytes_read }
196            | crate::session::backend::SingleLineReplyReadError::Invalid { bytes_read } => {
197                HealthCheckError::UnexpectedResponse(
198                    String::from_utf8_lossy(&response_buf[..bytes_read]).into_owned(),
199                )
200            }
201            crate::session::backend::SingleLineReplyReadError::Io(err) => {
202                HealthCheckError::ReadError(err)
203            }
204            crate::session::backend::SingleLineReplyReadError::Closed => {
205                HealthCheckError::ConnectionClosedDuringCheck
206            }
207        })
208}
209
210/// Application-level health check using DATE command
211///
212/// Sends DATE command and verifies response to ensure the NNTP connection
213/// is still functional. This detects server-side timeouts that TCP keepalive
214/// might miss.
215///
216/// # Errors
217/// Returns `HealthCheckError` when writing `DATE` fails, the response times out,
218/// the backend closes the connection, or the reply is not a valid `111` response.
219pub async fn check_date_response<C>(conn: &mut C) -> Result<(), HealthCheckError>
220where
221    C: AsyncRead + AsyncWrite + Unpin,
222{
223    // Wrap entire health check in single timeout
224    let health_check = async {
225        // Send DATE command
226        conn.write_all(DATE_COMMAND)
227            .await
228            .map_err(HealthCheckError::WriteError)?;
229
230        let response = read_date_response(conn).await?;
231        validate_date_response(&response)
232    };
233
234    // Apply timeout and convert errors
235    timeout(HEALTH_CHECK_TIMEOUT, health_check)
236        .await
237        .map_err(|_| HealthCheckError::Timeout)?
238}
239
240#[cfg(test)]
241#[allow(clippy::float_cmp)] // These tests assert exact health-check failure rates from fixed counters.
242mod tests {
243    use super::*;
244    use std::collections::VecDeque;
245    use std::pin::Pin;
246    use std::task::{Context, Poll};
247    use tokio::io::AsyncWrite;
248
249    struct ChunkedStream {
250        chunks: VecDeque<Vec<u8>>,
251        written: Vec<u8>,
252    }
253
254    impl ChunkedStream {
255        fn new(chunks: Vec<Vec<u8>>) -> Self {
256            Self {
257                chunks: chunks.into(),
258                written: Vec::new(),
259            }
260        }
261    }
262
263    impl tokio::io::AsyncRead for ChunkedStream {
264        fn poll_read(
265            mut self: Pin<&mut Self>,
266            _cx: &mut Context<'_>,
267            buf: &mut tokio::io::ReadBuf<'_>,
268        ) -> Poll<std::io::Result<()>> {
269            if let Some(chunk) = self.chunks.pop_front() {
270                let len = chunk.len().min(buf.remaining());
271                buf.put_slice(&chunk[..len]);
272                if len < chunk.len() {
273                    self.chunks.push_front(chunk[len..].to_vec());
274                }
275            }
276            Poll::Ready(Ok(()))
277        }
278    }
279
280    impl AsyncWrite for ChunkedStream {
281        fn poll_write(
282            mut self: Pin<&mut Self>,
283            _cx: &mut Context<'_>,
284            buf: &[u8],
285        ) -> Poll<std::io::Result<usize>> {
286            self.written.extend_from_slice(buf);
287            Poll::Ready(Ok(buf.len()))
288        }
289
290        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
291            Poll::Ready(Ok(()))
292        }
293
294        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
295            Poll::Ready(Ok(()))
296        }
297    }
298
299    #[test]
300    fn test_health_check_metrics_new() {
301        let metrics = HealthCheckMetrics::new();
302        assert_eq!(metrics.cycles_run(), 0);
303        assert_eq!(metrics.connections_checked(), 0);
304        assert_eq!(metrics.connections_failed(), 0);
305        assert_eq!(metrics.failure_rate(), 0.0);
306    }
307
308    #[test]
309    fn test_health_check_metrics_record_cycle() {
310        let metrics = HealthCheckMetrics::new();
311
312        metrics.record_cycle(10, 2);
313        assert_eq!(metrics.cycles_run(), 1);
314        assert_eq!(metrics.connections_checked(), 10);
315        assert_eq!(metrics.connections_failed(), 2);
316        assert_eq!(metrics.failure_rate(), 0.2);
317
318        metrics.record_cycle(5, 1);
319        assert_eq!(metrics.cycles_run(), 2);
320        assert_eq!(metrics.connections_checked(), 15);
321        assert_eq!(metrics.connections_failed(), 3);
322        assert_eq!(metrics.failure_rate(), 0.2);
323    }
324
325    #[test]
326    fn test_health_check_metrics_failure_rate() {
327        let metrics = HealthCheckMetrics::new();
328
329        // No failures
330        metrics.record_cycle(10, 0);
331        assert_eq!(metrics.failure_rate(), 0.0);
332
333        // 50% failure rate
334        metrics.record_cycle(10, 5);
335        assert!((metrics.failure_rate() - 0.25).abs() < 0.01);
336
337        // 100% failure rate cycle
338        metrics.record_cycle(10, 10);
339        assert!((metrics.failure_rate() - 0.5).abs() < 0.01);
340    }
341
342    #[test]
343    fn test_health_check_metrics_zero_checked() {
344        let metrics = HealthCheckMetrics::new();
345        assert_eq!(metrics.failure_rate(), 0.0);
346    }
347
348    #[test]
349    fn test_health_check_metrics_multiple_cycles() {
350        let metrics = HealthCheckMetrics::new();
351
352        for i in 1..=5 {
353            metrics.record_cycle(10, 1);
354            assert_eq!(metrics.cycles_run(), i);
355        }
356
357        assert_eq!(metrics.connections_checked(), 50);
358        assert_eq!(metrics.connections_failed(), 5);
359        assert_eq!(metrics.failure_rate(), 0.1);
360    }
361
362    #[tokio::test]
363    async fn test_tcp_alive_check_rejects_queued_backend_bytes() {
364        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
365        let addr = listener.local_addr().unwrap();
366
367        let client_handle =
368            tokio::spawn(async move { tokio::net::TcpStream::connect(addr).await.unwrap() });
369        let (server_stream, _) = listener.accept().await.unwrap();
370        let _client = client_handle.await.unwrap();
371
372        let mut conn = ConnectionStream::plain(server_stream);
373        conn.queue_pending_bytes(b"430 stale response\r\n").unwrap();
374
375        let result = check_tcp_alive(&mut conn);
376        assert!(
377            result.is_err(),
378            "connections with queued backend bytes must not recycle"
379        );
380    }
381
382    #[test]
383    fn test_health_check_error_display() {
384        assert_eq!(
385            HealthCheckError::TcpClosed.to_string(),
386            "TCP connection closed"
387        );
388        assert_eq!(
389            HealthCheckError::UnexpectedData.to_string(),
390            "Unexpected data in buffer"
391        );
392        assert_eq!(
393            HealthCheckError::Timeout.to_string(),
394            "Health check timeout"
395        );
396        assert_eq!(
397            HealthCheckError::ConnectionClosedDuringCheck.to_string(),
398            "Connection closed during health check"
399        );
400    }
401
402    #[test]
403    fn test_health_check_error_unexpected_response() {
404        let err = HealthCheckError::UnexpectedResponse("500 Error".to_string());
405        assert_eq!(
406            err.to_string(),
407            "Unexpected health check response: 500 Error"
408        );
409    }
410
411    #[test]
412    fn test_health_check_error_tcp_error() {
413        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
414        let err = HealthCheckError::TcpError(io_err);
415        assert!(err.to_string().contains("TCP error"));
416        assert!(err.to_string().contains("reset"));
417    }
418
419    #[test]
420    fn test_health_check_error_write_error() {
421        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe");
422        let err = HealthCheckError::WriteError(io_err);
423        assert!(err.to_string().contains("Failed to write health check"));
424    }
425
426    #[test]
427    fn test_health_check_error_read_error() {
428        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
429        let err = HealthCheckError::ReadError(io_err);
430        assert!(
431            err.to_string()
432                .contains("Failed to read health check response")
433        );
434    }
435
436    // DATE response validation tests
437
438    #[test]
439    fn test_validate_date_response_success() {
440        // Standard DATE response format
441        assert!(validate_date_response("111 20231215120000\r\n").is_ok());
442    }
443
444    #[test]
445    fn test_validate_date_response_success_minimal() {
446        // Minimal valid response (just "111 " prefix)
447        assert!(validate_date_response("111 \r\n").is_ok());
448    }
449
450    #[test]
451    fn test_validate_date_response_success_with_extra() {
452        // Extra data after timestamp is OK
453        assert!(validate_date_response("111 20231215120000 extra info\r\n").is_ok());
454    }
455
456    #[test]
457    fn test_validate_date_response_wrong_code() {
458        // Wrong status code
459        let result = validate_date_response("200 OK\r\n");
460        assert!(result.is_err());
461        match result {
462            Err(HealthCheckError::UnexpectedResponse(msg)) => {
463                assert_eq!(msg, "200 OK\r\n");
464            }
465            _ => panic!("Expected UnexpectedResponse error"),
466        }
467    }
468
469    #[test]
470    fn test_validate_date_response_error_code() {
471        // Error codes (4xx, 5xx) should fail
472        assert!(validate_date_response("400 Bad Request\r\n").is_err());
473        assert!(validate_date_response("500 Server Error\r\n").is_err());
474    }
475
476    #[test]
477    fn test_validate_date_response_empty() {
478        // Empty response
479        let result = validate_date_response("");
480        assert!(result.is_err());
481    }
482
483    #[test]
484    fn test_validate_date_response_malformed() {
485        // Malformed responses
486        assert!(validate_date_response("not a valid response").is_err());
487        assert!(validate_date_response("1\r\n").is_err());
488        assert!(validate_date_response("11 \r\n").is_err()); // Too short prefix
489    }
490
491    #[test]
492    fn test_validate_date_response_partial_match() {
493        // Starts with "11" but not "111 "
494        assert!(validate_date_response("110 Info\r\n").is_err());
495        assert!(validate_date_response("112 Other\r\n").is_err());
496    }
497
498    #[test]
499    fn test_validate_date_response_no_space() {
500        // Missing space after "111"
501        assert!(validate_date_response("11120231215120000\r\n").is_err());
502    }
503
504    #[test]
505    fn test_validate_date_response_whitespace_prefix() {
506        // Leading whitespace should fail
507        assert!(validate_date_response(" 111 20231215120000\r\n").is_err());
508        assert!(validate_date_response("\r\n111 20231215120000\r\n").is_err());
509    }
510
511    #[test]
512    fn test_validate_date_response_case_sensitivity() {
513        // Status codes are numeric, so no case issues, but test weird inputs
514        assert!(validate_date_response("111 lowercase\r\n").is_ok());
515        assert!(validate_date_response("111 UPPERCASE\r\n").is_ok());
516    }
517
518    #[test]
519    fn test_validate_date_response_unicode() {
520        // Unicode in timestamp portion (unusual but should work if starts with "111 ")
521        assert!(validate_date_response("111 日本語\r\n").is_ok());
522    }
523
524    #[test]
525    fn test_validate_date_response_realistic_examples() {
526        // Real-world examples from different NNTP servers
527        assert!(validate_date_response("111 20231215120530\r\n").is_ok());
528        assert!(validate_date_response("111 19700101000000\r\n").is_ok());
529        assert!(validate_date_response("111 20991231235959\r\n").is_ok());
530    }
531
532    #[tokio::test]
533    async fn test_check_date_response_reads_split_reply() {
534        let mut stream = ChunkedStream::new(vec![b"111 20231215".to_vec(), b"120000\r\n".to_vec()]);
535
536        let result = check_date_response(&mut stream).await;
537        assert!(
538            result.is_ok(),
539            "split DATE responses should be consumed fully"
540        );
541        assert_eq!(stream.written, DATE_COMMAND);
542    }
543
544    #[tokio::test]
545    async fn test_check_date_response_rejects_invalid_reply_bytes() {
546        let mut stream = ChunkedStream::new(vec![b"abc\r\n".to_vec()]);
547
548        let result = check_date_response(&mut stream).await;
549
550        match result {
551            Err(HealthCheckError::UnexpectedResponse(response)) => {
552                assert_eq!(response, "abc\r\n");
553            }
554            other => panic!("Expected invalid DATE response, got {other:?}"),
555        }
556    }
557}