Skip to main content

camel_api/
error.rs

1use std::sync::Arc;
2use thiserror::Error;
3
4/// Core error type for the Camel framework.
5#[derive(Debug, Clone, Error)]
6#[non_exhaustive]
7pub enum CamelError {
8    #[error("Component not found: {0}")]
9    ComponentNotFound(String),
10
11    #[error("Endpoint creation failed: {0}")]
12    EndpointCreationFailed(String),
13
14    #[error("Processor error: {0}")]
15    ProcessorError(String),
16
17    /// Like `ProcessorError` but preserves the source error chain
18    /// for downstream inspection (e.g. via `std::error::Error::source()`).
19    #[error("Processor error: {0}")]
20    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
21
22    #[error("Type conversion failed: {0}")]
23    TypeConversionFailed(String),
24
25    #[error("Invalid URI: {0}")]
26    InvalidUri(String),
27
28    #[error("Channel closed")]
29    ChannelClosed,
30
31    #[error("Route error: {0}")]
32    RouteError(String),
33
34    #[error("IO error: {0}")]
35    Io(String),
36
37    #[error("Dead letter channel failed: {0}")]
38    DeadLetterChannelFailed(String),
39
40    #[error("Circuit breaker open: {0}")]
41    CircuitOpen(String),
42
43    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
44    HttpOperationFailed {
45        method: String,
46        url: String,
47        status_code: u16,
48        status_text: String,
49        response_body: Option<String>,
50    },
51
52    /// Producer's `poll_ready` returned a shutdown signal — the consumer/semaphore
53    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
54    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
55    #[error("Consumer stopping: semaphore closed during poll_ready")]
56    ConsumerStopping,
57
58    #[error("Configuration error: {0}")]
59    Config(String),
60
61    #[error("Body stream has already been consumed")]
62    AlreadyConsumed,
63
64    #[error("Stream size exceeded limit: {0}")]
65    StreamLimitExceeded(usize),
66
67    #[error("Unauthenticated: {0}")]
68    Unauthenticated(String),
69
70    #[error("Unauthorized: {0}")]
71    Unauthorized(String),
72}
73
74impl CamelError {
75    pub fn classify(&self) -> &'static str {
76        #[allow(unreachable_patterns)]
77        match self {
78            Self::ComponentNotFound(_) => "component",
79            Self::EndpointCreationFailed(_) | Self::InvalidUri(_) => "endpoint",
80            Self::ProcessorError(_) | Self::ProcessorErrorWithSource(_, _) => "processor",
81            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
82            Self::Io(_) => "io",
83            Self::RouteError(_) => "route",
84            Self::CircuitOpen(_) => "circuit_open",
85            Self::HttpOperationFailed { .. } => "http",
86            Self::Config(_) => "config",
87            Self::DeadLetterChannelFailed(_) => "dead_letter",
88            Self::ConsumerStopping => "consumer_stop",
89            Self::StreamLimitExceeded(_) => "stream",
90            Self::ChannelClosed => "channel",
91            Self::Unauthenticated(_) => "unauthenticated",
92            Self::Unauthorized(_) => "unauthorized",
93            _ => "unknown",
94        }
95    }
96
97    /// Stable variant name used by `doTry` catch-by-variant matchers.
98    ///
99    /// `ProcessorErrorWithSource` aliases to `"ProcessorError"` — the two variants are
100    /// not distinguishable by name in MVP (see spec §5.4).
101    ///
102    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
103    /// so internal exhaustive matching is allowed. Adding a new variant without updating
104    /// this method will fail to compile, surfaced by `variant_name_tests`.
105    pub fn variant_name(&self) -> &'static str {
106        match self {
107            Self::ComponentNotFound(_) => "ComponentNotFound",
108            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
109            Self::ProcessorError(_) => "ProcessorError",
110            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
111            Self::TypeConversionFailed(_) => "TypeConversionFailed",
112            Self::InvalidUri(_) => "InvalidUri",
113            Self::ChannelClosed => "ChannelClosed",
114            Self::RouteError(_) => "RouteError",
115            Self::Io(_) => "Io",
116            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
117            Self::CircuitOpen(_) => "CircuitOpen",
118            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
119            Self::ConsumerStopping => "ConsumerStopping",
120            Self::Config(_) => "Config",
121            Self::AlreadyConsumed => "AlreadyConsumed",
122            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
123            Self::Unauthenticated(_) => "Unauthenticated",
124            Self::Unauthorized(_) => "Unauthorized",
125        }
126    }
127}
128
129impl From<std::io::Error> for CamelError {
130    fn from(err: std::io::Error) -> Self {
131        CamelError::Io(err.to_string())
132    }
133}
134
135impl From<crate::template::TemplateError> for CamelError {
136    fn from(err: crate::template::TemplateError) -> Self {
137        CamelError::Config(err.to_string())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn all_error_samples() -> Vec<CamelError> {
146        vec![
147            CamelError::ComponentNotFound("x".to_string()),
148            CamelError::EndpointCreationFailed("x".to_string()),
149            CamelError::ProcessorError("x".to_string()),
150            CamelError::ProcessorErrorWithSource(
151                "x".to_string(),
152                Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "inner")),
153            ),
154            CamelError::TypeConversionFailed("x".to_string()),
155            CamelError::InvalidUri("x".to_string()),
156            CamelError::ChannelClosed,
157            CamelError::RouteError("x".to_string()),
158            CamelError::Io("x".to_string()),
159            CamelError::DeadLetterChannelFailed("x".to_string()),
160            CamelError::CircuitOpen("x".to_string()),
161            CamelError::HttpOperationFailed {
162                method: "GET".to_string(),
163                url: "https://example.com".to_string(),
164                status_code: 500,
165                status_text: "Internal Server Error".to_string(),
166                response_body: Some("error".to_string()),
167            },
168            CamelError::ConsumerStopping,
169            CamelError::Config("x".to_string()),
170            CamelError::AlreadyConsumed,
171            CamelError::StreamLimitExceeded(42),
172            CamelError::Unauthenticated("token expired".to_string()),
173            CamelError::Unauthorized("missing admin role".to_string()),
174        ]
175    }
176
177    #[test]
178    fn test_http_operation_failed_display() {
179        let err = CamelError::HttpOperationFailed {
180            method: "GET".to_string(),
181            url: "https://example.com/test".to_string(),
182            status_code: 404,
183            status_text: "Not Found".to_string(),
184            response_body: Some("page not found".to_string()),
185        };
186        let msg = format!("{err}");
187        assert!(msg.contains("404"));
188        assert!(msg.contains("Not Found"));
189    }
190
191    #[test]
192    fn test_http_operation_failed_clone() {
193        let err = CamelError::HttpOperationFailed {
194            method: "POST".to_string(),
195            url: "https://api.example.com/users".to_string(),
196            status_code: 500,
197            status_text: "Internal Server Error".to_string(),
198            response_body: None,
199        };
200        let cloned = err.clone();
201        assert!(matches!(
202            cloned,
203            CamelError::HttpOperationFailed {
204                status_code: 500,
205                ..
206            }
207        ));
208    }
209
210    #[test]
211    fn test_classify_maps_all_variants() {
212        assert_eq!(
213            CamelError::ComponentNotFound("x".to_string()).classify(),
214            "component"
215        );
216        assert_eq!(
217            CamelError::EndpointCreationFailed("x".to_string()).classify(),
218            "endpoint"
219        );
220        assert_eq!(
221            CamelError::ProcessorError("x".to_string()).classify(),
222            "processor"
223        );
224        assert_eq!(
225            CamelError::TypeConversionFailed("x".to_string()).classify(),
226            "type_conversion"
227        );
228        assert_eq!(
229            CamelError::InvalidUri("x".to_string()).classify(),
230            "endpoint"
231        );
232        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
233        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
234        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
235        assert_eq!(
236            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
237            "dead_letter"
238        );
239        assert_eq!(
240            CamelError::CircuitOpen("x".to_string()).classify(),
241            "circuit_open"
242        );
243        assert_eq!(
244            CamelError::HttpOperationFailed {
245                method: "GET".to_string(),
246                url: "https://example.com".to_string(),
247                status_code: 500,
248                status_text: "Internal Server Error".to_string(),
249                response_body: None,
250            }
251            .classify(),
252            "http"
253        );
254        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
255        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
256        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
257    }
258
259    #[test]
260    fn test_classify_output_is_ascii_and_short() {
261        for error in all_error_samples() {
262            let class = error.classify();
263            assert!(class.is_ascii());
264            assert!(class.len() <= 15, "class too long: {class}");
265        }
266    }
267
268    #[test]
269    fn test_auth_variants_classify() {
270        assert_eq!(
271            CamelError::Unauthenticated("x".to_string()).classify(),
272            "unauthenticated"
273        );
274        assert_eq!(
275            CamelError::Unauthorized("x".to_string()).classify(),
276            "unauthorized"
277        );
278    }
279
280    #[test]
281    fn test_auth_variants_are_clone() {
282        let err = CamelError::Unauthenticated("test".to_string());
283        let cloned = err.clone();
284        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
285
286        let err2 = CamelError::Unauthorized("test".to_string());
287        let cloned2 = err2.clone();
288        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
289    }
290}
291
292#[cfg(test)]
293mod variant_name_tests {
294    use super::CamelError;
295    use std::sync::Arc;
296
297    /// Representative value for each of the 18 enum variants. This test fails to compile
298    /// when a new variant is added to CamelError without updating variant_name().
299    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
300    /// exhaustive matching is allowed.
301    #[test]
302    fn variant_name_covers_all_variants() {
303        let cases: Vec<(CamelError, &str)> = vec![
304            (
305                CamelError::ComponentNotFound("x".into()),
306                "ComponentNotFound",
307            ),
308            (
309                CamelError::EndpointCreationFailed("x".into()),
310                "EndpointCreationFailed",
311            ),
312            (CamelError::ProcessorError("x".into()), "ProcessorError"),
313            (
314                CamelError::ProcessorErrorWithSource(
315                    "x".into(),
316                    Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "y")),
317                ),
318                "ProcessorError", // aliased
319            ),
320            (
321                CamelError::TypeConversionFailed("x".into()),
322                "TypeConversionFailed",
323            ),
324            (CamelError::InvalidUri("x".into()), "InvalidUri"),
325            (CamelError::ChannelClosed, "ChannelClosed"),
326            (CamelError::RouteError("x".into()), "RouteError"),
327            (CamelError::Io("x".into()), "Io"),
328            (
329                CamelError::DeadLetterChannelFailed("x".into()),
330                "DeadLetterChannelFailed",
331            ),
332            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
333            (
334                CamelError::HttpOperationFailed {
335                    method: "GET".into(),
336                    url: "https://example.com".into(),
337                    status_code: 500,
338                    status_text: "Internal Server Error".into(),
339                    response_body: None,
340                },
341                "HttpOperationFailed",
342            ),
343            (CamelError::Config("x".into()), "Config"),
344            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
345            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
346            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
347            (CamelError::Unauthorized("x".into()), "Unauthorized"),
348        ];
349
350        for (err, expected) in cases {
351            assert_eq!(
352                err.variant_name(),
353                expected,
354                "variant_name mismatch for {:?}",
355                err
356            );
357        }
358    }
359}