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