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