Skip to main content

servo_fetch/
error.rs

1//! Error types.
2
3use std::error::Error as StdError;
4use std::time::Duration;
5
6/// A specialized `Result` type for servo-fetch.
7pub type Result<T> = std::result::Result<T, Error>;
8
9/// Boxed error type used as the `source` of variants that wrap arbitrary errors.
10pub(crate) type BoxError = Box<dyn StdError + Send + Sync + 'static>;
11
12/// Errors from servo-fetch operations.
13#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum Error {
16    /// The URL is malformed or uses a disallowed scheme.
17    #[error("invalid URL '{url}': {reason}")]
18    InvalidUrl {
19        /// The URL that failed validation.
20        url: String,
21        /// Why the URL is invalid.
22        reason: String,
23    },
24
25    /// The page did not finish loading within the configured timeout.
26    #[error("page load timed out after {}s at {url}", timeout.as_secs())]
27    Timeout {
28        /// The URL that timed out.
29        url: String,
30        /// The timeout that was exceeded.
31        timeout: Duration,
32    },
33
34    /// The URL resolves to a private or reserved address (SSRF protection).
35    #[error("address not allowed: {host}")]
36    AddressNotAllowed {
37        /// The blocked host.
38        host: String,
39    },
40
41    /// The Servo engine is unavailable or crashed.
42    #[error("engine error: {source}")]
43    Engine {
44        /// URL being processed, if known.
45        url: Option<String>,
46        /// Source error.
47        #[source]
48        source: BoxError,
49    },
50
51    /// JavaScript evaluation failed.
52    #[error("JavaScript evaluation failed: {source}")]
53    JavaScript {
54        /// URL being processed, if known.
55        url: Option<String>,
56        /// Source error.
57        #[source]
58        source: BoxError,
59    },
60
61    /// Screenshot capture failed.
62    #[error("screenshot capture failed: {source}")]
63    Screenshot {
64        /// URL being captured, if known.
65        url: Option<String>,
66        /// Source error.
67        #[source]
68        source: BoxError,
69    },
70
71    /// Content extraction failed.
72    #[error(transparent)]
73    Extract(#[from] crate::extract::ExtractError),
74
75    /// Failed to load or parse a cookies file.
76    #[error("failed to load cookies from {path}: {reason}")]
77    Cookies {
78        /// The cookies file path.
79        path: String,
80        /// Why loading failed.
81        reason: String,
82    },
83
84    /// Schema-based structured extraction failed.
85    #[error(transparent)]
86    Schema(#[from] crate::schema::SchemaError),
87
88    /// An I/O error occurred.
89    #[error(transparent)]
90    Io(#[from] std::io::Error),
91
92    /// A glob pattern is invalid.
93    #[error(transparent)]
94    InvalidGlob(#[from] globset::Error),
95
96    /// A custom request header is malformed or not permitted.
97    #[error("{0}")]
98    InvalidHeader(String),
99}
100
101impl Error {
102    /// Construct an [`Error::Engine`] from any error type, preserving source chain.
103    pub(crate) fn engine(source: impl Into<BoxError>, url: Option<String>) -> Self {
104        Self::Engine {
105            url,
106            source: source.into(),
107        }
108    }
109
110    /// Construct an [`Error::Screenshot`] from any error type, preserving source chain.
111    pub(crate) fn screenshot(source: impl Into<BoxError>, url: Option<String>) -> Self {
112        Self::Screenshot {
113            url,
114            source: source.into(),
115        }
116    }
117
118    /// Construct an [`Error::JavaScript`] from any error type, preserving source chain.
119    pub(crate) fn javascript(source: impl Into<BoxError>, url: Option<String>) -> Self {
120        Self::JavaScript {
121            url,
122            source: source.into(),
123        }
124    }
125
126    /// Construct an [`Error::InvalidHeader`].
127    pub(crate) fn invalid_header(message: impl Into<String>) -> Self {
128        Self::InvalidHeader(message.into())
129    }
130
131    /// Returns `true` if this is a timeout error.
132    #[must_use]
133    pub fn is_timeout(&self) -> bool {
134        matches!(self, Self::Timeout { .. })
135    }
136
137    /// Returns `true` if this is a network-related error (timeout or address-policy rejection).
138    #[must_use]
139    pub fn is_network(&self) -> bool {
140        matches!(self, Self::Timeout { .. } | Self::AddressNotAllowed { .. })
141    }
142
143    /// Returns the URL associated with this error, if any.
144    #[must_use]
145    pub fn url(&self) -> Option<&str> {
146        match self {
147            Self::InvalidUrl { url, .. } | Self::Timeout { url, .. } => Some(url),
148            Self::Engine { url, .. } | Self::JavaScript { url, .. } | Self::Screenshot { url, .. } => url.as_deref(),
149            _ => None,
150        }
151    }
152
153    /// Returns the host that was rejected, if this is [`Error::AddressNotAllowed`].
154    #[must_use]
155    pub fn host(&self) -> Option<&str> {
156        match self {
157            Self::AddressNotAllowed { host } => Some(host),
158            _ => None,
159        }
160    }
161}
162
163#[derive(Debug)]
164pub(crate) enum UrlError {
165    Invalid(String),
166    PrivateAddress(String),
167}
168
169pub(crate) fn map_url_error(url: &str, e: UrlError) -> Error {
170    match e {
171        UrlError::PrivateAddress(host) => Error::AddressNotAllowed { host },
172        UrlError::Invalid(reason) => Error::InvalidUrl {
173            url: url.into(),
174            reason,
175        },
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn assert_send_sync() {
185        fn check<T: Send + Sync>() {}
186        check::<Error>();
187    }
188
189    #[test]
190    fn timeout_predicates() {
191        let err = Error::Timeout {
192            url: "https://example.com".into(),
193            timeout: Duration::from_secs(30),
194        };
195        assert!(err.is_timeout());
196        assert!(err.is_network());
197        assert_eq!(err.url(), Some("https://example.com"));
198        assert_eq!(err.host(), None);
199    }
200
201    #[test]
202    fn address_not_allowed_predicates() {
203        let err = Error::AddressNotAllowed {
204            host: "127.0.0.1".into(),
205        };
206        assert!(!err.is_timeout());
207        assert!(err.is_network());
208        assert_eq!(err.url(), None);
209        assert_eq!(err.host(), Some("127.0.0.1"));
210    }
211
212    #[test]
213    fn invalid_url_carries_url() {
214        let err = Error::InvalidUrl {
215            url: "bad://url".into(),
216            reason: "scheme not allowed".into(),
217        };
218        assert!(!err.is_network());
219        assert_eq!(err.url(), Some("bad://url"));
220        assert_eq!(err.host(), None);
221    }
222
223    #[test]
224    fn engine_helper_preserves_source_chain() {
225        let inner = std::io::Error::other("disk full");
226        let err = Error::engine(inner, Some("https://example.com".into()));
227        assert_eq!(err.url(), Some("https://example.com"));
228        assert!(err.source().is_some());
229        assert_eq!(err.to_string(), "engine error: disk full");
230    }
231
232    #[test]
233    fn engine_without_url_returns_none() {
234        let err = Error::engine(std::io::Error::other("crash"), None);
235        assert!(err.url().is_none());
236    }
237}