Skip to main content

link_r/
error.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! Error and result types for `link-r`.
5//!
6//! Follows the house convention: a single `thiserror` enum, a `Result<T, E = Error>`
7//! alias, `#[must_use]` constructor helpers, and an [`Error::is_retriable`] predicate
8//! that drives backoff in the fetch/crawl layer.
9
10/// Result type alias for `link-r` operations.
11pub type Result<T, E = Error> = std::result::Result<T, E>;
12
13/// The unified error type for every `link-r` operation.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// A resource (page, index file) could not be found.
18    #[error("not found: {uri}")]
19    NotFound {
20        /// The URI/path that was missing.
21        uri: String,
22    },
23
24    /// The remote returned 304 Not Modified — used as the unchanged sentinel for
25    /// incremental rebuilds.
26    #[error("not modified: {uri}")]
27    NotModified {
28        /// The URI that was unchanged.
29        uri: String,
30    },
31
32    /// Authentication was required and failed or was missing.
33    #[error("authentication failed: {message}")]
34    Unauthenticated {
35        /// Human-readable detail.
36        message: String,
37    },
38
39    /// The credential was valid but lacked permission.
40    #[error("permission denied: {message}")]
41    PermissionDenied {
42        /// Human-readable detail.
43        message: String,
44    },
45
46    /// The remote rate-limited us; retry after the given delay.
47    #[error("rate limited; retry after {retry_after_ms}ms")]
48    RateLimited {
49        /// Suggested backoff in milliseconds (from `Retry-After` / `x-ratelimit-reset`).
50        retry_after_ms: u64,
51    },
52
53    /// A non-success HTTP status that isn't modelled more specifically.
54    #[error("HTTP {status}: {message}")]
55    Http {
56        /// The HTTP status code.
57        status: u16,
58        /// Human-readable detail.
59        message: String,
60    },
61
62    /// A malformed or unsupported URL.
63    #[error("invalid URL: {message}")]
64    InvalidUrl {
65        /// Human-readable detail.
66        message: String,
67    },
68
69    /// Discovery/crawl failed for a source.
70    #[error("crawl failed [{source_kind}]: {message}")]
71    Crawl {
72        /// The source kind (e.g. `"http"`, `"fs"`).
73        source_kind: &'static str,
74        /// Human-readable detail.
75        message: String,
76    },
77
78    /// Content extraction failed.
79    #[error("extraction failed: {message}")]
80    Extract {
81        /// Human-readable detail.
82        message: String,
83    },
84
85    /// Embedding failed.
86    #[error("embedding failed: {message}")]
87    Embed {
88        /// Human-readable detail.
89        message: String,
90    },
91
92    /// The on-disk index format was invalid or corrupt.
93    #[error("index format error: {message}")]
94    Format {
95        /// Human-readable detail.
96        message: String,
97    },
98
99    /// A query/index dimension mismatch (e.g. searching with the wrong embedder).
100    #[error("dimension mismatch: index={index}, query={query}")]
101    DimMismatch {
102        /// The index's embedding dimension.
103        index: usize,
104        /// The query vector's dimension.
105        query: usize,
106    },
107
108    /// An operation timed out.
109    #[error("operation timed out after {duration_ms}ms")]
110    Timeout {
111        /// The elapsed budget in milliseconds.
112        duration_ms: u64,
113    },
114
115    /// An error originating from a named backend dependency.
116    #[error("{backend} error: {message}")]
117    Backend {
118        /// The backend that produced the error (e.g. `"reqwest"`, `"fastembed"`).
119        backend: &'static str,
120        /// Human-readable detail.
121        message: String,
122    },
123
124    /// An underlying I/O error.
125    #[error("I/O error: {source}")]
126    Io {
127        /// The wrapped I/O error.
128        #[from]
129        source: std::io::Error,
130    },
131}
132
133/// graph-r core (byte codecs, container, mmap) reports malformed images as
134/// format errors; its I/O stays I/O.
135impl From<graph_r::Error> for Error {
136    fn from(e: graph_r::Error) -> Self {
137        match e {
138            graph_r::Error::Io { source } => Self::Io { source },
139            other => Self::format(other.to_string()),
140        }
141    }
142}
143
144impl Error {
145    /// Construct a [`Error::NotFound`].
146    #[must_use]
147    pub fn not_found(uri: impl Into<String>) -> Self {
148        Self::NotFound { uri: uri.into() }
149    }
150
151    /// Construct a [`Error::NotModified`].
152    #[must_use]
153    pub fn not_modified(uri: impl Into<String>) -> Self {
154        Self::NotModified { uri: uri.into() }
155    }
156
157    /// Construct a [`Error::Unauthenticated`].
158    #[must_use]
159    pub fn unauthenticated(message: impl Into<String>) -> Self {
160        Self::Unauthenticated {
161            message: message.into(),
162        }
163    }
164
165    /// Construct a [`Error::PermissionDenied`].
166    #[must_use]
167    pub fn permission_denied(message: impl Into<String>) -> Self {
168        Self::PermissionDenied {
169            message: message.into(),
170        }
171    }
172
173    /// Construct a [`Error::RateLimited`].
174    #[must_use]
175    pub fn rate_limited(retry_after_ms: u64) -> Self {
176        Self::RateLimited { retry_after_ms }
177    }
178
179    /// Construct a [`Error::Http`].
180    #[must_use]
181    pub fn http(status: u16, message: impl Into<String>) -> Self {
182        Self::Http {
183            status,
184            message: message.into(),
185        }
186    }
187
188    /// Construct a [`Error::InvalidUrl`].
189    #[must_use]
190    pub fn invalid_url(message: impl Into<String>) -> Self {
191        Self::InvalidUrl {
192            message: message.into(),
193        }
194    }
195
196    /// Construct a [`Error::Crawl`].
197    #[must_use]
198    pub fn crawl(source_kind: &'static str, message: impl Into<String>) -> Self {
199        Self::Crawl {
200            source_kind,
201            message: message.into(),
202        }
203    }
204
205    /// Construct a [`Error::Extract`].
206    #[must_use]
207    pub fn extract(message: impl Into<String>) -> Self {
208        Self::Extract {
209            message: message.into(),
210        }
211    }
212
213    /// Construct a [`Error::Embed`].
214    #[must_use]
215    pub fn embed(message: impl Into<String>) -> Self {
216        Self::Embed {
217            message: message.into(),
218        }
219    }
220
221    /// Construct a [`Error::Format`].
222    #[must_use]
223    pub fn format(message: impl Into<String>) -> Self {
224        Self::Format {
225            message: message.into(),
226        }
227    }
228
229    /// Construct a [`Error::Backend`].
230    #[must_use]
231    pub fn backend(backend: &'static str, message: impl Into<String>) -> Self {
232        Self::Backend {
233            backend,
234            message: message.into(),
235        }
236    }
237
238    /// Whether retrying the failed operation could plausibly succeed.
239    ///
240    /// Drives backoff in the fetch/crawl layer: timeouts, rate limits, transient
241    /// HTTP 5xx/408/429, and recoverable I/O conditions are retriable; logical
242    /// errors (not found, auth, format) are not.
243    #[must_use]
244    pub fn is_retriable(&self) -> bool {
245        match self {
246            Self::Timeout { .. } | Self::RateLimited { .. } => true,
247            Self::Http { status, .. } => objstore_r::net::is_retriable_status(*status),
248            Self::Io { source } => matches!(
249                source.kind(),
250                std::io::ErrorKind::ConnectionReset
251                    | std::io::ErrorKind::ConnectionAborted
252                    | std::io::ErrorKind::TimedOut
253                    | std::io::ErrorKind::Interrupted
254                    | std::io::ErrorKind::WouldBlock
255            ),
256            _ => false,
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn constructors_build_expected_variants() {
267        assert!(matches!(Error::not_found("x"), Error::NotFound { .. }));
268        assert!(matches!(
269            Error::http(503, "x"),
270            Error::Http { status: 503, .. }
271        ));
272        assert!(matches!(
273            Error::backend("reqwest", "boom"),
274            Error::Backend {
275                backend: "reqwest",
276                ..
277            }
278        ));
279    }
280
281    #[test]
282    fn retriable_classification() {
283        assert!(Error::rate_limited(100).is_retriable());
284        assert!(Error::Timeout { duration_ms: 1 }.is_retriable());
285        assert!(Error::http(503, "").is_retriable());
286        assert!(Error::http(500, "").is_retriable());
287        assert!(!Error::http(404, "").is_retriable());
288        assert!(!Error::not_found("x").is_retriable());
289        assert!(!Error::unauthenticated("x").is_retriable());
290    }
291
292    #[test]
293    fn io_errors_convert_via_from() {
294        let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow");
295        let err: Error = io.into();
296        assert!(err.is_retriable());
297        assert!(matches!(err, Error::Io { .. }));
298    }
299
300    #[test]
301    fn display_is_human_readable() {
302        assert_eq!(
303            Error::not_found("https://x/y").to_string(),
304            "not found: https://x/y"
305        );
306    }
307}