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    /// A non-final resumable chunk is shorter than the upload granularity.
74    ChunkTooSmall {
75        /// The declared length of the chunk.
76        chunk_length: u64,
77        /// The upload granularity in bytes.
78        upload_granularity: u64,
79    },
80    /// The service cannot accept more work.
81    AtCapacity,
82    /// The requested operation is unsupported.
83    Unsupported,
84    /// A storage backend operation failed.
85    BackendFailure,
86    /// A storage backend rejected the operation because it is rate limited.
87    BackendRateLimited,
88    /// A storage backend operation timed out.
89    BackendTimeout,
90    /// A storage backend is temporarily unavailable.
91    BackendUnavailable,
92    /// A service task panicked.
93    Panic,
94    /// A redirect tombstone was encountered by a read that does not support tombstones.
95    UnexpectedTombstone,
96    /// Persisted or remote data is corrupt.
97    CorruptData,
98    /// An unexpected internal service failure occurred.
99    Internal,
100}
101
102impl fmt::Display for ErrorKind {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::InvalidMetadata => f.write_str("invalid object metadata"),
106            Self::InvalidUploadId => f.write_str("invalid upload id"),
107            Self::ClientStream => f.write_str("invalid client stream"),
108            Self::RangeNotSatisfiable { total } => {
109                write!(f, "range not satisfiable (object size: {total} bytes)")
110            }
111            Self::UploadOffsetMismatch { offset } => {
112                write!(f, "upload offset mismatch (server holds {offset} bytes)")
113            }
114            Self::UploadSessionGone => f.write_str("upload session gone"),
115            Self::UnknownUploadSession => f.write_str("unknown upload session"),
116            Self::ChunkExceedsUploadLength {
117                offset,
118                content_length,
119                upload_length,
120            } => write!(
121                f,
122                "chunk at offset {offset} with length {content_length} exceeds upload length {upload_length}"
123            ),
124            Self::ChunkTooSmall {
125                chunk_length,
126                upload_granularity,
127            } => write!(
128                f,
129                "non-final chunk length {chunk_length} is smaller than upload granularity {upload_granularity}"
130            ),
131            Self::AtCapacity => f.write_str("service at capacity"),
132            Self::Unsupported => f.write_str("unsupported operation"),
133            Self::BackendFailure => f.write_str("backend operation failed"),
134            Self::BackendRateLimited => f.write_str("backend rate limited"),
135            Self::BackendTimeout => f.write_str("backend timed out"),
136            Self::BackendUnavailable => f.write_str("backend unavailable"),
137            Self::CorruptData => f.write_str("corrupt stored data"),
138            Self::Panic => f.write_str("service task panicked"),
139            Self::UnexpectedTombstone => f.write_str("unexpected tombstone"),
140            Self::Internal => f.write_str("internal service error"),
141        }
142    }
143}
144
145/// Opaque service error with a stable semantic kind.
146///
147/// Its string representation is the kind followed by `: ` and human-readable context when context
148/// is present. The underlying source is retained separately through [`StdError::source`].
149pub struct Error {
150    kind: ErrorKind,
151    context: Option<Cow<'static, str>>,
152    source: Option<Box<dyn StdError + Send + Sync>>,
153}
154
155impl Error {
156    /// Returns this error's semantic kind.
157    pub fn kind(&self) -> ErrorKind {
158        self.kind
159    }
160
161    /// Creates an error without an underlying source and with human-readable context.
162    pub fn new(kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Self {
163        Self::build(kind, Some(context.into()), None)
164    }
165
166    /// Creates an error with an underlying source.
167    pub fn with_source<E>(kind: ErrorKind, source: E) -> Self
168    where
169        E: StdError + Send + Sync + 'static,
170    {
171        Self::build(kind, None, Some(Box::new(source)))
172    }
173
174    pub(crate) fn with_context<E>(
175        kind: ErrorKind,
176        context: impl Into<Cow<'static, str>>,
177        source: E,
178    ) -> Self
179    where
180        E: StdError + Send + Sync + 'static,
181    {
182        Self::build(kind, Some(context.into()), Some(Box::new(source)))
183    }
184
185    fn build(
186        kind: ErrorKind,
187        context: Option<Cow<'static, str>>,
188        source: Option<Box<dyn StdError + Send + Sync>>,
189    ) -> Self {
190        Self {
191            kind,
192            context,
193            source,
194        }
195    }
196
197    /// Returns the appropriate log level for this error.
198    pub fn level(&self) -> Level {
199        match self.kind {
200            // Malformed client input at DEBUG level
201            ErrorKind::InvalidMetadata => Level::DEBUG,
202            ErrorKind::InvalidUploadId => Level::DEBUG,
203            ErrorKind::ClientStream => Level::DEBUG,
204            ErrorKind::RangeNotSatisfiable { .. } => Level::DEBUG,
205            ErrorKind::UploadOffsetMismatch { .. } => Level::DEBUG,
206            ErrorKind::UploadSessionGone => Level::DEBUG,
207            ErrorKind::UnknownUploadSession => Level::DEBUG,
208            ErrorKind::ChunkExceedsUploadLength { .. } => Level::DEBUG,
209            ErrorKind::ChunkTooSmall { .. } => Level::DEBUG,
210            // Indicates that optional functionality is not supported.
211            // We don't want a rogue client spamming us with Sentry errors just by calling an API
212            // that the server doesn't support, so we just log it.
213            ErrorKind::Unsupported => Level::INFO,
214            // Capacity, rate-limit, and transient backend errors are warnings.
215            ErrorKind::AtCapacity => Level::WARN,
216            ErrorKind::BackendRateLimited => Level::WARN,
217            ErrorKind::BackendTimeout => Level::WARN,
218            ErrorKind::BackendUnavailable => Level::WARN,
219            // All other errors are service or backend failures. These become Sentry errors.
220            ErrorKind::BackendFailure => Level::ERROR,
221            ErrorKind::Panic => Level::ERROR,
222            ErrorKind::UnexpectedTombstone => Level::ERROR,
223            ErrorKind::CorruptData => Level::ERROR,
224            ErrorKind::Internal => Level::ERROR,
225        }
226    }
227}
228
229impl fmt::Display for Error {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        self.kind.fmt(f)?;
232        if let Some(context) = &self.context {
233            write!(f, ": {context}")?;
234        }
235        Ok(())
236    }
237}
238
239impl fmt::Debug for Error {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        f.debug_struct("Error")
242            .field("kind", &self.kind)
243            .field("context", &self.context)
244            .field("source", &self.source)
245            .finish()
246    }
247}
248
249impl StdError for Error {
250    fn source(&self) -> Option<&(dyn StdError + 'static)> {
251        self.source.as_deref().map(|source| source as _)
252    }
253}
254
255impl From<ErrorKind> for Error {
256    fn from(kind: ErrorKind) -> Self {
257        Self::build(kind, None, None)
258    }
259}
260
261impl From<Panic> for Error {
262    fn from(source: Panic) -> Self {
263        Self::with_source(ErrorKind::Panic, source)
264    }
265}
266
267/// Adds a semantic kind and optional context when converting an external error.
268pub trait ResultExt<T> {
269    /// Converts an external error into a service error with `kind` and human-readable context.
270    ///
271    /// The source error is retained, while the rendered service error contains the semantic kind
272    /// and context.
273    ///
274    /// ```
275    /// use objectstore_service::error::{ErrorKind, ResultExt as _};
276    ///
277    /// let result = std::fs::read("missing")
278    ///     .context(ErrorKind::BackendFailure, "reading local object");
279    /// let error = result.unwrap_err();
280    /// assert_eq!(
281    ///     error.to_string(),
282    ///     "backend operation failed: reading local object"
283    /// );
284    /// ```
285    fn context(self, kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Result<T>;
286
287    /// Converts an external error into a service error with only `kind`.
288    ///
289    /// Use this when the source already identifies the failure or when the operation is expected to
290    /// be infallible. The source error is still retained.
291    ///
292    /// ```
293    /// use objectstore_service::error::{ErrorKind, ResultExt as _};
294    ///
295    /// let result = "invalid".parse::<u64>().kind(ErrorKind::InvalidMetadata);
296    /// assert_eq!(result.unwrap_err().to_string(), "invalid object metadata");
297    /// ```
298    fn kind(self, kind: ErrorKind) -> Result<T>;
299}
300
301impl<T, E> ResultExt<T> for std::result::Result<T, E>
302where
303    E: StdError + Send + Sync + 'static,
304{
305    fn context(self, kind: ErrorKind, context: impl Into<Cow<'static, str>>) -> Result<T> {
306        self.map_err(|source| Error::with_context(kind, context, source))
307    }
308
309    fn kind(self, kind: ErrorKind) -> Result<T> {
310        self.map_err(|source| Error::with_source(kind, source))
311    }
312}
313
314impl From<std::io::Error> for Error {
315    fn from(source: std::io::Error) -> Self {
316        Self::with_source(ErrorKind::BackendFailure, source)
317    }
318}
319
320impl From<crate::stream::ClientError> for Error {
321    fn from(source: crate::stream::ClientError) -> Self {
322        Self::with_source(ErrorKind::ClientStream, source)
323    }
324}
325
326impl From<objectstore_types::multipart::InvalidUploadId> for Error {
327    fn from(source: objectstore_types::multipart::InvalidUploadId) -> Self {
328        Self::with_source(ErrorKind::InvalidUploadId, source)
329    }
330}
331
332/// Result type for service operations.
333pub type Result<T, E = Error> = std::result::Result<T, E>;
334
335#[cfg(test)]
336mod tests {
337    use std::error::Error as _;
338    use std::io;
339
340    use super::{Error, ErrorKind, Panic};
341
342    #[test]
343    fn opaque_error_preserves_source() {
344        let error = Error::with_source(ErrorKind::BackendFailure, io::Error::other("backend down"));
345        let standard_error: &dyn std::error::Error = &error;
346
347        assert_eq!(error.kind(), ErrorKind::BackendFailure);
348        assert_eq!(standard_error.source().unwrap().to_string(), "backend down");
349    }
350
351    #[test]
352    fn context_renders_after_kind() {
353        let error = Error::with_context(
354            ErrorKind::BackendFailure,
355            "reading local object",
356            io::Error::other("backend down"),
357        );
358
359        assert_eq!(
360            error.to_string(),
361            "backend operation failed: reading local object"
362        );
363    }
364
365    #[test]
366    fn error_kind_default_message_includes_range_size() {
367        let error: Error = ErrorKind::RangeNotSatisfiable { total: 42 }.into();
368
369        assert_eq!(
370            error.to_string(),
371            "range not satisfiable (object size: 42 bytes)"
372        );
373    }
374
375    #[test]
376    fn panic_uses_the_payload_message() {
377        let panic = Panic::new(Box::new("task panicked"));
378        let error: Error = panic.into();
379
380        assert_eq!(error.kind(), ErrorKind::Panic);
381        assert_eq!(error.to_string(), "service task panicked");
382        assert_eq!(error.source().unwrap().to_string(), "task panicked");
383    }
384}