Skip to main content

dynamo_runtime/
error.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dynamo Error System
5//!
6//! This module provides a standardized error type for Dynamo with support for:
7//! - Categorized error types via [`ErrorType`] enum
8//! - Error chaining via the standard [`std::error::Error::source()`] method
9//! - Serialization for network transmission via serde
10//!
11//! # DynamoError
12//!
13//! [`DynamoError`] is the standardized error type for Dynamo. It can be created
14//! directly or converted from any [`std::error::Error`]:
15//!
16//! ```rust,ignore
17//! use dynamo_runtime::error::{DynamoError, ErrorType};
18//!
19//! // Simple error
20//! let err = DynamoError::msg("something failed");
21//!
22//! // Typed error with cause
23//! let cause = std::io::Error::other("io error");
24//! let err = DynamoError::builder()
25//!     .error_type(ErrorType::Unknown)
26//!     .message("operation failed")
27//!     .cause(cause)
28//!     .build();
29//!
30//! // Convert from any std::error::Error
31//! let std_err = std::io::Error::other("io error");
32//! let dynamo_err = DynamoError::from(Box::new(std_err) as Box<dyn std::error::Error>);
33//! ```
34
35use serde::{Deserialize, Serialize};
36use std::fmt;
37
38// ============================================================================
39// ErrorType Enum
40// ============================================================================
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum ErrorType {
44    /// Uncategorized or unknown error.
45    Unknown,
46    /// The request contains invalid input (e.g., prompt exceeds context length).
47    InvalidArgument,
48    /// Failed to establish a connection to a remote worker.
49    CannotConnect,
50    /// An established connection was lost unexpectedly.
51    Disconnected,
52    /// A connection or request timed out.
53    ConnectionTimeout,
54    /// Error originating from a backend engine.
55    Backend(BackendError),
56}
57
58impl fmt::Display for ErrorType {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            ErrorType::Unknown => write!(f, "Unknown"),
62            ErrorType::InvalidArgument => write!(f, "InvalidArgument"),
63            ErrorType::CannotConnect => write!(f, "CannotConnect"),
64            ErrorType::Disconnected => write!(f, "Disconnected"),
65            ErrorType::ConnectionTimeout => write!(f, "ConnectionTimeout"),
66            ErrorType::Backend(sub) => write!(f, "Backend.{sub}"),
67        }
68    }
69}
70
71/// Categorizes errors into a fixed set of standard types.
72///
73/// Consumers (e.g., the migration module) inspect the error type to decide
74/// what action to take, rather than the error defining its own behavior.
75/// Backend engine error subcategories.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub enum BackendError {
78    /// The engine process has shut down or crashed.
79    EngineShutdown,
80}
81
82impl fmt::Display for BackendError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            BackendError::EngineShutdown => write!(f, "EngineShutdown"),
86        }
87    }
88}
89
90// ============================================================================
91// DynamoError - The Standardized Error Type
92// ============================================================================
93
94/// The standardized error type for Dynamo.
95///
96/// `DynamoError` is a serializable, chainable error that:
97/// - Carries an [`ErrorType`] for categorization
98/// - Supports error chaining via [`std::error::Error::source()`]
99/// - Is serializable for network transmission via `Annotated`
100/// - Can be created from any [`std::error::Error`]
101///
102/// # Display
103///
104/// `Display` shows only the current error (standard Rust convention).
105/// Use `source()` to walk the cause chain:
106///
107/// ```rust,ignore
108/// let err = DynamoError::msg("outer");
109/// println!("{}", err); // "Unknown: outer"
110/// ```
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct DynamoError {
113    error_type: ErrorType,
114    message: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    caused_by: Option<Box<DynamoError>>,
117}
118
119impl DynamoError {
120    /// Create a builder for constructing a `DynamoError`.
121    pub fn builder() -> DynamoErrorBuilder {
122        DynamoErrorBuilder::default()
123    }
124
125    /// Shorthand to create an `Unknown` error with just a message and no cause.
126    pub fn msg(message: impl Into<String>) -> Self {
127        Self::builder().message(message).build()
128    }
129
130    /// Returns the error type.
131    pub fn error_type(&self) -> ErrorType {
132        self.error_type
133    }
134
135    /// Returns the error message.
136    pub fn message(&self) -> &str {
137        &self.message
138    }
139}
140
141impl fmt::Display for DynamoError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}: {}", self.error_type, self.message)
144    }
145}
146
147impl std::error::Error for DynamoError {
148    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
149        self.caused_by
150            .as_deref()
151            .map(|e| e as &(dyn std::error::Error + 'static))
152    }
153}
154
155/// Convert from a reference to any `std::error::Error`.
156///
157/// If the error is already a `DynamoError`, it is cloned. Otherwise, it is
158/// wrapped as `ErrorType::Unknown` with the display string as the message.
159/// The source chain is recursively converted, preserving `DynamoError` instances.
160impl<'a> From<&'a (dyn std::error::Error + 'static)> for DynamoError {
161    fn from(err: &'a (dyn std::error::Error + 'static)) -> Self {
162        if let Some(dynamo_err) = err.downcast_ref::<DynamoError>() {
163            return dynamo_err.clone();
164        }
165
166        Self {
167            error_type: ErrorType::Unknown,
168            message: err.to_string(),
169            caused_by: err.source().map(|s| Box::new(DynamoError::from(s))),
170        }
171    }
172}
173
174/// Convert from an owned boxed `std::error::Error`.
175///
176/// If the error is already a `DynamoError`, ownership is taken without cloning.
177/// Otherwise, falls back to the reference-based conversion.
178impl From<Box<dyn std::error::Error + 'static>> for DynamoError {
179    fn from(err: Box<dyn std::error::Error + 'static>) -> Self {
180        match err.downcast::<DynamoError>() {
181            Ok(dynamo_err) => *dynamo_err,
182            Err(err) => DynamoError::from(&*err as &(dyn std::error::Error + 'static)),
183        }
184    }
185}
186
187// ============================================================================
188// DynamoErrorBuilder
189// ============================================================================
190
191/// Builder for constructing a [`DynamoError`].
192///
193/// # Example
194/// ```rust,ignore
195/// let err = DynamoError::builder()
196///     .error_type(ErrorType::Disconnected)
197///     .message("worker lost")
198///     .cause(some_io_error)
199///     .build();
200/// ```
201#[derive(Default)]
202pub struct DynamoErrorBuilder {
203    error_type: Option<ErrorType>,
204    message: Option<String>,
205    caused_by: Option<Box<DynamoError>>,
206}
207
208impl DynamoErrorBuilder {
209    /// Set the error type.
210    pub fn error_type(mut self, error_type: ErrorType) -> Self {
211        self.error_type = Some(error_type);
212        self
213    }
214
215    /// Set the error message.
216    pub fn message(mut self, message: impl Into<String>) -> Self {
217        self.message = Some(message.into());
218        self
219    }
220
221    /// Set the cause from any `std::error::Error`.
222    ///
223    /// If the cause is already a `DynamoError`, it is preserved as-is.
224    /// Otherwise, it is converted to a `DynamoError` with `ErrorType::Unknown`.
225    pub fn cause(mut self, cause: impl std::error::Error + 'static) -> Self {
226        self.caused_by = Some(Box::new(DynamoError::from(
227            &cause as &(dyn std::error::Error + 'static),
228        )));
229        self
230    }
231
232    /// Build the `DynamoError`.
233    ///
234    /// Defaults: `error_type` → `Unknown`, `message` → `""`, `cause` → `None`.
235    pub fn build(self) -> DynamoError {
236        DynamoError {
237            error_type: self.error_type.unwrap_or(ErrorType::Unknown),
238            message: self.message.unwrap_or_default(),
239            caused_by: self.caused_by,
240        }
241    }
242}
243
244// ============================================================================
245// Utility Functions
246// ============================================================================
247
248/// Check whether an error chain contains a specific set of error types
249/// while not containing any of the excluded error types.
250///
251/// Walks the chain via `source()`, inspecting each error that can be downcast
252/// to `DynamoError`. Returns `false` immediately if any error's type is in
253/// `exclude_set`. Otherwise, returns `true` if at least one error's type is
254/// in `match_set`. Errors that are not `DynamoError` are skipped.
255pub fn match_error_chain(
256    err: &(dyn std::error::Error + 'static),
257    match_set: &[ErrorType],
258    exclude_set: &[ErrorType],
259) -> bool {
260    let mut found = false;
261    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
262
263    while let Some(e) = current {
264        if let Some(dynamo_err) = e.downcast_ref::<DynamoError>() {
265            if exclude_set.contains(&dynamo_err.error_type()) {
266                return false;
267            }
268            if match_set.contains(&dynamo_err.error_type()) {
269                found = true;
270            }
271        }
272        current = e.source();
273    }
274
275    found
276}
277
278// ============================================================================
279// Tests
280// ============================================================================
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::error::Error;
286
287    // Compile-time assertions that DynamoError is std::error::Error + Send + Sync + 'static.
288    // These fail at compile time if a future change breaks these guarantees.
289    const _: () = {
290        fn assert_stderror<T: std::error::Error>() {}
291        fn assert_send<T: Send>() {}
292        fn assert_sync<T: Sync>() {}
293        fn assert_static<T: 'static>() {}
294        fn assert_all() {
295            assert_stderror::<DynamoError>();
296            assert_send::<DynamoError>();
297            assert_sync::<DynamoError>();
298            assert_static::<DynamoError>();
299        }
300    };
301
302    #[test]
303    fn test_msg_constructor() {
304        let err = DynamoError::msg("something failed");
305        assert_eq!(err.error_type(), ErrorType::Unknown);
306        assert_eq!(err.message(), "something failed");
307        assert!(err.source().is_none());
308    }
309
310    #[test]
311    fn test_new_constructor_with_cause() {
312        let cause = std::io::Error::other("io error");
313        let err = DynamoError::builder()
314            .error_type(ErrorType::Unknown)
315            .message("operation failed")
316            .cause(cause)
317            .build();
318
319        assert_eq!(err.error_type(), ErrorType::Unknown);
320        assert_eq!(err.message(), "operation failed");
321        assert!(err.source().is_some());
322    }
323
324    #[test]
325    fn test_display_shows_only_current_error() {
326        let cause = std::io::Error::other("io error");
327        let err = DynamoError::builder()
328            .error_type(ErrorType::Unknown)
329            .message("operation failed")
330            .cause(cause)
331            .build();
332
333        // Display should only show the current error, not the chain
334        assert_eq!(err.to_string(), "Unknown: operation failed");
335    }
336
337    #[test]
338    fn test_source_chain() {
339        let cause = std::io::Error::other("io error");
340        let err = DynamoError::builder()
341            .error_type(ErrorType::Unknown)
342            .message("operation failed")
343            .cause(cause)
344            .build();
345
346        // source() should return the cause
347        let source = err.source().unwrap();
348        assert!(source.to_string().contains("io error"));
349    }
350
351    #[test]
352    fn test_from_boxed_std_error() {
353        let std_err = std::io::Error::other("io error");
354        let boxed: Box<dyn std::error::Error> = Box::new(std_err);
355        let dynamo_err = DynamoError::from(boxed);
356
357        assert_eq!(dynamo_err.error_type(), ErrorType::Unknown);
358        assert_eq!(dynamo_err.message(), "io error");
359    }
360
361    #[test]
362    fn test_from_boxed_takes_ownership_of_dynamo_error() {
363        let inner = DynamoError::msg("original");
364        let boxed: Box<dyn std::error::Error> = Box::new(inner);
365        let dynamo_err = DynamoError::from(boxed);
366
367        // Should take ownership, not clone or wrap
368        assert_eq!(dynamo_err.error_type(), ErrorType::Unknown);
369        assert_eq!(dynamo_err.message(), "original");
370    }
371
372    #[test]
373    fn test_from_boxed_with_source_chain() {
374        #[derive(Debug)]
375        struct OuterError {
376            source: std::io::Error,
377        }
378
379        impl fmt::Display for OuterError {
380            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381                write!(f, "outer error occurred")
382            }
383        }
384
385        impl std::error::Error for OuterError {
386            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
387                Some(&self.source)
388            }
389        }
390
391        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
392        let outer = OuterError { source: inner };
393        let boxed: Box<dyn std::error::Error> = Box::new(outer);
394        let dynamo_err = DynamoError::from(boxed);
395
396        assert_eq!(dynamo_err.message(), "outer error occurred");
397        assert!(dynamo_err.source().is_some());
398
399        let cause = dynamo_err.source().unwrap();
400        assert!(cause.to_string().contains("file not found"));
401    }
402
403    #[test]
404    fn test_serialization_roundtrip() {
405        let cause = DynamoError::msg("inner cause");
406        let err = DynamoError::builder()
407            .error_type(ErrorType::Unknown)
408            .message("outer error")
409            .cause(cause)
410            .build();
411
412        let json = serde_json::to_string(&err).unwrap();
413        let deserialized: DynamoError = serde_json::from_str(&json).unwrap();
414
415        assert_eq!(deserialized.error_type(), ErrorType::Unknown);
416        assert_eq!(deserialized.message(), "outer error");
417        assert!(deserialized.source().is_some());
418
419        let cause = deserialized
420            .source()
421            .unwrap()
422            .downcast_ref::<DynamoError>()
423            .unwrap();
424        assert_eq!(cause.message(), "inner cause");
425    }
426
427    #[test]
428    fn test_error_type_display() {
429        assert_eq!(ErrorType::Unknown.to_string(), "Unknown");
430    }
431}