Skip to main content

camel_api/
error.rs

1use std::sync::Arc;
2use thiserror::Error;
3
4/// Typed security-validation error for fail-closed config/startup checks (ADR-0033).
5///
6/// Each variant corresponds to a specific Batch 1+ security validation that refuses
7/// to start with a misconfigured or dangerous default. Operators can `match` on
8/// these variants for programmatic error handling.
9#[derive(Debug, Clone, PartialEq, Eq, Error)]
10#[non_exhaustive]
11pub enum ConfigValidationError {
12    #[error(
13        "aggregator config requires at least one completion bound (size, timeout, predicate, or interval)"
14    )]
15    AggregatorMissingCompletionBound,
16
17    /// Raised when an Aggregator has none of: max_buckets, a Timeout completion
18    /// condition, or a bucket_ttl. At least one memory-release bound is mandatory
19    /// (R3-M2) so a unique-correlation-key flood cannot grow the bucket map
20    /// without limit.
21    #[error("aggregator requires at least one of max_buckets, completionTimeout, or bucket_ttl")]
22    AggregatorMissingMemoryBound,
23
24    /// Raised when an Aggregator has a Timeout completion condition but no
25    /// `bucket_ttl`. The R3-M3 timeout-task cap may skip spawning a dedicated
26    /// timeout task under flood; without `bucket_ttl` there is no fallback
27    /// eviction path and the bucket leaks until shutdown. Requiring `bucket_ttl`
28    /// whenever Timeout is present makes the cap-skip degradation safe by
29    /// construction.
30    #[error(
31        "aggregator Timeout completion requires bucket_ttl (memory-release bound for the timeout-task cap fallback)"
32    )]
33    AggregatorTimeoutRequiresTtl,
34
35    #[error("throttler max_requests must be > 0")]
36    ThrottlerMaxRequestsZero,
37
38    #[error("loop step must specify either 'count' or 'while', not both")]
39    LoopConflictingCountAndWhile,
40
41    #[error("loop step must specify either 'count' or 'while'")]
42    LoopMissingCountOrWhile,
43
44    #[error("SQL use_message_body_for_sql requires allow_dynamic_query=true")]
45    SqlDynamicQueryWithoutAllowDynamic,
46}
47
48/// Core error type for the Camel framework.
49#[derive(Debug, Clone, Error)]
50#[non_exhaustive]
51pub enum CamelError {
52    #[error("Component not found: {0}")]
53    ComponentNotFound(String),
54
55    #[error("Endpoint creation failed: {0}")]
56    EndpointCreationFailed(String),
57
58    #[error("Processor error: {0}")]
59    ProcessorError(String),
60
61    /// Like `ProcessorError` but preserves the source error chain
62    /// for downstream inspection (e.g. via `std::error::Error::source()`).
63    #[error("Processor error: {0}")]
64    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
65
66    #[error("Type conversion failed: {0}")]
67    TypeConversionFailed(String),
68
69    #[error("Invalid URI: {0}")]
70    InvalidUri(String),
71
72    #[error("Channel closed")]
73    ChannelClosed,
74
75    #[error("Route error: {0}")]
76    RouteError(String),
77
78    #[error("IO error: {0}")]
79    Io(String),
80
81    #[error("Dead letter channel failed: {0}")]
82    DeadLetterChannelFailed(String),
83
84    #[error("Circuit breaker open: {0}")]
85    CircuitOpen(String),
86
87    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
88    HttpOperationFailed {
89        method: String,
90        url: String,
91        status_code: u16,
92        status_text: String,
93        response_body: Option<String>,
94    },
95
96    /// Producer's `poll_ready` returned a shutdown signal — the consumer/semaphore
97    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
98    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
99    #[error("Consumer stopping: semaphore closed during poll_ready")]
100    ConsumerStopping,
101
102    #[error("Configuration error: {0}")]
103    Config(String),
104
105    /// Typed security-validation error (ADR-0033). Promotes Batch 1+ config/startup
106    /// failure modes from stringly-typed `Config(_)` to a matchable enum so
107    /// operators can discriminate programmatically.
108    #[error("Configuration validation error: {0}")]
109    ConfigValidation(ConfigValidationError),
110
111    #[error("Body stream has already been consumed")]
112    AlreadyConsumed,
113
114    #[error("Stream size exceeded limit: {0}")]
115    StreamLimitExceeded(usize),
116
117    #[error("Unauthenticated: {0}")]
118    Unauthenticated(String),
119
120    #[error("Unauthorized: {0}")]
121    Unauthorized(String),
122
123    #[error("Validation failed: {0}")]
124    ValidationError(String),
125
126    #[error("Template reload failed: {0}")]
127    TemplateReload(String),
128}
129
130impl CamelError {
131    pub fn classify(&self) -> &'static str {
132        #[allow(unreachable_patterns)]
133        match self {
134            Self::ComponentNotFound(_) => "component",
135            Self::EndpointCreationFailed(_) | Self::InvalidUri(_) => "endpoint",
136            Self::ProcessorError(_) | Self::ProcessorErrorWithSource(_, _) => "processor",
137            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
138            Self::Io(_) => "io",
139            Self::RouteError(_) => "route",
140            Self::CircuitOpen(_) => "circuit_open",
141            Self::HttpOperationFailed { .. } => "http",
142            Self::Config(_) | Self::ConfigValidation(_) => "config",
143            Self::DeadLetterChannelFailed(_) => "dead_letter",
144            Self::ConsumerStopping => "consumer_stop",
145            Self::StreamLimitExceeded(_) => "stream",
146            Self::ChannelClosed => "channel",
147            Self::Unauthenticated(_) => "unauthenticated",
148            Self::Unauthorized(_) => "unauthorized",
149            Self::ValidationError(_) => "validation",
150            Self::TemplateReload(_) => "template",
151            _ => "unknown",
152        }
153    }
154
155    /// Stable variant name used by `doTry` catch-by-variant matchers.
156    ///
157    /// `ProcessorErrorWithSource` aliases to `"ProcessorError"` — the two variants are
158    /// not distinguishable by name in MVP (see spec §5.4).
159    ///
160    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
161    /// so internal exhaustive matching is allowed. Adding a new variant without updating
162    /// this method will fail to compile, surfaced by `variant_name_tests`.
163    pub fn variant_name(&self) -> &'static str {
164        match self {
165            Self::ComponentNotFound(_) => "ComponentNotFound",
166            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
167            Self::ProcessorError(_) => "ProcessorError",
168            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
169            Self::TypeConversionFailed(_) => "TypeConversionFailed",
170            Self::InvalidUri(_) => "InvalidUri",
171            Self::ChannelClosed => "ChannelClosed",
172            Self::RouteError(_) => "RouteError",
173            Self::Io(_) => "Io",
174            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
175            Self::CircuitOpen(_) => "CircuitOpen",
176            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
177            Self::ConsumerStopping => "ConsumerStopping",
178            Self::Config(_) => "Config",
179            Self::ConfigValidation(_) => "ConfigValidation",
180            Self::AlreadyConsumed => "AlreadyConsumed",
181            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
182            Self::Unauthenticated(_) => "Unauthenticated",
183            Self::Unauthorized(_) => "Unauthorized",
184            Self::ValidationError(_) => "ValidationError",
185            Self::TemplateReload(_) => "TemplateReload",
186        }
187    }
188}
189
190impl From<std::io::Error> for CamelError {
191    fn from(err: std::io::Error) -> Self {
192        CamelError::Io(err.to_string())
193    }
194}
195
196impl From<crate::template::TemplateError> for CamelError {
197    fn from(err: crate::template::TemplateError) -> Self {
198        CamelError::Config(err.to_string())
199    }
200}
201
202impl From<ConfigValidationError> for CamelError {
203    fn from(e: ConfigValidationError) -> Self {
204        CamelError::ConfigValidation(e)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn all_error_samples() -> Vec<CamelError> {
213        vec![
214            CamelError::ComponentNotFound("x".to_string()),
215            CamelError::EndpointCreationFailed("x".to_string()),
216            CamelError::ProcessorError("x".to_string()),
217            CamelError::ProcessorErrorWithSource(
218                "x".to_string(),
219                Arc::new(std::io::Error::other("inner")),
220            ),
221            CamelError::TypeConversionFailed("x".to_string()),
222            CamelError::InvalidUri("x".to_string()),
223            CamelError::ChannelClosed,
224            CamelError::RouteError("x".to_string()),
225            CamelError::Io("x".to_string()),
226            CamelError::DeadLetterChannelFailed("x".to_string()),
227            CamelError::CircuitOpen("x".to_string()),
228            CamelError::HttpOperationFailed {
229                method: "GET".to_string(),
230                url: "https://example.com".to_string(),
231                status_code: 500,
232                status_text: "Internal Server Error".to_string(),
233                response_body: Some("error".to_string()),
234            },
235            CamelError::ConsumerStopping,
236            CamelError::Config("x".to_string()),
237            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
238            CamelError::AlreadyConsumed,
239            CamelError::StreamLimitExceeded(42),
240            CamelError::Unauthenticated("token expired".to_string()),
241            CamelError::Unauthorized("missing admin role".to_string()),
242            CamelError::ValidationError("body does not match schema".to_string()),
243            CamelError::TemplateReload("reload failed".to_string()),
244        ]
245    }
246
247    #[test]
248    fn test_http_operation_failed_display() {
249        let err = CamelError::HttpOperationFailed {
250            method: "GET".to_string(),
251            url: "https://example.com/test".to_string(),
252            status_code: 404,
253            status_text: "Not Found".to_string(),
254            response_body: Some("page not found".to_string()),
255        };
256        let msg = format!("{err}");
257        assert!(msg.contains("404"));
258        assert!(msg.contains("Not Found"));
259    }
260
261    #[test]
262    fn test_http_operation_failed_clone() {
263        let err = CamelError::HttpOperationFailed {
264            method: "POST".to_string(),
265            url: "https://api.example.com/users".to_string(),
266            status_code: 500,
267            status_text: "Internal Server Error".to_string(),
268            response_body: None,
269        };
270        let cloned = err.clone();
271        assert!(matches!(
272            cloned,
273            CamelError::HttpOperationFailed {
274                status_code: 500,
275                ..
276            }
277        ));
278    }
279
280    #[test]
281    fn test_classify_maps_all_variants() {
282        assert_eq!(
283            CamelError::ComponentNotFound("x".to_string()).classify(),
284            "component"
285        );
286        assert_eq!(
287            CamelError::EndpointCreationFailed("x".to_string()).classify(),
288            "endpoint"
289        );
290        assert_eq!(
291            CamelError::ProcessorError("x".to_string()).classify(),
292            "processor"
293        );
294        assert_eq!(
295            CamelError::TypeConversionFailed("x".to_string()).classify(),
296            "type_conversion"
297        );
298        assert_eq!(
299            CamelError::InvalidUri("x".to_string()).classify(),
300            "endpoint"
301        );
302        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
303        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
304        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
305        assert_eq!(
306            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
307            "dead_letter"
308        );
309        assert_eq!(
310            CamelError::CircuitOpen("x".to_string()).classify(),
311            "circuit_open"
312        );
313        assert_eq!(
314            CamelError::HttpOperationFailed {
315                method: "GET".to_string(),
316                url: "https://example.com".to_string(),
317                status_code: 500,
318                status_text: "Internal Server Error".to_string(),
319                response_body: None,
320            }
321            .classify(),
322            "http"
323        );
324        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
325        assert_eq!(
326            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
327                .classify(),
328            "config"
329        );
330        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
331        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
332        assert_eq!(
333            CamelError::ValidationError("bad".to_string()).classify(),
334            "validation"
335        );
336    }
337
338    #[test]
339    fn test_classify_output_is_ascii_and_short() {
340        for error in all_error_samples() {
341            let class = error.classify();
342            assert!(class.is_ascii());
343            assert!(class.len() <= 15, "class too long: {class}");
344        }
345    }
346
347    #[test]
348    fn test_auth_variants_classify() {
349        assert_eq!(
350            CamelError::Unauthenticated("x".to_string()).classify(),
351            "unauthenticated"
352        );
353        assert_eq!(
354            CamelError::Unauthorized("x".to_string()).classify(),
355            "unauthorized"
356        );
357    }
358
359    #[test]
360    fn test_validation_error_classify() {
361        assert_eq!(
362            CamelError::ValidationError("bad".to_string()).classify(),
363            "validation"
364        );
365    }
366
367    #[test]
368    fn template_reload_classifies_as_template() {
369        let err = CamelError::TemplateReload("boom".into());
370        assert_eq!(err.classify(), "template");
371    }
372
373    #[test]
374    fn template_reload_variant_name() {
375        let err = CamelError::TemplateReload("boom".into());
376        assert_eq!(err.variant_name(), "TemplateReload");
377    }
378
379    #[test]
380    fn test_auth_variants_are_clone() {
381        let err = CamelError::Unauthenticated("test".to_string());
382        let cloned = err.clone();
383        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
384
385        let err2 = CamelError::Unauthorized("test".to_string());
386        let cloned2 = err2.clone();
387        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
388    }
389}
390
391#[cfg(test)]
392mod variant_name_tests {
393    use super::{CamelError, ConfigValidationError};
394    use std::sync::Arc;
395
396    /// Representative value for each enum variant. This test fails to compile
397    /// when a new variant is added to CamelError without updating variant_name().
398    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
399    /// exhaustive matching is allowed.
400    #[test]
401    fn variant_name_covers_all_variants() {
402        let cases: Vec<(CamelError, &str)> = vec![
403            (
404                CamelError::ComponentNotFound("x".into()),
405                "ComponentNotFound",
406            ),
407            (
408                CamelError::EndpointCreationFailed("x".into()),
409                "EndpointCreationFailed",
410            ),
411            (CamelError::ProcessorError("x".into()), "ProcessorError"),
412            (
413                CamelError::ProcessorErrorWithSource(
414                    "x".into(),
415                    Arc::new(std::io::Error::other("y")),
416                ),
417                "ProcessorError", // aliased
418            ),
419            (
420                CamelError::TypeConversionFailed("x".into()),
421                "TypeConversionFailed",
422            ),
423            (CamelError::InvalidUri("x".into()), "InvalidUri"),
424            (CamelError::ChannelClosed, "ChannelClosed"),
425            (CamelError::RouteError("x".into()), "RouteError"),
426            (CamelError::Io("x".into()), "Io"),
427            (
428                CamelError::DeadLetterChannelFailed("x".into()),
429                "DeadLetterChannelFailed",
430            ),
431            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
432            (
433                CamelError::HttpOperationFailed {
434                    method: "GET".into(),
435                    url: "https://example.com".into(),
436                    status_code: 500,
437                    status_text: "Internal Server Error".into(),
438                    response_body: None,
439                },
440                "HttpOperationFailed",
441            ),
442            (CamelError::Config("x".into()), "Config"),
443            (
444                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
445                "ConfigValidation",
446            ),
447            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
448            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
449            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
450            (CamelError::Unauthorized("x".into()), "Unauthorized"),
451            (CamelError::ValidationError("bad".into()), "ValidationError"),
452            (CamelError::TemplateReload("x".into()), "TemplateReload"),
453        ];
454
455        for (err, expected) in cases {
456            assert_eq!(
457                err.variant_name(),
458                expected,
459                "variant_name mismatch for {:?}",
460                err
461            );
462        }
463    }
464}