Skip to main content

nntp_proxy/session/
session_error.rs

1//! Session-level error enum with explicit client-disconnect classification.
2//!
3//! Replaces the `is_client_disconnect_error` downcast pattern with a typed enum
4//! that carries the disconnect signal through the entire handler stack without
5//! any `downcast_ref` inspection at each call site.
6
7use std::fmt;
8
9/// Session-level error that carries the client-disconnect signal through the handler stack.
10///
11/// Only two cases matter for control flow:
12/// - `ClientDisconnect` — client closed connection; don't log, don't retry, propagate cleanly
13/// - `Backend` — log, maybe retry, handle as backend/protocol error
14#[derive(Debug)]
15pub enum SessionError {
16    /// Client disconnected (`BrokenPipe`, `ConnectionReset`).
17    ///
18    /// Not a real error — normal operation. Should not be logged as a warning.
19    ClientDisconnect(std::io::Error),
20
21    /// Any other error (backend I/O, protocol, timeout, pool exhaustion, etc.)
22    Backend(anyhow::Error),
23}
24
25impl fmt::Display for SessionError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::ClientDisconnect(e) => write!(f, "client disconnected: {e}"),
29            Self::Backend(e) => write!(f, "{e}"),
30        }
31    }
32}
33
34impl std::error::Error for SessionError {
35    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36        match self {
37            Self::ClientDisconnect(e) => Some(e),
38            Self::Backend(e) => e.source(),
39        }
40    }
41}
42
43/// Convert `anyhow::Error` into `SessionError`, classifying client disconnects.
44///
45/// Tries to downcast to `io::Error` (most common) and then to `ConnectionError`.
46/// Disconnect kinds (`BrokenPipe`, `ConnectionReset`) become `ClientDisconnect`.
47/// Everything else becomes `Backend`.
48impl From<anyhow::Error> for SessionError {
49    fn from(e: anyhow::Error) -> Self {
50        use crate::connection_error::{ConnectionError, is_disconnect_kind};
51
52        // Fast path: bare io::Error (most common for client disconnects in stateful path)
53        match e.downcast::<std::io::Error>() {
54            Ok(io_err) if is_disconnect_kind(io_err.kind()) => Self::ClientDisconnect(io_err),
55            Ok(io_err) => Self::Backend(io_err.into()),
56            Err(e) => {
57                // Try ConnectionError::IoError
58                match e.downcast::<ConnectionError>() {
59                    Ok(ConnectionError::IoError(io_err)) if is_disconnect_kind(io_err.kind()) => {
60                        Self::ClientDisconnect(io_err)
61                    }
62                    Ok(conn_err) => Self::Backend(conn_err.into()),
63                    Err(e) => Self::Backend(e),
64                }
65            }
66        }
67    }
68}
69
70/// Convert response-transfer errors to `SessionError`, preserving the disconnect signal.
71impl From<crate::session::response_transfer::ResponseTransferError> for SessionError {
72    fn from(e: crate::session::response_transfer::ResponseTransferError) -> Self {
73        match e {
74            crate::session::response_transfer::ResponseTransferError::ClientDisconnect(io_err) => {
75                Self::ClientDisconnect(io_err)
76            }
77            other => Self::Backend(other.into_anyhow()),
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::io::ErrorKind;
86
87    #[test]
88    fn client_disconnect_from_broken_pipe_io_error() {
89        let io_err = std::io::Error::from(ErrorKind::BrokenPipe);
90        let e = SessionError::from(anyhow::Error::from(io_err));
91        assert!(matches!(e, SessionError::ClientDisconnect(_)));
92    }
93
94    #[test]
95    fn client_disconnect_from_connection_reset_io_error() {
96        let io_err = std::io::Error::from(ErrorKind::ConnectionReset);
97        let e = SessionError::from(anyhow::Error::from(io_err));
98        assert!(matches!(e, SessionError::ClientDisconnect(_)));
99    }
100
101    #[test]
102    fn backend_from_non_disconnect_io_error() {
103        let io_err = std::io::Error::from(ErrorKind::TimedOut);
104        let e = SessionError::from(anyhow::Error::from(io_err));
105        assert!(matches!(e, SessionError::Backend(_)));
106    }
107
108    #[test]
109    fn backend_from_non_io_error() {
110        let e = SessionError::from(anyhow::anyhow!("generic error"));
111        assert!(matches!(e, SessionError::Backend(_)));
112    }
113
114    #[test]
115    fn client_disconnect_from_response_transfer_error() {
116        let io_err = std::io::Error::from(ErrorKind::BrokenPipe);
117        let response_transfer_err =
118            crate::session::response_transfer::ResponseTransferError::ClientDisconnect(io_err);
119        let e = SessionError::from(response_transfer_err);
120        assert!(matches!(e, SessionError::ClientDisconnect(_)));
121    }
122
123    #[test]
124    fn backend_from_response_transfer_backend_eof() {
125        let response_transfer_err =
126            crate::session::response_transfer::ResponseTransferError::BackendEof {
127                backend_id: crate::types::BackendId::from_index(0),
128                bytes_received: 100,
129            };
130        let e = SessionError::from(response_transfer_err);
131        assert!(matches!(e, SessionError::Backend(_)));
132    }
133}