Skip to main content

nzb_dispatch/
article_failure.rs

1//! Typed failure taxonomy for article downloads.
2//!
3//! Replaces the previous `String`-typed `ProgressUpdate::ArticleFailed { error }`.
4//! Carrying a structured `ArticleFailureKind` lets the queue manager, hopeless
5//! tracker, and circuit breaker each react to *what* failed without parsing
6//! free-form messages — and lets future per-server retry policy be expressed
7//! in code rather than in regex.
8//!
9//! Classification happens at the *emit site* (the worker that observed the
10//! failure), where the original `NntpError` is still typed. By the time the
11//! failure crosses the progress channel, it has been reduced to one of the
12//! kinds below plus an opaque `message` for human-readable logs.
13
14use nzb_nntp::error::NntpError;
15
16/// Why an article failed. Drives retry decisions, hopeless tracking, and
17/// circuit-breaker logic.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum ArticleFailureKind {
20    /// `430` — article not present on this server. Could be retention drift
21    /// or never posted; try other servers.
22    NotFound,
23    /// `50x` or service unavailable — transient server-side issue. Try
24    /// another server; circuit-break this one if it persists.
25    ServerDown,
26    /// `481` / `482` — authentication failed for this account on this server.
27    /// Don't keep retrying with the same credentials.
28    AuthFailed,
29    /// `403` / explicit forbidden — permanent rejection for this account on
30    /// this server. Functionally identical to AuthFailed for retry purposes
31    /// but distinguished for diagnostics.
32    PermissionDenied,
33    /// yEnc decode failure or file-assembly mismatch. Could be corruption on
34    /// this server (try another) or genuine corruption on every server.
35    DecodeError,
36    /// Read/write timeout. Transient — retry on the same or another server.
37    Timeout,
38    /// TCP socket closed mid-transfer (RST, EOF, etc.).
39    ConnectionClosed,
40    /// NNTP protocol violation or unexpected response shape.
41    Protocol,
42    /// Catch-all when classification isn't possible at the emit site.
43    Other,
44}
45
46impl ArticleFailureKind {
47    /// True if this failure is specific to the server that produced it —
48    /// the article may still be obtainable from another provider.
49    pub fn is_per_server(self) -> bool {
50        matches!(
51            self,
52            Self::NotFound
53                | Self::ServerDown
54                | Self::AuthFailed
55                | Self::PermissionDenied
56                | Self::Timeout
57                | Self::ConnectionClosed
58                | Self::Protocol
59        )
60    }
61
62    /// True if the failure suggests the article is gone everywhere once
63    /// every server has been tried (hopeless-tracker should count it).
64    pub fn counts_toward_hopeless(self) -> bool {
65        matches!(self, Self::NotFound | Self::DecodeError)
66    }
67
68    /// True if this server is unlikely to recover within the lifetime of
69    /// the current download — circuit-breaker hint.
70    pub fn should_break_server(self) -> bool {
71        matches!(self, Self::AuthFailed | Self::PermissionDenied)
72    }
73
74    /// Short stable identifier suitable for logs/metrics labels.
75    pub fn as_str(self) -> &'static str {
76        match self {
77            Self::NotFound => "not_found",
78            Self::ServerDown => "server_down",
79            Self::AuthFailed => "auth_failed",
80            Self::PermissionDenied => "permission_denied",
81            Self::DecodeError => "decode_error",
82            Self::Timeout => "timeout",
83            Self::ConnectionClosed => "connection_closed",
84            Self::Protocol => "protocol",
85            Self::Other => "other",
86        }
87    }
88}
89
90/// A classified article failure ready to flow through the progress channel.
91#[derive(Debug, Clone)]
92pub struct ArticleFailure {
93    pub kind: ArticleFailureKind,
94    pub server_id: String,
95    pub message: String,
96}
97
98impl ArticleFailure {
99    /// Classify an `NntpError` observed by a worker fetching an article.
100    pub fn from_nntp(err: &NntpError, server_id: impl Into<String>) -> Self {
101        let kind = match err {
102            NntpError::ArticleNotFound(_) => ArticleFailureKind::NotFound,
103            NntpError::ServiceUnavailable(_) => ArticleFailureKind::ServerDown,
104            NntpError::Auth(_) | NntpError::AuthRequired(_) => ArticleFailureKind::AuthFailed,
105            NntpError::Connection(_) => ArticleFailureKind::ConnectionClosed,
106            NntpError::Io(_) => ArticleFailureKind::ConnectionClosed,
107            NntpError::Timeout(_) => ArticleFailureKind::Timeout,
108            NntpError::Protocol(_) => ArticleFailureKind::Protocol,
109            NntpError::NoSuchGroup(_) | NntpError::NoArticleSelected(_) => {
110                ArticleFailureKind::Protocol
111            }
112            NntpError::NoConnectionsAvailable(_)
113            | NntpError::AllServersExhausted(_)
114            | NntpError::Tls(_)
115            | NntpError::Shutdown => ArticleFailureKind::Other,
116        };
117        Self {
118            kind,
119            server_id: server_id.into(),
120            message: err.to_string(),
121        }
122    }
123
124    /// Decode or yEnc-assembly failure raised above the NNTP layer.
125    pub fn decode_error(server_id: impl Into<String>, msg: impl Into<String>) -> Self {
126        Self {
127            kind: ArticleFailureKind::DecodeError,
128            server_id: server_id.into(),
129            message: msg.into(),
130        }
131    }
132
133    /// Article is present nowhere — emitted when every enabled server has
134    /// already been tried for this article and the last attempt failed.
135    pub fn not_found_anywhere(server_id: impl Into<String>) -> Self {
136        Self {
137            kind: ArticleFailureKind::NotFound,
138            server_id: server_id.into(),
139            message: "Article not found on any server".to_string(),
140        }
141    }
142
143    /// Catch-all classifier for failures the emit site can't precisely type.
144    pub fn other(server_id: impl Into<String>, msg: impl Into<String>) -> Self {
145        Self {
146            kind: ArticleFailureKind::Other,
147            server_id: server_id.into(),
148            message: msg.into(),
149        }
150    }
151}
152
153impl std::fmt::Display for ArticleFailure {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(
156            f,
157            "[{}] {} ({})",
158            self.kind.as_str(),
159            self.message,
160            self.server_id
161        )
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn classify_article_not_found() {
171        let err = NntpError::ArticleNotFound("<msg-1>".into());
172        let f = ArticleFailure::from_nntp(&err, "srv-a");
173        assert_eq!(f.kind, ArticleFailureKind::NotFound);
174        assert_eq!(f.server_id, "srv-a");
175    }
176
177    #[test]
178    fn classify_service_unavailable_as_server_down() {
179        let err = NntpError::ServiceUnavailable("502".into());
180        assert_eq!(
181            ArticleFailure::from_nntp(&err, "srv-a").kind,
182            ArticleFailureKind::ServerDown
183        );
184    }
185
186    #[test]
187    fn classify_auth() {
188        let err = NntpError::Auth("482".into());
189        assert_eq!(
190            ArticleFailure::from_nntp(&err, "srv-a").kind,
191            ArticleFailureKind::AuthFailed
192        );
193    }
194
195    #[test]
196    fn classify_io_as_connection_closed() {
197        let err = NntpError::Io(std::io::Error::other("eof"));
198        assert_eq!(
199            ArticleFailure::from_nntp(&err, "srv-a").kind,
200            ArticleFailureKind::ConnectionClosed
201        );
202    }
203
204    #[test]
205    fn classify_timeout() {
206        let err = NntpError::Timeout("read timeout".into());
207        assert_eq!(
208            ArticleFailure::from_nntp(&err, "srv-a").kind,
209            ArticleFailureKind::Timeout
210        );
211    }
212
213    #[test]
214    fn per_server_classification_is_correct() {
215        assert!(ArticleFailureKind::NotFound.is_per_server());
216        assert!(ArticleFailureKind::ServerDown.is_per_server());
217        assert!(!ArticleFailureKind::DecodeError.is_per_server());
218        assert!(!ArticleFailureKind::Other.is_per_server());
219    }
220
221    #[test]
222    fn counts_toward_hopeless() {
223        assert!(ArticleFailureKind::NotFound.counts_toward_hopeless());
224        assert!(ArticleFailureKind::DecodeError.counts_toward_hopeless());
225        assert!(!ArticleFailureKind::ServerDown.counts_toward_hopeless());
226        assert!(!ArticleFailureKind::AuthFailed.counts_toward_hopeless());
227    }
228}