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    /// The backend accepted the request but stopped responding (stream inactivity timeout).
55    ResponseTimeout,
56    /// The request was cancelled (e.g., client disconnected).
57    Cancelled,
58    /// The system does not have enough resources to handle the request.
59    ResourceExhausted,
60    /// No backend worker is currently available to handle the request.
61    Unavailable,
62    /// Error originating from a backend engine.
63    Backend(BackendError),
64}
65
66impl fmt::Display for ErrorType {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            ErrorType::Unknown => write!(f, "Unknown"),
70            ErrorType::InvalidArgument => write!(f, "InvalidArgument"),
71            ErrorType::CannotConnect => write!(f, "CannotConnect"),
72            ErrorType::Disconnected => write!(f, "Disconnected"),
73            ErrorType::ConnectionTimeout => write!(f, "ConnectionTimeout"),
74            ErrorType::ResponseTimeout => write!(f, "ResponseTimeout"),
75            ErrorType::Cancelled => write!(f, "Cancelled"),
76            ErrorType::ResourceExhausted => write!(f, "ResourceExhausted"),
77            ErrorType::Unavailable => write!(f, "Unavailable"),
78            ErrorType::Backend(sub) => write!(f, "Backend{sub}"),
79        }
80    }
81}
82
83/// Categorizes errors into a fixed set of standard types.
84///
85/// Consumers (e.g., the migration module) inspect the error type to decide
86/// what action to take, rather than the error defining its own behavior.
87/// Backend engine error subcategories.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89pub enum BackendError {
90    /// Uncategorized or unknown backend error.
91    Unknown,
92    /// The request contains invalid input (e.g., prompt exceeds context length).
93    InvalidArgument,
94    /// Failed to establish a connection to a remote worker.
95    CannotConnect,
96    /// An established connection was lost unexpectedly.
97    Disconnected,
98    /// A connection or request timed out.
99    ConnectionTimeout,
100    /// The backend accepted the request but stopped responding (stream inactivity timeout).
101    ResponseTimeout,
102    /// The request was cancelled (e.g., client disconnected).
103    Cancelled,
104    /// The engine process has shut down or crashed.
105    EngineShutdown,
106    /// The response stream was terminated before completion (e.g., engine dropped mid-stream).
107    StreamIncomplete,
108}
109
110impl fmt::Display for BackendError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            BackendError::Unknown => write!(f, "Unknown"),
114            BackendError::InvalidArgument => write!(f, "InvalidArgument"),
115            BackendError::CannotConnect => write!(f, "CannotConnect"),
116            BackendError::Disconnected => write!(f, "Disconnected"),
117            BackendError::ConnectionTimeout => write!(f, "ConnectionTimeout"),
118            BackendError::ResponseTimeout => write!(f, "ResponseTimeout"),
119            BackendError::Cancelled => write!(f, "Cancelled"),
120            BackendError::EngineShutdown => write!(f, "EngineShutdown"),
121            BackendError::StreamIncomplete => write!(f, "StreamIncomplete"),
122        }
123    }
124}
125
126// ============================================================================
127// DynamoError - The Standardized Error Type
128// ============================================================================
129
130/// The standardized error type for Dynamo.
131///
132/// `DynamoError` is a serializable, chainable error that:
133/// - Carries an [`ErrorType`] for categorization
134/// - Supports error chaining via [`std::error::Error::source()`]
135/// - Is serializable for network transmission via `Annotated`
136/// - Can be created from any [`std::error::Error`]
137///
138/// # Display
139///
140/// `Display` shows only the current error (standard Rust convention).
141/// Use `source()` to walk the cause chain:
142///
143/// ```rust,ignore
144/// let err = DynamoError::msg("outer");
145/// println!("{}", err); // "Unknown: outer"
146/// ```
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct DynamoError {
149    error_type: ErrorType,
150    message: String,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    caused_by: Option<Box<DynamoError>>,
153}
154
155impl DynamoError {
156    /// Create a builder for constructing a `DynamoError`.
157    pub fn builder() -> DynamoErrorBuilder {
158        DynamoErrorBuilder::default()
159    }
160
161    /// Shorthand to create an `Unknown` error with just a message and no cause.
162    pub fn msg(message: impl Into<String>) -> Self {
163        Self::builder().message(message).build()
164    }
165
166    /// Returns the error type.
167    pub fn error_type(&self) -> ErrorType {
168        self.error_type
169    }
170
171    /// Returns the error message.
172    pub fn message(&self) -> &str {
173        &self.message
174    }
175}
176
177impl fmt::Display for DynamoError {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        write!(f, "{}: {}", self.error_type, self.message)
180    }
181}
182
183impl std::error::Error for DynamoError {
184    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
185        self.caused_by
186            .as_deref()
187            .map(|e| e as &(dyn std::error::Error + 'static))
188    }
189}
190
191/// Convert from a reference to any `std::error::Error`.
192///
193/// If the error is already a `DynamoError`, it is cloned. Otherwise, it is
194/// wrapped as `ErrorType::Unknown` with the display string as the message.
195/// The source chain is recursively converted, preserving `DynamoError` instances.
196impl<'a> From<&'a (dyn std::error::Error + 'static)> for DynamoError {
197    fn from(err: &'a (dyn std::error::Error + 'static)) -> Self {
198        if let Some(dynamo_err) = err.downcast_ref::<DynamoError>() {
199            return dynamo_err.clone();
200        }
201
202        Self {
203            error_type: ErrorType::Unknown,
204            message: err.to_string(),
205            caused_by: err.source().map(|s| Box::new(DynamoError::from(s))),
206        }
207    }
208}
209
210/// Convert from an owned boxed `std::error::Error`.
211///
212/// If the error is already a `DynamoError`, ownership is taken without cloning.
213/// Otherwise, falls back to the reference-based conversion.
214impl From<Box<dyn std::error::Error + 'static>> for DynamoError {
215    fn from(err: Box<dyn std::error::Error + 'static>) -> Self {
216        match err.downcast::<DynamoError>() {
217            Ok(dynamo_err) => *dynamo_err,
218            Err(err) => DynamoError::from(&*err as &(dyn std::error::Error + 'static)),
219        }
220    }
221}
222
223// ============================================================================
224// DynamoErrorBuilder
225// ============================================================================
226
227/// Builder for constructing a [`DynamoError`].
228///
229/// # Example
230/// ```rust,ignore
231/// let err = DynamoError::builder()
232///     .error_type(ErrorType::Disconnected)
233///     .message("worker lost")
234///     .cause(some_io_error)
235///     .build();
236/// ```
237#[derive(Default)]
238pub struct DynamoErrorBuilder {
239    error_type: Option<ErrorType>,
240    message: Option<String>,
241    caused_by: Option<Box<DynamoError>>,
242}
243
244impl DynamoErrorBuilder {
245    /// Set the error type.
246    pub fn error_type(mut self, error_type: ErrorType) -> Self {
247        self.error_type = Some(error_type);
248        self
249    }
250
251    /// Set the error message.
252    pub fn message(mut self, message: impl Into<String>) -> Self {
253        self.message = Some(message.into());
254        self
255    }
256
257    /// Set the cause from any `std::error::Error`.
258    ///
259    /// If the cause is already a `DynamoError`, it is preserved as-is.
260    /// Otherwise, it is converted to a `DynamoError` with `ErrorType::Unknown`.
261    pub fn cause(mut self, cause: impl std::error::Error + 'static) -> Self {
262        self.caused_by = Some(Box::new(DynamoError::from(
263            &cause as &(dyn std::error::Error + 'static),
264        )));
265        self
266    }
267
268    /// Build the `DynamoError`.
269    ///
270    /// Defaults: `error_type` → `Unknown`, `message` → `""`, `cause` → `None`.
271    pub fn build(self) -> DynamoError {
272        DynamoError {
273            error_type: self.error_type.unwrap_or(ErrorType::Unknown),
274            message: self.message.unwrap_or_default(),
275            caused_by: self.caused_by,
276        }
277    }
278}
279
280// ============================================================================
281// Utility Functions
282// ============================================================================
283
284/// Check whether an error chain contains a specific set of error types
285/// while not containing any of the excluded error types.
286///
287/// Walks the chain via `source()`, inspecting each error that can be downcast
288/// to `DynamoError`. Returns `false` immediately if any error's type is in
289/// `exclude_set`. Otherwise, returns `true` if at least one error's type is
290/// in `match_set`. Errors that are not `DynamoError` are skipped.
291pub fn match_error_chain(
292    err: &(dyn std::error::Error + 'static),
293    match_set: &[ErrorType],
294    exclude_set: &[ErrorType],
295) -> bool {
296    let mut found = false;
297    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
298
299    while let Some(e) = current {
300        if let Some(dynamo_err) = e.downcast_ref::<DynamoError>() {
301            if exclude_set.contains(&dynamo_err.error_type()) {
302                return false;
303            }
304            if match_set.contains(&dynamo_err.error_type()) {
305                found = true;
306            }
307        }
308        current = e.source();
309    }
310
311    found
312}
313
314// ============================================================================
315// Tests
316// ============================================================================
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::error::Error;
322
323    // Compile-time assertions that DynamoError is std::error::Error + Send + Sync + 'static.
324    // These fail at compile time if a future change breaks these guarantees.
325    const _: () = {
326        fn assert_stderror<T: std::error::Error>() {}
327        fn assert_send<T: Send>() {}
328        fn assert_sync<T: Sync>() {}
329        fn assert_static<T: 'static>() {}
330        fn assert_all() {
331            assert_stderror::<DynamoError>();
332            assert_send::<DynamoError>();
333            assert_sync::<DynamoError>();
334            assert_static::<DynamoError>();
335        }
336    };
337
338    #[test]
339    fn test_msg_constructor() {
340        let err = DynamoError::msg("something failed");
341        assert_eq!(err.error_type(), ErrorType::Unknown);
342        assert_eq!(err.message(), "something failed");
343        assert!(err.source().is_none());
344    }
345
346    #[test]
347    fn test_new_constructor_with_cause() {
348        let cause = std::io::Error::other("io error");
349        let err = DynamoError::builder()
350            .error_type(ErrorType::Unknown)
351            .message("operation failed")
352            .cause(cause)
353            .build();
354
355        assert_eq!(err.error_type(), ErrorType::Unknown);
356        assert_eq!(err.message(), "operation failed");
357        assert!(err.source().is_some());
358    }
359
360    #[test]
361    fn test_display_shows_only_current_error() {
362        let cause = std::io::Error::other("io error");
363        let err = DynamoError::builder()
364            .error_type(ErrorType::Unknown)
365            .message("operation failed")
366            .cause(cause)
367            .build();
368
369        // Display should only show the current error, not the chain
370        assert_eq!(err.to_string(), "Unknown: operation failed");
371    }
372
373    #[test]
374    fn test_source_chain() {
375        let cause = std::io::Error::other("io error");
376        let err = DynamoError::builder()
377            .error_type(ErrorType::Unknown)
378            .message("operation failed")
379            .cause(cause)
380            .build();
381
382        // source() should return the cause
383        let source = err.source().unwrap();
384        assert!(source.to_string().contains("io error"));
385    }
386
387    #[test]
388    fn test_from_boxed_std_error() {
389        let std_err = std::io::Error::other("io error");
390        let boxed: Box<dyn std::error::Error> = Box::new(std_err);
391        let dynamo_err = DynamoError::from(boxed);
392
393        assert_eq!(dynamo_err.error_type(), ErrorType::Unknown);
394        assert_eq!(dynamo_err.message(), "io error");
395    }
396
397    #[test]
398    fn test_from_boxed_takes_ownership_of_dynamo_error() {
399        let inner = DynamoError::msg("original");
400        let boxed: Box<dyn std::error::Error> = Box::new(inner);
401        let dynamo_err = DynamoError::from(boxed);
402
403        // Should take ownership, not clone or wrap
404        assert_eq!(dynamo_err.error_type(), ErrorType::Unknown);
405        assert_eq!(dynamo_err.message(), "original");
406    }
407
408    #[test]
409    fn test_from_boxed_with_source_chain() {
410        #[derive(Debug)]
411        struct OuterError {
412            source: std::io::Error,
413        }
414
415        impl fmt::Display for OuterError {
416            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417                write!(f, "outer error occurred")
418            }
419        }
420
421        impl std::error::Error for OuterError {
422            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
423                Some(&self.source)
424            }
425        }
426
427        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
428        let outer = OuterError { source: inner };
429        let boxed: Box<dyn std::error::Error> = Box::new(outer);
430        let dynamo_err = DynamoError::from(boxed);
431
432        assert_eq!(dynamo_err.message(), "outer error occurred");
433        assert!(dynamo_err.source().is_some());
434
435        let cause = dynamo_err.source().unwrap();
436        assert!(cause.to_string().contains("file not found"));
437    }
438
439    #[test]
440    fn test_serialization_roundtrip() {
441        let cause = DynamoError::msg("inner cause");
442        let err = DynamoError::builder()
443            .error_type(ErrorType::Unknown)
444            .message("outer error")
445            .cause(cause)
446            .build();
447
448        let json = serde_json::to_string(&err).unwrap();
449        let deserialized: DynamoError = serde_json::from_str(&json).unwrap();
450
451        assert_eq!(deserialized.error_type(), ErrorType::Unknown);
452        assert_eq!(deserialized.message(), "outer error");
453        assert!(deserialized.source().is_some());
454
455        let cause = deserialized
456            .source()
457            .unwrap()
458            .downcast_ref::<DynamoError>()
459            .unwrap();
460        assert_eq!(cause.message(), "inner cause");
461    }
462
463    #[test]
464    fn test_error_type_display() {
465        assert_eq!(ErrorType::Unknown.to_string(), "Unknown");
466        assert_eq!(ErrorType::InvalidArgument.to_string(), "InvalidArgument");
467        assert_eq!(ErrorType::CannotConnect.to_string(), "CannotConnect");
468        assert_eq!(ErrorType::Disconnected.to_string(), "Disconnected");
469        assert_eq!(
470            ErrorType::ConnectionTimeout.to_string(),
471            "ConnectionTimeout"
472        );
473        assert_eq!(ErrorType::ResponseTimeout.to_string(), "ResponseTimeout");
474        assert_eq!(ErrorType::Cancelled.to_string(), "Cancelled");
475        assert_eq!(
476            ErrorType::ResourceExhausted.to_string(),
477            "ResourceExhausted"
478        );
479        assert_eq!(ErrorType::Unavailable.to_string(), "Unavailable");
480        assert_eq!(
481            ErrorType::Backend(BackendError::Unknown).to_string(),
482            "BackendUnknown"
483        );
484        assert_eq!(
485            ErrorType::Backend(BackendError::InvalidArgument).to_string(),
486            "BackendInvalidArgument"
487        );
488        assert_eq!(
489            ErrorType::Backend(BackendError::CannotConnect).to_string(),
490            "BackendCannotConnect"
491        );
492        assert_eq!(
493            ErrorType::Backend(BackendError::Disconnected).to_string(),
494            "BackendDisconnected"
495        );
496        assert_eq!(
497            ErrorType::Backend(BackendError::ConnectionTimeout).to_string(),
498            "BackendConnectionTimeout"
499        );
500        assert_eq!(
501            ErrorType::Backend(BackendError::Cancelled).to_string(),
502            "BackendCancelled"
503        );
504        assert_eq!(
505            ErrorType::Backend(BackendError::EngineShutdown).to_string(),
506            "BackendEngineShutdown"
507        );
508        assert_eq!(
509            ErrorType::Backend(BackendError::StreamIncomplete).to_string(),
510            "BackendStreamIncomplete"
511        );
512        assert_eq!(
513            ErrorType::Backend(BackendError::ResponseTimeout).to_string(),
514            "BackendResponseTimeout"
515        );
516    }
517}