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::PermissionDenied(_) => ArticleFailureKind::PermissionDenied,
106            NntpError::Connection(_) => ArticleFailureKind::ConnectionClosed,
107            NntpError::Io(_) => ArticleFailureKind::ConnectionClosed,
108            NntpError::Timeout(_) => ArticleFailureKind::Timeout,
109            NntpError::Protocol(_) => ArticleFailureKind::Protocol,
110            NntpError::NoSuchGroup(_) | NntpError::NoArticleSelected(_) => {
111                ArticleFailureKind::Protocol
112            }
113            NntpError::NoConnectionsAvailable(_)
114            | NntpError::AllServersExhausted(_)
115            | NntpError::Tls(_)
116            | NntpError::Shutdown => ArticleFailureKind::Other,
117        };
118        Self {
119            kind,
120            server_id: server_id.into(),
121            message: err.to_string(),
122        }
123    }
124
125    /// Decode or yEnc-assembly failure raised above the NNTP layer.
126    pub fn decode_error(server_id: impl Into<String>, msg: impl Into<String>) -> Self {
127        Self {
128            kind: ArticleFailureKind::DecodeError,
129            server_id: server_id.into(),
130            message: msg.into(),
131        }
132    }
133
134    /// Article is present nowhere — emitted when every enabled server has
135    /// already been tried for this article and the last attempt failed.
136    pub fn not_found_anywhere(
137        server_id: impl Into<String>,
138        provider_outcomes: impl Into<String>,
139    ) -> Self {
140        Self {
141            kind: ArticleFailureKind::NotFound,
142            server_id: server_id.into(),
143            message: format!(
144                "Article explicitly not found on every eligible provider; outcomes: {}",
145                provider_outcomes.into()
146            ),
147        }
148    }
149
150    /// Catch-all classifier for failures the emit site can't precisely type.
151    pub fn other(server_id: impl Into<String>, msg: impl Into<String>) -> Self {
152        Self {
153            kind: ArticleFailureKind::Other,
154            server_id: server_id.into(),
155            message: msg.into(),
156        }
157    }
158}
159
160impl std::fmt::Display for ArticleFailure {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(
163            f,
164            "[{}] {} ({})",
165            self.kind.as_str(),
166            self.message,
167            self.server_id
168        )
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn classify_article_not_found() {
178        let err = NntpError::ArticleNotFound("<msg-1>".into());
179        let f = ArticleFailure::from_nntp(&err, "srv-a");
180        assert_eq!(f.kind, ArticleFailureKind::NotFound);
181        assert_eq!(f.server_id, "srv-a");
182    }
183
184    #[test]
185    fn classify_service_unavailable_as_server_down() {
186        let err = NntpError::ServiceUnavailable("502".into());
187        assert_eq!(
188            ArticleFailure::from_nntp(&err, "srv-a").kind,
189            ArticleFailureKind::ServerDown
190        );
191    }
192
193    #[test]
194    fn classify_auth() {
195        let err = NntpError::Auth("482".into());
196        assert_eq!(
197            ArticleFailure::from_nntp(&err, "srv-a").kind,
198            ArticleFailureKind::AuthFailed
199        );
200    }
201
202    #[test]
203    fn classify_io_as_connection_closed() {
204        let err = NntpError::Io(std::io::Error::other("eof"));
205        assert_eq!(
206            ArticleFailure::from_nntp(&err, "srv-a").kind,
207            ArticleFailureKind::ConnectionClosed
208        );
209    }
210
211    #[test]
212    fn classify_timeout() {
213        let err = NntpError::Timeout("read timeout".into());
214        assert_eq!(
215            ArticleFailure::from_nntp(&err, "srv-a").kind,
216            ArticleFailureKind::Timeout
217        );
218    }
219
220    #[test]
221    fn per_server_classification_is_correct() {
222        assert!(ArticleFailureKind::NotFound.is_per_server());
223        assert!(ArticleFailureKind::ServerDown.is_per_server());
224        assert!(!ArticleFailureKind::DecodeError.is_per_server());
225        assert!(!ArticleFailureKind::Other.is_per_server());
226    }
227
228    #[test]
229    fn counts_toward_hopeless() {
230        assert!(ArticleFailureKind::NotFound.counts_toward_hopeless());
231        assert!(ArticleFailureKind::DecodeError.counts_toward_hopeless());
232        assert!(!ArticleFailureKind::ServerDown.counts_toward_hopeless());
233        assert!(!ArticleFailureKind::AuthFailed.counts_toward_hopeless());
234    }
235}