Skip to main content

objectstore_service/
error.rs

1//! Semantic errors for service and backend operations.
2//!
3//! [`Error`] deliberately exposes only a stable semantic [`ErrorKind`]. Human-readable context and
4//! the source chain retain diagnostic detail without making backend implementation details part of
5//! the service API.
6
7use std::any::Any;
8use std::borrow::Cow;
9use std::error::Error as StdError;
10use std::fmt;
11
12use objectstore_log::Level;
13/// A panic captured from a service task.
14#[derive(Debug)]
15pub struct Panic {
16    message: Cow<'static, str>,
17}
18
19impl Panic {
20    /// Extracts a message from a panic payload.
21    pub fn new(payload: Box<dyn Any + Send>) -> Self {
22        let message = if let Some(s) = payload.downcast_ref::<&str>() {
23            Cow::Borrowed(*s)
24        } else if let Some(s) = payload.downcast_ref::<String>() {
25            Cow::Owned(s.clone())
26        } else {
27            Cow::Borrowed("unknown panic")
28        };
29        Self { message }
30    }
31}
32
33impl fmt::Display for Panic {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(&self.message)
36    }
37}
38
39impl StdError for Panic {}
40
41/// The client-visible semantic classification of a service error.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum ErrorKind {
44    /// Object metadata supplied by a client is invalid.
45    InvalidMetadata,
46    /// A multipart upload identifier is invalid.
47    InvalidUploadId,
48    /// A client-provided request stream failed.
49    ClientStream,
50    /// A requested byte range cannot be resolved against the object size.
51    RangeNotSatisfiable {
52        /// Total object length in bytes.
53        total: u64,
54    },
55    /// A resumable chunk starts at a different offset than the backend currently holds.
56    UploadOffsetMismatch {
57        /// The offset the backend currently holds.
58        offset: u64,
59    },
60    /// A resumable upload session expired or was canceled.
61    UploadSessionGone,
62    /// The backend does not recognize a resumable upload session.
63    UnknownUploadSession,
64    /// A resumable chunk would exceed the total upload length.
65    ChunkExceedsUploadLength {
66        /// The offset at which the chunk would be written.
67        offset: u64,
68        /// The declared length of the chunk.
69        content_length: u64,
70        /// The total upload length declared when the session was created.
71        upload_length: u64,
72    },
73    /// The service cannot accept more work.
74    AtCapacity,
75    /// The requested operation is unsupported.
76    Unsupported,
77    /// A storage backend operation failed.
78    BackendFailure,
79    /// A storage backend rejected the operation because it is rate limited.
80    BackendRateLimited,
81    /// A storage backend operation timed out.
82    BackendTimeout,
83    /// A storage backend is temporarily unavailable.
84    BackendUnavailable,
85    /// A service task panicked.
86    Panic,
87    /// A redirect tombstone was encountered by a read that does not support tombstones.
88    UnexpectedTombstone,
89    /// Persisted or remote data is corrupt.
90    CorruptData,
91    /// An unexpected internal service failure occurred.
92    Internal,
93}
94
95impl fmt::Display for ErrorKind {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::InvalidMetadata => f.write_str("invalid object metadata"),
99            Self::InvalidUploadId => f.write_str("invalid upload id"),
100            Self::ClientStream => f.write_str("invalid client stream"),
101            Self::RangeNotSatisfiable { total } => {
102                write!(f, "range not satisfiable (object size: {total} bytes)")
103            }
104            Self::UploadOffsetMismatch { offset } => {
105                write!(f, "upload offset mismatch (server holds {offset} bytes)")
106            }
107            Self::UploadSessionGone => f.write_str("upload session gone"),
108            Self::UnknownUploadSession => f.write_str("unknown upload session"),
109            Self::ChunkExceedsUploadLength {
110                offset,
111                content_length,
112                upload_length,
113            } => write!(
114                f,
115                "chunk at offset {offset} with length {content_length} exceeds upload length {upload_length}"
116            ),
117            Self::AtCapacity => f.write_str("service at capacity"),
118            Self::Unsupported => f.write_str("unsupported operation"),
119            Self::BackendFailure => f.write_str("backend operation failed"),
120            Self::BackendRateLimited => f.write_str("backend rate limited"),
121            Self::BackendTimeout => f.write_str("backend timed out"),
122            Self::BackendUnavailable => f.write_str("backend unavailable"),
123            Self::CorruptData => f.write_str("corrupt stored data"),
124            Self::Panic => f.write_str("service task panicked"),
125            Self::UnexpectedTombstone => f.write_str("unexpected tombstone"),
126            Self::Internal => f.write_str("internal service error"),
127        }
128    }
129}
130
131/// Opaque service error with a stable semantic kind.
132///
133/// Its string representation is the kind followed by `: ` and human-readable context when context
134/// is present. The underlying source is retained separately through [`StdError::source`].
135pub struct Error {
136    kind: ErrorKind,
137    context: Option<Cow<'static, str>>,
138    source: Option<Box<dyn StdError + Send + Sync>>,
139}
140
141impl Error {
142    /// Returns this error's semantic kind.
143    pub fn kind(&self) -> ErrorKind {
144        self.kind
145    }
146
147    /// Creates an error without an underlying source and with human-readable context.
148    pub fn new(kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Self {
149        Self::build(kind, Some(context.into()), None)
150    }
151
152    /// Creates an error with an underlying source.
153    pub fn with_source<E>(kind: ErrorKind, source: E) -> Self
154    where
155        E: StdError + Send + Sync + 'static,
156    {
157        Self::build(kind, None, Some(Box::new(source)))
158    }
159
160    pub(crate) fn with_context<E>(
161        kind: ErrorKind,
162        context: impl Into<Cow<'static, str>>,
163        source: E,
164    ) -> Self
165    where
166        E: StdError + Send + Sync + 'static,
167    {
168        Self::build(kind, Some(context.into()), Some(Box::new(source)))
169    }
170
171    fn build(
172        kind: ErrorKind,
173        context: Option<Cow<'static, str>>,
174        source: Option<Box<dyn StdError + Send + Sync>>,
175    ) -> Self {
176        Self {
177            kind,
178            context,
179            source,
180        }
181    }
182
183    /// Returns the appropriate log level for this error.
184    pub fn level(&self) -> Level {
185        match self.kind {
186            // Malformed client input at DEBUG level
187            ErrorKind::InvalidMetadata => Level::DEBUG,
188            ErrorKind::InvalidUploadId => Level::DEBUG,
189            ErrorKind::ClientStream => Level::DEBUG,
190            ErrorKind::RangeNotSatisfiable { .. } => Level::DEBUG,
191            ErrorKind::UploadOffsetMismatch { .. } => Level::DEBUG,
192            ErrorKind::UploadSessionGone => Level::DEBUG,
193            ErrorKind::UnknownUploadSession => Level::DEBUG,
194            ErrorKind::ChunkExceedsUploadLength { .. } => Level::DEBUG,
195            // Indicates that optional functionality is not supported.
196            // We don't want a rogue client spamming us with Sentry errors just by calling an API
197            // that the server doesn't support, so we just log it.
198            ErrorKind::Unsupported => Level::INFO,
199            // Capacity, rate-limit, and transient backend errors are warnings.
200            ErrorKind::AtCapacity => Level::WARN,
201            ErrorKind::BackendRateLimited => Level::WARN,
202            ErrorKind::BackendTimeout => Level::WARN,
203            ErrorKind::BackendUnavailable => Level::WARN,
204            // All other errors are service or backend failures. These become Sentry errors.
205            ErrorKind::BackendFailure => Level::ERROR,
206            ErrorKind::Panic => Level::ERROR,
207            ErrorKind::UnexpectedTombstone => Level::ERROR,
208            ErrorKind::CorruptData => Level::ERROR,
209            ErrorKind::Internal => Level::ERROR,
210        }
211    }
212}
213
214impl fmt::Display for Error {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        self.kind.fmt(f)?;
217        if let Some(context) = &self.context {
218            write!(f, ": {context}")?;
219        }
220        Ok(())
221    }
222}
223
224impl fmt::Debug for Error {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.debug_struct("Error")
227            .field("kind", &self.kind)
228            .field("context", &self.context)
229            .field("source", &self.source)
230            .finish()
231    }
232}
233
234impl StdError for Error {
235    fn source(&self) -> Option<&(dyn StdError + 'static)> {
236        self.source.as_deref().map(|source| source as _)
237    }
238}
239
240impl From<ErrorKind> for Error {
241    fn from(kind: ErrorKind) -> Self {
242        Self::build(kind, None, None)
243    }
244}
245
246impl From<Panic> for Error {
247    fn from(source: Panic) -> Self {
248        Self::with_source(ErrorKind::Panic, source)
249    }
250}
251
252/// Adds a semantic kind and optional context when converting an external error.
253pub trait ResultExt<T> {
254    /// Converts an external error into a service error with `kind` and human-readable context.
255    ///
256    /// The source error is retained, while the rendered service error contains the semantic kind
257    /// and context.
258    ///
259    /// ```
260    /// use objectstore_service::error::{ErrorKind, ResultExt as _};
261    ///
262    /// let result = std::fs::read("missing")
263    ///     .context(ErrorKind::BackendFailure, "reading local object");
264    /// let error = result.unwrap_err();
265    /// assert_eq!(
266    ///     error.to_string(),
267    ///     "backend operation failed: reading local object"
268    /// );
269    /// ```
270    fn context(self, kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Result<T>;
271
272    /// Converts an external error into a service error with only `kind`.
273    ///
274    /// Use this when the source already identifies the failure or when the operation is expected to
275    /// be infallible. The source error is still retained.
276    ///
277    /// ```
278    /// use objectstore_service::error::{ErrorKind, ResultExt as _};
279    ///
280    /// let result = "invalid".parse::<u64>().kind(ErrorKind::InvalidMetadata);
281    /// assert_eq!(result.unwrap_err().to_string(), "invalid object metadata");
282    /// ```
283    fn kind(self, kind: ErrorKind) -> Result<T>;
284}
285
286impl<T, E> ResultExt<T> for std::result::Result<T, E>
287where
288    E: StdError + Send + Sync + 'static,
289{
290    fn context(self, kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Result<T> {
291        self.map_err(|source| Error::with_context(kind, context, source))
292    }
293
294    fn kind(self, kind: ErrorKind) -> Result<T> {
295        self.map_err(|source| Error::with_source(kind, source))
296    }
297}
298
299impl From<std::io::Error> for Error {
300    fn from(source: std::io::Error) -> Self {
301        Self::with_source(ErrorKind::BackendFailure, source)
302    }
303}
304
305impl From<crate::stream::ClientError> for Error {
306    fn from(source: crate::stream::ClientError) -> Self {
307        Self::with_source(ErrorKind::ClientStream, source)
308    }
309}
310
311impl From<objectstore_types::multipart::InvalidUploadId> for Error {
312    fn from(source: objectstore_types::multipart::InvalidUploadId) -> Self {
313        Self::with_source(ErrorKind::InvalidUploadId, source)
314    }
315}
316
317/// Result type for service operations.
318pub type Result<T, E = Error> = std::result::Result<T, E>;
319
320#[cfg(test)]
321mod tests {
322    use std::error::Error as _;
323    use std::io;
324
325    use super::{Error, ErrorKind, Panic};
326
327    #[test]
328    fn opaque_error_preserves_source() {
329        let error = Error::with_source(ErrorKind::BackendFailure, io::Error::other("backend down"));
330        let standard_error: &dyn std::error::Error = &error;
331
332        assert_eq!(error.kind(), ErrorKind::BackendFailure);
333        assert_eq!(standard_error.source().unwrap().to_string(), "backend down");
334    }
335
336    #[test]
337    fn context_renders_after_kind() {
338        let error = Error::with_context(
339            ErrorKind::BackendFailure,
340            "reading local object",
341            io::Error::other("backend down"),
342        );
343
344        assert_eq!(
345            error.to_string(),
346            "backend operation failed: reading local object"
347        );
348    }
349
350    #[test]
351    fn error_kind_default_message_includes_range_size() {
352        let error: Error = ErrorKind::RangeNotSatisfiable { total: 42 }.into();
353
354        assert_eq!(
355            error.to_string(),
356            "range not satisfiable (object size: 42 bytes)"
357        );
358    }
359
360    #[test]
361    fn panic_uses_the_payload_message() {
362        let panic = Panic::new(Box::new("task panicked"));
363        let error: Error = panic.into();
364
365        assert_eq!(error.kind(), ErrorKind::Panic);
366        assert_eq!(error.to_string(), "service task panicked");
367        assert_eq!(error.source().unwrap().to_string(), "task panicked");
368    }
369}