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