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/// Typed error for constructing an [`EndpointUri`](crate::EndpointUri) from a base URI
49/// plus a `parameters:` map.
50///
51/// Every variant names the offending key or input in its `Display` text so failures
52/// are diagnosable without losing the context of what was rejected.
53#[derive(Debug, Clone, PartialEq, Eq, Error)]
54#[non_exhaustive]
55pub enum EndpointUriError {
56    /// A `parameters:` key collides with a key already present in the base URI query.
57    #[error(
58        "endpoint URI parameter `{key}` duplicates a key already present in the base URI query"
59    )]
60    DuplicateKey { key: String },
61
62    /// The base URI has no non-empty scheme (no `:` before the path).
63    #[error("endpoint URI is missing a scheme (expected `scheme:path`)")]
64    MissingScheme,
65
66    /// The base URI query contains a pair with an empty key (e.g. `?=value`).
67    #[error("endpoint URI query contains a pair with an empty key")]
68    EmptyQueryKey,
69
70    /// A `parameters:` key is empty or contains a reserved/unsafe character.
71    #[error("endpoint URI parameter key `{key}` is empty or contains a reserved character")]
72    InvalidParamKey { key: String },
73}
74
75/// Core error type for the Camel framework.
76#[derive(Debug, Clone, Error)]
77#[non_exhaustive]
78pub enum CamelError {
79    #[error("Component not found: {0}")]
80    ComponentNotFound(String),
81
82    #[error("Endpoint creation failed: {0}")]
83    EndpointCreationFailed(String),
84
85    #[error("Processor error: {0}")]
86    ProcessorError(String),
87
88    /// Like `ProcessorError` but preserves the source error chain
89    /// for downstream inspection (e.g. via `std::error::Error::source()`).
90    #[error("Processor error: {0}")]
91    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
92
93    #[error("Type conversion failed: {0}")]
94    TypeConversionFailed(String),
95
96    #[error("Invalid URI: {0}")]
97    InvalidUri(String),
98
99    #[error("Channel closed")]
100    ChannelClosed,
101
102    #[error("Route error: {0}")]
103    RouteError(String),
104
105    #[error("IO error: {0}")]
106    Io(String),
107
108    #[error("Dead letter channel failed: {0}")]
109    DeadLetterChannelFailed(String),
110
111    #[error("Circuit breaker open: {0}")]
112    CircuitOpen(String),
113
114    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
115    HttpOperationFailed {
116        method: String,
117        url: String,
118        status_code: u16,
119        status_text: String,
120        response_body: Option<String>,
121    },
122
123    /// Producer's `poll_ready` returned a shutdown signal — the consumer/semaphore
124    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
125    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
126    #[error("Consumer stopping: semaphore closed during poll_ready")]
127    ConsumerStopping,
128
129    #[error("Configuration error: {0}")]
130    Config(String),
131
132    /// Typed security-validation error (ADR-0033). Promotes Batch 1+ config/startup
133    /// failure modes from stringly-typed `Config(_)` to a matchable enum so
134    /// operators can discriminate programmatically.
135    #[error("Configuration validation error: {0}")]
136    ConfigValidation(ConfigValidationError),
137
138    #[error("Body stream has already been consumed")]
139    AlreadyConsumed,
140
141    #[error("Stream size exceeded limit: {0}")]
142    StreamLimitExceeded(usize),
143
144    #[error("Unauthenticated: {0}")]
145    Unauthenticated(String),
146
147    #[error("Unauthorized: {0}")]
148    Unauthorized(String),
149
150    #[error("Validation failed: {0}")]
151    ValidationError(String),
152
153    #[error("Template reload failed: {0}")]
154    TemplateReload(String),
155
156    /// Typed endpoint-URI construction error (see [`EndpointUriError`]). Promotes the
157    /// fail-closed `EndpointUri` merge failures from stringly-typed errors to a matchable
158    /// variant so operators can discriminate programmatically.
159    #[error("Endpoint URI error: {0}")]
160    EndpointUri(EndpointUriError),
161}
162
163impl CamelError {
164    pub fn classify(&self) -> &'static str {
165        #[allow(unreachable_patterns)]
166        match self {
167            Self::ComponentNotFound(_) => "component",
168            Self::EndpointCreationFailed(_) | Self::InvalidUri(_) | Self::EndpointUri(_) => {
169                "endpoint"
170            }
171            Self::ProcessorError(_) | Self::ProcessorErrorWithSource(_, _) => "processor",
172            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
173            Self::Io(_) => "io",
174            Self::RouteError(_) => "route",
175            Self::CircuitOpen(_) => "circuit_open",
176            Self::HttpOperationFailed { .. } => "http",
177            Self::Config(_) | Self::ConfigValidation(_) => "config",
178            Self::DeadLetterChannelFailed(_) => "dead_letter",
179            Self::ConsumerStopping => "consumer_stop",
180            Self::StreamLimitExceeded(_) => "stream",
181            Self::ChannelClosed => "channel",
182            Self::Unauthenticated(_) => "unauthenticated",
183            Self::Unauthorized(_) => "unauthorized",
184            Self::ValidationError(_) => "validation",
185            Self::TemplateReload(_) => "template",
186            _ => "unknown",
187        }
188    }
189
190    /// Stable variant name used by `doTry` catch-by-variant matchers.
191    ///
192    /// `ProcessorErrorWithSource` aliases to `"ProcessorError"` — the two variants are
193    /// not distinguishable by name in MVP (see spec §5.4).
194    ///
195    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
196    /// so internal exhaustive matching is allowed. Adding a new variant without updating
197    /// this method will fail to compile, surfaced by `variant_name_tests`.
198    pub fn variant_name(&self) -> &'static str {
199        match self {
200            Self::ComponentNotFound(_) => "ComponentNotFound",
201            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
202            Self::ProcessorError(_) => "ProcessorError",
203            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
204            Self::TypeConversionFailed(_) => "TypeConversionFailed",
205            Self::InvalidUri(_) => "InvalidUri",
206            Self::ChannelClosed => "ChannelClosed",
207            Self::RouteError(_) => "RouteError",
208            Self::Io(_) => "Io",
209            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
210            Self::CircuitOpen(_) => "CircuitOpen",
211            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
212            Self::ConsumerStopping => "ConsumerStopping",
213            Self::Config(_) => "Config",
214            Self::ConfigValidation(_) => "ConfigValidation",
215            Self::AlreadyConsumed => "AlreadyConsumed",
216            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
217            Self::Unauthenticated(_) => "Unauthenticated",
218            Self::Unauthorized(_) => "Unauthorized",
219            Self::ValidationError(_) => "ValidationError",
220            Self::TemplateReload(_) => "TemplateReload",
221            Self::EndpointUri(_) => "EndpointUri",
222        }
223    }
224}
225
226impl From<std::io::Error> for CamelError {
227    fn from(err: std::io::Error) -> Self {
228        CamelError::Io(err.to_string())
229    }
230}
231
232impl From<crate::template::TemplateError> for CamelError {
233    fn from(err: crate::template::TemplateError) -> Self {
234        CamelError::Config(err.to_string())
235    }
236}
237
238impl From<ConfigValidationError> for CamelError {
239    fn from(e: ConfigValidationError) -> Self {
240        CamelError::ConfigValidation(e)
241    }
242}
243
244impl From<EndpointUriError> for CamelError {
245    fn from(e: EndpointUriError) -> Self {
246        CamelError::EndpointUri(e)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn all_error_samples() -> Vec<CamelError> {
255        vec![
256            CamelError::ComponentNotFound("x".to_string()),
257            CamelError::EndpointCreationFailed("x".to_string()),
258            CamelError::ProcessorError("x".to_string()),
259            CamelError::ProcessorErrorWithSource(
260                "x".to_string(),
261                Arc::new(std::io::Error::other("inner")),
262            ),
263            CamelError::TypeConversionFailed("x".to_string()),
264            CamelError::InvalidUri("x".to_string()),
265            CamelError::ChannelClosed,
266            CamelError::RouteError("x".to_string()),
267            CamelError::Io("x".to_string()),
268            CamelError::DeadLetterChannelFailed("x".to_string()),
269            CamelError::CircuitOpen("x".to_string()),
270            CamelError::HttpOperationFailed {
271                method: "GET".to_string(),
272                url: "https://example.com".to_string(),
273                status_code: 500,
274                status_text: "Internal Server Error".to_string(),
275                response_body: Some("error".to_string()),
276            },
277            CamelError::ConsumerStopping,
278            CamelError::Config("x".to_string()),
279            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
280            CamelError::AlreadyConsumed,
281            CamelError::StreamLimitExceeded(42),
282            CamelError::Unauthenticated("token expired".to_string()),
283            CamelError::Unauthorized("missing admin role".to_string()),
284            CamelError::ValidationError("body does not match schema".to_string()),
285            CamelError::TemplateReload("reload failed".to_string()),
286            CamelError::EndpointUri(EndpointUriError::MissingScheme),
287        ]
288    }
289
290    #[test]
291    fn test_http_operation_failed_display() {
292        let err = CamelError::HttpOperationFailed {
293            method: "GET".to_string(),
294            url: "https://example.com/test".to_string(),
295            status_code: 404,
296            status_text: "Not Found".to_string(),
297            response_body: Some("page not found".to_string()),
298        };
299        let msg = format!("{err}");
300        assert!(msg.contains("404"));
301        assert!(msg.contains("Not Found"));
302    }
303
304    #[test]
305    fn test_http_operation_failed_clone() {
306        let err = CamelError::HttpOperationFailed {
307            method: "POST".to_string(),
308            url: "https://api.example.com/users".to_string(),
309            status_code: 500,
310            status_text: "Internal Server Error".to_string(),
311            response_body: None,
312        };
313        let cloned = err.clone();
314        assert!(matches!(
315            cloned,
316            CamelError::HttpOperationFailed {
317                status_code: 500,
318                ..
319            }
320        ));
321    }
322
323    #[test]
324    fn test_classify_maps_all_variants() {
325        assert_eq!(
326            CamelError::ComponentNotFound("x".to_string()).classify(),
327            "component"
328        );
329        assert_eq!(
330            CamelError::EndpointCreationFailed("x".to_string()).classify(),
331            "endpoint"
332        );
333        assert_eq!(
334            CamelError::ProcessorError("x".to_string()).classify(),
335            "processor"
336        );
337        assert_eq!(
338            CamelError::TypeConversionFailed("x".to_string()).classify(),
339            "type_conversion"
340        );
341        assert_eq!(
342            CamelError::InvalidUri("x".to_string()).classify(),
343            "endpoint"
344        );
345        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
346        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
347        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
348        assert_eq!(
349            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
350            "dead_letter"
351        );
352        assert_eq!(
353            CamelError::CircuitOpen("x".to_string()).classify(),
354            "circuit_open"
355        );
356        assert_eq!(
357            CamelError::HttpOperationFailed {
358                method: "GET".to_string(),
359                url: "https://example.com".to_string(),
360                status_code: 500,
361                status_text: "Internal Server Error".to_string(),
362                response_body: None,
363            }
364            .classify(),
365            "http"
366        );
367        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
368        assert_eq!(
369            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
370                .classify(),
371            "config"
372        );
373        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
374        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
375        assert_eq!(
376            CamelError::ValidationError("bad".to_string()).classify(),
377            "validation"
378        );
379    }
380
381    #[test]
382    fn test_classify_output_is_ascii_and_short() {
383        for error in all_error_samples() {
384            let class = error.classify();
385            assert!(class.is_ascii());
386            assert!(class.len() <= 15, "class too long: {class}");
387        }
388    }
389
390    #[test]
391    fn test_auth_variants_classify() {
392        assert_eq!(
393            CamelError::Unauthenticated("x".to_string()).classify(),
394            "unauthenticated"
395        );
396        assert_eq!(
397            CamelError::Unauthorized("x".to_string()).classify(),
398            "unauthorized"
399        );
400    }
401
402    #[test]
403    fn test_validation_error_classify() {
404        assert_eq!(
405            CamelError::ValidationError("bad".to_string()).classify(),
406            "validation"
407        );
408    }
409
410    #[test]
411    fn template_reload_classifies_as_template() {
412        let err = CamelError::TemplateReload("boom".into());
413        assert_eq!(err.classify(), "template");
414    }
415
416    #[test]
417    fn template_reload_variant_name() {
418        let err = CamelError::TemplateReload("boom".into());
419        assert_eq!(err.variant_name(), "TemplateReload");
420    }
421
422    #[test]
423    fn test_auth_variants_are_clone() {
424        let err = CamelError::Unauthenticated("test".to_string());
425        let cloned = err.clone();
426        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
427
428        let err2 = CamelError::Unauthorized("test".to_string());
429        let cloned2 = err2.clone();
430        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
431    }
432}
433
434#[cfg(test)]
435mod variant_name_tests {
436    use super::{CamelError, ConfigValidationError, EndpointUriError};
437    use std::sync::Arc;
438
439    /// Representative value for each enum variant. This test fails to compile
440    /// when a new variant is added to CamelError without updating variant_name().
441    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
442    /// exhaustive matching is allowed.
443    #[test]
444    fn variant_name_covers_all_variants() {
445        let cases: Vec<(CamelError, &str)> = vec![
446            (
447                CamelError::ComponentNotFound("x".into()),
448                "ComponentNotFound",
449            ),
450            (
451                CamelError::EndpointCreationFailed("x".into()),
452                "EndpointCreationFailed",
453            ),
454            (CamelError::ProcessorError("x".into()), "ProcessorError"),
455            (
456                CamelError::ProcessorErrorWithSource(
457                    "x".into(),
458                    Arc::new(std::io::Error::other("y")),
459                ),
460                "ProcessorError", // aliased
461            ),
462            (
463                CamelError::TypeConversionFailed("x".into()),
464                "TypeConversionFailed",
465            ),
466            (CamelError::InvalidUri("x".into()), "InvalidUri"),
467            (CamelError::ChannelClosed, "ChannelClosed"),
468            (CamelError::RouteError("x".into()), "RouteError"),
469            (CamelError::Io("x".into()), "Io"),
470            (
471                CamelError::DeadLetterChannelFailed("x".into()),
472                "DeadLetterChannelFailed",
473            ),
474            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
475            (
476                CamelError::HttpOperationFailed {
477                    method: "GET".into(),
478                    url: "https://example.com".into(),
479                    status_code: 500,
480                    status_text: "Internal Server Error".into(),
481                    response_body: None,
482                },
483                "HttpOperationFailed",
484            ),
485            (CamelError::Config("x".into()), "Config"),
486            (
487                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
488                "ConfigValidation",
489            ),
490            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
491            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
492            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
493            (CamelError::Unauthorized("x".into()), "Unauthorized"),
494            (CamelError::ValidationError("bad".into()), "ValidationError"),
495            (CamelError::TemplateReload("x".into()), "TemplateReload"),
496            (
497                CamelError::EndpointUri(EndpointUriError::MissingScheme),
498                "EndpointUri",
499            ),
500        ];
501
502        for (err, expected) in cases {
503            assert_eq!(
504                err.variant_name(),
505                expected,
506                "variant_name mismatch for {:?}",
507                err
508            );
509        }
510    }
511}