Skip to main content

xds_core/
error.rs

1//! Error types for xDS operations.
2//!
3//! This module provides [`XdsError`], a comprehensive error type that covers
4//! all failure modes in xDS operations and properly converts to gRPC status codes.
5
6/// Comprehensive error type for xDS operations.
7///
8/// This error type is designed to:
9/// - Cover all failure modes without using panics
10/// - Properly convert to [`tonic::Status`] for gRPC responses
11/// - Provide detailed error messages for debugging
12/// - Support error chaining via the `source` field
13///
14/// # Example
15///
16/// ```rust
17/// use xds_core::XdsError;
18///
19/// fn validate_resource(name: &str) -> Result<(), XdsError> {
20///     if name.is_empty() {
21///         return Err(XdsError::InvalidResource {
22///             type_url: "type.googleapis.com/envoy.config.cluster.v3.Cluster".to_string(),
23///             name: name.to_string(),
24///             reason: "resource name cannot be empty".to_string(),
25///         });
26///     }
27///     Ok(())
28/// }
29/// ```
30#[derive(Debug, thiserror::Error)]
31pub enum XdsError {
32    /// Malformed or unknown type URL.
33    #[error("invalid type URL: {type_url} - {reason}")]
34    InvalidTypeUrl {
35        /// The invalid type URL.
36        type_url: String,
37        /// Reason why the type URL is invalid.
38        reason: String,
39    },
40
41    /// Requested resource doesn't exist in the cache.
42    #[error("resource not found: {type_url}/{name}")]
43    ResourceNotFound {
44        /// The type URL of the resource.
45        type_url: String,
46        /// The name of the resource.
47        name: String,
48    },
49
50    /// Version conflict during update.
51    #[error("version mismatch for {type_url}/{name}: expected {expected}, got {actual}")]
52    VersionMismatch {
53        /// The type URL of the resource.
54        type_url: String,
55        /// The name of the resource.
56        name: String,
57        /// Expected version.
58        expected: String,
59        /// Actual version received.
60        actual: String,
61    },
62
63    /// Resource validation failed.
64    #[error("invalid resource {type_url}/{name}: {reason}")]
65    InvalidResource {
66        /// The type URL of the resource.
67        type_url: String,
68        /// The name of the resource.
69        name: String,
70        /// Reason for validation failure.
71        reason: String,
72    },
73
74    /// Cache operation failed.
75    #[error("cache error: {message}")]
76    CacheError {
77        /// Description of the cache error.
78        message: String,
79        /// Optional underlying error.
80        #[source]
81        source: Option<Box<dyn std::error::Error + Send + Sync>>,
82    },
83
84    /// Watch creation or notification failed.
85    #[error("watch error: {message}")]
86    WatchError {
87        /// Description of the watch error.
88        message: String,
89    },
90
91    /// Snapshot is incomplete - missing required resource types.
92    #[error("snapshot incomplete: missing {missing_types:?}")]
93    SnapshotIncomplete {
94        /// List of missing type URLs.
95        missing_types: Vec<String>,
96    },
97
98    /// Protobuf encoding failed.
99    #[error("encoding error for {type_url}: {message}")]
100    EncodingError {
101        /// The type URL being encoded.
102        type_url: String,
103        /// Error message.
104        message: String,
105    },
106
107    /// Protobuf decoding failed.
108    #[error("decoding error for {type_url}: {message}")]
109    DecodingError {
110        /// The type URL being decoded.
111        type_url: String,
112        /// Error message.
113        message: String,
114    },
115
116    /// gRPC transport error.
117    #[error("transport error: {message}")]
118    TransportError {
119        /// Error message.
120        message: String,
121        /// Optional underlying error.
122        #[source]
123        source: Option<Box<dyn std::error::Error + Send + Sync>>,
124    },
125
126    /// Client stream closed unexpectedly.
127    #[error("stream closed: {reason}")]
128    StreamClosed {
129        /// Reason for stream closure.
130        reason: String,
131    },
132
133    /// Client rejected configuration (NACK).
134    #[error("NACK received from {node_id} for {type_url}: {error_message}")]
135    NackReceived {
136        /// The node ID that sent the NACK.
137        node_id: String,
138        /// The type URL that was rejected.
139        type_url: String,
140        /// The nonce of the rejected response.
141        nonce: String,
142        /// Error message from the client.
143        error_message: String,
144    },
145
146    /// Operation timed out.
147    #[error("operation timed out: {operation}")]
148    Timeout {
149        /// Description of the operation that timed out.
150        operation: String,
151    },
152
153    /// Server is shutting down.
154    #[error("server is shutting down")]
155    Shutdown,
156
157    /// Too many requests (rate limited).
158    #[error("rate limited: {message}")]
159    RateLimited {
160        /// Rate limit message.
161        message: String,
162    },
163
164    /// Unexpected internal error.
165    #[error("internal error: {message}")]
166    Internal {
167        /// Error message.
168        message: String,
169        /// Optional underlying error.
170        #[source]
171        source: Option<Box<dyn std::error::Error + Send + Sync>>,
172    },
173
174    /// Watch subscription was closed.
175    #[error("watch closed: watch_id={watch_id}")]
176    WatchClosed {
177        /// ID of the closed watch.
178        watch_id: u64,
179    },
180
181    /// Configuration error.
182    #[error("configuration error: {0}")]
183    Configuration(String),
184}
185
186impl XdsError {
187    /// Create an internal error from any error type.
188    pub fn internal<E>(message: impl Into<String>, source: E) -> Self
189    where
190        E: std::error::Error + Send + Sync + 'static,
191    {
192        Self::Internal {
193            message: message.into(),
194            source: Some(Box::new(source)),
195        }
196    }
197
198    /// Create a cache error from any error type.
199    pub fn cache<E>(message: impl Into<String>, source: E) -> Self
200    where
201        E: std::error::Error + Send + Sync + 'static,
202    {
203        Self::CacheError {
204            message: message.into(),
205            source: Some(Box::new(source)),
206        }
207    }
208
209    /// Create a transport error from any error type.
210    pub fn transport<E>(message: impl Into<String>, source: E) -> Self
211    where
212        E: std::error::Error + Send + Sync + 'static,
213    {
214        Self::TransportError {
215            message: message.into(),
216            source: Some(Box::new(source)),
217        }
218    }
219}
220
221/// Convert to tonic::Status for gRPC responses.
222///
223/// This implementation maps each error variant to an appropriate gRPC status code.
224impl From<XdsError> for tonic::Status {
225    fn from(err: XdsError) -> Self {
226        match &err {
227            XdsError::InvalidTypeUrl { .. } | XdsError::InvalidResource { .. } => {
228                tonic::Status::invalid_argument(err.to_string())
229            }
230            XdsError::ResourceNotFound { .. } => tonic::Status::not_found(err.to_string()),
231            XdsError::VersionMismatch { .. } => tonic::Status::failed_precondition(err.to_string()),
232            XdsError::CacheError { .. }
233            | XdsError::WatchError { .. }
234            | XdsError::SnapshotIncomplete { .. } => tonic::Status::internal(err.to_string()),
235            XdsError::EncodingError { .. } | XdsError::DecodingError { .. } => {
236                tonic::Status::invalid_argument(err.to_string())
237            }
238            XdsError::TransportError { .. } | XdsError::StreamClosed { .. } => {
239                tonic::Status::unavailable(err.to_string())
240            }
241            XdsError::NackReceived { .. } => {
242                // NACKs are informational, not necessarily errors for the server
243                tonic::Status::ok(err.to_string())
244            }
245            XdsError::Timeout { .. } => tonic::Status::deadline_exceeded(err.to_string()),
246            XdsError::Shutdown => tonic::Status::unavailable(err.to_string()),
247            XdsError::RateLimited { .. } => tonic::Status::resource_exhausted(err.to_string()),
248            XdsError::Internal { .. } => tonic::Status::internal(err.to_string()),
249            XdsError::WatchClosed { .. } => tonic::Status::cancelled(err.to_string()),
250            XdsError::Configuration(_) => tonic::Status::invalid_argument(err.to_string()),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_error_display() {
261        let err = XdsError::ResourceNotFound {
262            type_url: "type.googleapis.com/envoy.config.cluster.v3.Cluster".to_string(),
263            name: "my-cluster".to_string(),
264        };
265        assert!(err.to_string().contains("my-cluster"));
266    }
267
268    #[test]
269    fn test_error_to_status() {
270        let err = XdsError::ResourceNotFound {
271            type_url: "type.googleapis.com/envoy.config.cluster.v3.Cluster".to_string(),
272            name: "my-cluster".to_string(),
273        };
274        let status: tonic::Status = err.into();
275        assert_eq!(status.code(), tonic::Code::NotFound);
276    }
277
278    #[test]
279    fn test_internal_error_helper() {
280        let io_err = std::io::Error::other("test error");
281        let err = XdsError::internal("operation failed", io_err);
282        assert!(matches!(err, XdsError::Internal { .. }));
283    }
284}