Skip to main content

gosh_dl/
error.rs

1//! Typed error hierarchy for gosh-dl
2//!
3//! [`EngineError`] is the primary error type returned by all public API methods.
4//! It includes rich context (error kinds, retryability) for Rust consumers to
5//! pattern-match on.
6//!
7//! For serialization boundaries (JSON-RPC, gRPC, IPC), convert to
8//! [`crate::ProtocolError`] via the `From` impl — it carries only
9//! string messages and is `Serialize`/`Deserialize`.
10//!
11//! # Error handling example
12//!
13//! ```rust,no_run
14//! use gosh_dl::{EngineError, NetworkErrorKind};
15//!
16//! fn handle_error(err: EngineError) {
17//!     if err.is_retryable() {
18//!         eprintln!("Retryable error: {err}");
19//!     } else if err.is_not_found() {
20//!         eprintln!("Download not found");
21//!     } else if let EngineError::Network { kind, .. } = &err {
22//!         match kind {
23//!             NetworkErrorKind::Timeout => eprintln!("Timed out"),
24//!             _ => eprintln!("Network error: {err}"),
25//!         }
26//!     }
27//! }
28//! ```
29
30use std::path::PathBuf;
31use thiserror::Error;
32
33/// Main error type for the download engine
34#[derive(Debug, Error)]
35pub enum EngineError {
36    /// Network-related errors (connection, timeout, DNS, etc.)
37    #[error("Network error: {message}")]
38    Network {
39        kind: NetworkErrorKind,
40        message: String,
41        retryable: bool,
42    },
43
44    /// Storage/filesystem errors
45    #[error("Storage error at {path:?}: {message}")]
46    Storage {
47        kind: StorageErrorKind,
48        path: PathBuf,
49        message: String,
50    },
51
52    /// Protocol-level errors (HTTP, BitTorrent)
53    #[error("Protocol error: {message}")]
54    Protocol {
55        kind: ProtocolErrorKind,
56        message: String,
57    },
58
59    /// Invalid input from user
60    #[error("Invalid input for '{field}': {message}")]
61    InvalidInput {
62        field: &'static str,
63        message: String,
64    },
65
66    /// Resource limits exceeded
67    #[error("Resource limit exceeded: {resource} (limit: {limit})")]
68    ResourceLimit {
69        resource: &'static str,
70        limit: usize,
71    },
72
73    /// Download not found
74    #[error("Download not found: {0}")]
75    NotFound(String),
76
77    /// Download already exists
78    #[error("Download already exists: {0}")]
79    AlreadyExists(String),
80
81    /// Invalid state transition
82    #[error("Invalid state: cannot {action} while {current_state}")]
83    InvalidState {
84        action: &'static str,
85        current_state: String,
86    },
87
88    /// Engine is shutting down
89    #[error("Engine is shutting down")]
90    Shutdown,
91
92    /// Database error
93    #[error("Database error: {0}")]
94    Database(String),
95
96    /// Internal error (bug)
97    #[error("Internal error: {0}")]
98    Internal(String),
99}
100
101/// Network error subtypes
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum NetworkErrorKind {
104    /// DNS resolution failed
105    DnsResolution,
106    /// Connection refused
107    ConnectionRefused,
108    /// Connection reset
109    ConnectionReset,
110    /// Connection timeout
111    Timeout,
112    /// TLS/SSL error
113    Tls,
114    /// Server returned error status
115    HttpStatus(u16),
116    /// Server not reachable
117    Unreachable,
118    /// Too many redirects
119    TooManyRedirects,
120    /// Other network error
121    Other,
122}
123
124/// Storage error subtypes
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum StorageErrorKind {
127    /// File/directory not found
128    NotFound,
129    /// Permission denied
130    PermissionDenied,
131    /// Disk full
132    DiskFull,
133    /// Path is outside allowed directory (security)
134    PathTraversal,
135    /// File already exists
136    AlreadyExists,
137    /// Invalid path
138    InvalidPath,
139    /// I/O error
140    Io,
141}
142
143/// Protocol error subtypes
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum ProtocolErrorKind {
146    /// Invalid URL
147    InvalidUrl,
148    /// Server doesn't support range requests
149    RangeNotSupported,
150    /// Invalid HTTP response
151    InvalidResponse,
152    /// Invalid torrent file
153    InvalidTorrent,
154    /// Invalid magnet URI
155    InvalidMagnet,
156    /// Piece hash verification failed
157    HashMismatch,
158    /// Tracker error
159    TrackerError,
160    /// Peer protocol violation
161    PeerProtocol,
162    /// Bencode parsing error
163    BencodeParse,
164    /// Peer Exchange (PEX) error
165    PexError,
166    /// DHT error
167    DhtError,
168    /// Local Peer Discovery error
169    LpdError,
170    /// Metadata fetch error (BEP 9)
171    MetadataError,
172}
173
174impl EngineError {
175    /// Check if this error is retryable
176    pub fn is_retryable(&self) -> bool {
177        match self {
178            Self::Network { retryable, .. } => *retryable,
179            Self::Storage { kind, .. } => matches!(kind, StorageErrorKind::Io),
180            Self::Protocol { kind, .. } => matches!(
181                kind,
182                ProtocolErrorKind::TrackerError | ProtocolErrorKind::PeerProtocol
183            ),
184            _ => false,
185        }
186    }
187
188    /// Create a network error
189    pub fn network(kind: NetworkErrorKind, message: impl Into<String>) -> Self {
190        let retryable = matches!(
191            kind,
192            NetworkErrorKind::Timeout
193                | NetworkErrorKind::ConnectionReset
194                | NetworkErrorKind::Unreachable
195                | NetworkErrorKind::HttpStatus(408)
196                | NetworkErrorKind::HttpStatus(429)
197                | NetworkErrorKind::HttpStatus(500..=599)
198        );
199        Self::Network {
200            kind,
201            message: message.into(),
202            retryable,
203        }
204    }
205
206    /// Create a storage error
207    pub fn storage(
208        kind: StorageErrorKind,
209        path: impl Into<PathBuf>,
210        message: impl Into<String>,
211    ) -> Self {
212        Self::Storage {
213            kind,
214            path: path.into(),
215            message: message.into(),
216        }
217    }
218
219    /// Create a protocol error
220    pub fn protocol(kind: ProtocolErrorKind, message: impl Into<String>) -> Self {
221        Self::Protocol {
222            kind,
223            message: message.into(),
224        }
225    }
226
227    /// Create an invalid input error
228    pub fn invalid_input(field: &'static str, message: impl Into<String>) -> Self {
229        Self::InvalidInput {
230            field,
231            message: message.into(),
232        }
233    }
234
235    /// Check if this is a "not found" error
236    pub fn is_not_found(&self) -> bool {
237        matches!(self, Self::NotFound(_))
238    }
239
240    /// Check if this is a network error
241    pub fn is_network(&self) -> bool {
242        matches!(self, Self::Network { .. })
243    }
244
245    /// Check if this is a shutdown error
246    pub fn is_shutdown(&self) -> bool {
247        matches!(self, Self::Shutdown)
248    }
249}
250
251/// Result type alias for engine operations
252pub type Result<T> = std::result::Result<T, EngineError>;
253
254// Implement From traits for common error types
255
256impl From<std::io::Error> for EngineError {
257    fn from(err: std::io::Error) -> Self {
258        use std::io::ErrorKind;
259        let kind = match err.kind() {
260            ErrorKind::NotFound => StorageErrorKind::NotFound,
261            ErrorKind::PermissionDenied => StorageErrorKind::PermissionDenied,
262            ErrorKind::AlreadyExists => StorageErrorKind::AlreadyExists,
263            _ => StorageErrorKind::Io,
264        };
265        Self::Storage {
266            kind,
267            path: PathBuf::new(),
268            message: err.to_string(),
269        }
270    }
271}
272
273#[cfg(any(feature = "http", feature = "torrent"))]
274impl From<reqwest::Error> for EngineError {
275    fn from(err: reqwest::Error) -> Self {
276        let kind = if err.is_timeout() {
277            NetworkErrorKind::Timeout
278        } else if err.is_connect() {
279            NetworkErrorKind::ConnectionRefused
280        } else if err.is_redirect() {
281            NetworkErrorKind::TooManyRedirects
282        } else if err.is_body() || err.is_decode() {
283            // Stream interrupted during body transfer — retryable
284            NetworkErrorKind::ConnectionReset
285        } else if let Some(status) = err.status() {
286            NetworkErrorKind::HttpStatus(status.as_u16())
287        } else {
288            NetworkErrorKind::Other
289        };
290
291        // Use the standard constructor so retryability is computed consistently
292        Self::network(kind, err.to_string())
293    }
294}
295
296impl From<url::ParseError> for EngineError {
297    fn from(err: url::ParseError) -> Self {
298        Self::Protocol {
299            kind: ProtocolErrorKind::InvalidUrl,
300            message: err.to_string(),
301        }
302    }
303}
304
305#[cfg(feature = "storage")]
306impl From<rusqlite::Error> for EngineError {
307    fn from(err: rusqlite::Error) -> Self {
308        Self::Database(err.to_string())
309    }
310}
311
312impl From<serde_json::Error> for EngineError {
313    fn from(err: serde_json::Error) -> Self {
314        Self::Internal(format!("JSON error: {}", err))
315    }
316}
317
318impl From<tokio::sync::broadcast::error::SendError<crate::protocol::DownloadEvent>>
319    for EngineError
320{
321    fn from(_: tokio::sync::broadcast::error::SendError<crate::protocol::DownloadEvent>) -> Self {
322        Self::Shutdown
323    }
324}
325
326// Conversion from EngineError to ProtocolError for the public API boundary
327impl From<EngineError> for crate::protocol::ProtocolError {
328    fn from(e: EngineError) -> Self {
329        use crate::protocol::ProtocolError;
330        match e {
331            EngineError::NotFound(id) => ProtocolError::NotFound { id },
332            EngineError::InvalidState {
333                action,
334                current_state,
335            } => ProtocolError::InvalidState {
336                action: action.to_string(),
337                current_state,
338            },
339            EngineError::InvalidInput { field, message } => ProtocolError::InvalidInput {
340                field: field.to_string(),
341                message,
342            },
343            EngineError::Network {
344                message, retryable, ..
345            } => ProtocolError::Network { message, retryable },
346            EngineError::Storage { message, .. } => ProtocolError::Storage { message },
347            EngineError::Protocol { message, .. } => ProtocolError::Network {
348                message,
349                retryable: false,
350            },
351            EngineError::Shutdown => ProtocolError::Shutdown,
352            EngineError::AlreadyExists(id) => ProtocolError::InvalidInput {
353                field: "id".to_string(),
354                message: format!("Download already exists: {}", id),
355            },
356            EngineError::ResourceLimit { resource, limit } => ProtocolError::InvalidInput {
357                field: resource.to_string(),
358                message: format!("Resource limit exceeded (limit: {})", limit),
359            },
360            EngineError::Database(msg) => ProtocolError::Storage { message: msg },
361            EngineError::Internal(msg) => ProtocolError::Internal { message: msg },
362        }
363    }
364}