Skip to main content

camel_api/
error.rs

1use std::fmt;
2use std::sync::Arc;
3use thiserror::Error;
4
5/// Typed security-validation error for fail-closed config/startup checks (ADR-0033).
6///
7/// Each variant corresponds to a specific Batch 1+ security validation that refuses
8/// to start with a misconfigured or dangerous default. Operators can `match` on
9/// these variants for programmatic error handling.
10#[derive(Debug, Clone, PartialEq, Eq, Error)]
11#[non_exhaustive]
12pub enum ConfigValidationError {
13    #[error(
14        "aggregator config requires at least one completion bound (size, timeout, predicate, or interval)"
15    )]
16    AggregatorMissingCompletionBound,
17
18    /// Raised when an Aggregator has none of: max_buckets, a Timeout completion
19    /// condition, or a bucket_ttl. At least one memory-release bound is mandatory
20    /// (R3-M2) so a unique-correlation-key flood cannot grow the bucket map
21    /// without limit.
22    #[error("aggregator requires at least one of max_buckets, completionTimeout, or bucket_ttl")]
23    AggregatorMissingMemoryBound,
24
25    /// Raised when an Aggregator has a Timeout completion condition but no
26    /// `bucket_ttl`. The R3-M3 timeout-task cap may skip spawning a dedicated
27    /// timeout task under flood; without `bucket_ttl` there is no fallback
28    /// eviction path and the bucket leaks until shutdown. Requiring `bucket_ttl`
29    /// whenever Timeout is present makes the cap-skip degradation safe by
30    /// construction.
31    #[error(
32        "aggregator Timeout completion requires bucket_ttl (memory-release bound for the timeout-task cap fallback)"
33    )]
34    AggregatorTimeoutRequiresTtl,
35
36    #[error("throttler max_requests must be > 0")]
37    ThrottlerMaxRequestsZero,
38
39    #[error("loop step must specify either 'count' or 'while', not both")]
40    LoopConflictingCountAndWhile,
41
42    #[error("on_exceptions clause cannot set both steps and handled_by (delegation is exclusive)")]
43    OnExceptionStepsHandledByConflict,
44
45    #[error("loop step must specify either 'count' or 'while'")]
46    LoopMissingCountOrWhile,
47
48    #[error("SQL use_message_body_for_sql requires allow_dynamic_query=true")]
49    SqlDynamicQueryWithoutAllowDynamic,
50}
51
52/// Typed error for constructing an [`EndpointUri`](crate::EndpointUri) from a base URI
53/// plus a `parameters:` map.
54///
55/// Every variant names the offending key or input in its `Display` text so failures
56/// are diagnosable without losing the context of what was rejected.
57#[derive(Debug, Clone, PartialEq, Eq, Error)]
58#[non_exhaustive]
59pub enum EndpointUriError {
60    /// A `parameters:` key collides with a key already present in the base URI query.
61    #[error(
62        "endpoint URI parameter `{key}` duplicates a key already present in the base URI query"
63    )]
64    DuplicateKey { key: String },
65
66    /// The base URI has no non-empty scheme (no `:` before the path).
67    #[error("endpoint URI is missing a scheme (expected `scheme:path`)")]
68    MissingScheme,
69
70    /// The base URI query contains a pair with an empty key (e.g. `?=value`).
71    #[error("endpoint URI query contains a pair with an empty key")]
72    EmptyQueryKey,
73
74    /// A `parameters:` key is empty or contains a reserved/unsafe character.
75    #[error("endpoint URI parameter key `{key}` is empty or contains a reserved character")]
76    InvalidParamKey { key: String },
77}
78
79/// Opaque handle to an underlying error, preserving its source chain without
80/// exposing the concrete type.
81///
82/// The opacity contract: the pointee is reachable only through
83/// [`std::error::Error::source()`] (returned directly — no `Arc` wrapper hop),
84/// the inner handle is private, there is no public `Clone`, and provenance
85/// cannot be extracted outside camel-api (short of `unsafe`). Crate internals
86/// duplicate the handle via `OpaqueErrorSource::clone_handle` when cloning a
87/// [`CamelError`].
88///
89/// # Examples
90///
91/// The inner handle cannot be destructured out of the wrapper (private field):
92///
93/// ```compile_fail
94/// use camel_api::OpaqueErrorSource;
95///
96/// #[derive(Debug)]
97/// struct MyError;
98///
99/// impl std::fmt::Display for MyError {
100///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101///         f.write_str("my error")
102///     }
103/// }
104///
105/// impl std::error::Error for MyError {}
106///
107/// let OpaqueErrorSource(inner) = OpaqueErrorSource::new(std::sync::Arc::new(MyError));
108/// ```
109///
110/// The wrapper is deliberately not `Clone`, so callers cannot copy the handle
111/// out of camel-api:
112///
113/// ```compile_fail
114/// use camel_api::OpaqueErrorSource;
115///
116/// #[derive(Debug)]
117/// struct MyError;
118///
119/// impl std::fmt::Display for MyError {
120///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121///         f.write_str("my error")
122///     }
123/// }
124///
125/// impl std::error::Error for MyError {}
126///
127/// let s = OpaqueErrorSource::new(std::sync::Arc::new(MyError));
128/// let _ = s.clone();
129/// ```
130#[derive(Debug)]
131pub struct OpaqueErrorSource(Arc<dyn std::error::Error + Send + Sync>);
132
133impl OpaqueErrorSource {
134    /// Wrap an existing error as an opaque source.
135    pub fn new(source: Arc<dyn std::error::Error + Send + Sync>) -> Self {
136        Self(source)
137    }
138
139    /// Duplicate the inner handle for the manual `Clone` impl on
140    /// [`CamelError`]. Crate-private by design: the wrapper itself is not
141    /// `Clone`, so external code cannot duplicate provenance out of camel-api.
142    fn clone_handle(&self) -> Self {
143        Self(Arc::clone(&self.0))
144    }
145}
146
147impl fmt::Display for OpaqueErrorSource {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        self.0.fmt(f)
150    }
151}
152
153impl std::error::Error for OpaqueErrorSource {
154    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
155        // Pointee directly — NO Arc wrapper hop, so `downcast_ref` on the
156        // returned trait object reaches the wrapped error itself.
157        Some(self.0.as_ref())
158    }
159}
160
161/// Core error type for the Camel framework.
162#[derive(Debug, Error)]
163#[non_exhaustive]
164pub enum CamelError {
165    #[error("Component not found: {0}")]
166    ComponentNotFound(String),
167
168    #[error("Endpoint creation failed: {0}")]
169    EndpointCreationFailed(String),
170
171    /// Like `EndpointCreationFailed` but preserves the source error chain
172    /// for downstream inspection (e.g. typed gate-rejection classification).
173    #[error("Endpoint creation failed: {0}")]
174    EndpointCreationFailedWithSource(String, #[source] OpaqueErrorSource),
175
176    #[error("Processor error: {0}")]
177    ProcessorError(String),
178
179    /// Like `ProcessorError` but preserves the source error chain
180    /// for downstream inspection (e.g. via `std::error::Error::source()`).
181    #[error("Processor error: {0}")]
182    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
183
184    #[error("Type conversion failed: {0}")]
185    TypeConversionFailed(String),
186
187    #[error("Invalid URI: {0}")]
188    InvalidUri(String),
189
190    #[error("Channel closed")]
191    ChannelClosed,
192
193    #[error("Route error: {0}")]
194    RouteError(String),
195
196    #[error("IO error: {0}")]
197    Io(String),
198
199    #[error("Dead letter channel failed: {0}")]
200    DeadLetterChannelFailed(String),
201
202    #[error("Circuit breaker open: {0}")]
203    CircuitOpen(String),
204
205    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
206    HttpOperationFailed {
207        method: String,
208        url: String,
209        status_code: u16,
210        status_text: String,
211        response_body: Option<String>,
212    },
213
214    /// Producer `call()` failed on a shutdown signal — the consumer/semaphore
215    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
216    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
217    #[error("Consumer stopping: semaphore closed during call")]
218    ConsumerStopping,
219
220    #[error("Configuration error: {0}")]
221    Config(String),
222
223    /// Typed security-validation error (ADR-0033). Promotes Batch 1+ config/startup
224    /// failure modes from stringly-typed `Config(_)` to a matchable enum so
225    /// operators can discriminate programmatically.
226    #[error("Configuration validation error: {0}")]
227    ConfigValidation(ConfigValidationError),
228
229    #[error("Body stream has already been consumed")]
230    AlreadyConsumed,
231
232    #[error("Stream size exceeded limit: {0}")]
233    StreamLimitExceeded(usize),
234
235    #[error("Unauthenticated: {0}")]
236    Unauthenticated(String),
237
238    #[error("Unauthorized: {0}")]
239    Unauthorized(String),
240
241    /// Auth provider (JWKS/introspection/token endpoint) is unreachable or failing.
242    /// Promotes the auth-provider-down signal from a stringly-typed ProcessorError to
243    /// a matchable variant; WebSocket and gRPC transports map it to 503 / UNAVAILABLE.
244    #[error("Auth provider unavailable: {0}")]
245    AuthProviderUnavailable(String),
246
247    #[error("Validation failed: {0}")]
248    ValidationError(String),
249
250    #[error("Template reload failed: {0}")]
251    TemplateReload(String),
252
253    /// Typed endpoint-URI construction error (see [`EndpointUriError`]). Promotes the
254    /// fail-closed `EndpointUri` merge failures from stringly-typed errors to a matchable
255    /// variant so operators can discriminate programmatically.
256    #[error("Endpoint URI error: {0}")]
257    EndpointUri(EndpointUriError),
258
259    /// The request body media type does not match the declared/consumed type
260    /// (REST DSL default-strict content negotiation, HTTP 415).
261    #[error("Unsupported media type: consumed {consumed}, declared {declared}")]
262    UnsupportedMediaType { consumed: String, declared: String },
263
264    /// The response representation cannot satisfy the client's Accept header
265    /// (REST DSL default-strict content negotiation, HTTP 406).
266    #[error("Not acceptable: accept {accept}, produced {produced}")]
267    NotAcceptable { accept: String, produced: String },
268}
269
270/// Manual `Clone` impl: every arm clones its fields normally, except
271/// `EndpointCreationFailedWithSource`, which duplicates the opaque source
272/// handle via the crate-private `OpaqueErrorSource::clone_handle` (the wrapper
273/// itself is deliberately not `Clone`). Exhaustive like `variant_name()` — a
274/// new variant without an arm fails compilation.
275impl Clone for CamelError {
276    fn clone(&self) -> Self {
277        match self {
278            Self::ComponentNotFound(msg) => Self::ComponentNotFound(msg.clone()),
279            Self::EndpointCreationFailed(msg) => Self::EndpointCreationFailed(msg.clone()),
280            Self::EndpointCreationFailedWithSource(msg, source) => {
281                Self::EndpointCreationFailedWithSource(msg.clone(), source.clone_handle())
282            }
283            Self::ProcessorError(msg) => Self::ProcessorError(msg.clone()),
284            Self::ProcessorErrorWithSource(msg, source) => {
285                Self::ProcessorErrorWithSource(msg.clone(), Arc::clone(source))
286            }
287            Self::TypeConversionFailed(msg) => Self::TypeConversionFailed(msg.clone()),
288            Self::InvalidUri(msg) => Self::InvalidUri(msg.clone()),
289            Self::ChannelClosed => Self::ChannelClosed,
290            Self::RouteError(msg) => Self::RouteError(msg.clone()),
291            Self::Io(msg) => Self::Io(msg.clone()),
292            Self::DeadLetterChannelFailed(msg) => Self::DeadLetterChannelFailed(msg.clone()),
293            Self::CircuitOpen(msg) => Self::CircuitOpen(msg.clone()),
294            Self::HttpOperationFailed {
295                method,
296                url,
297                status_code,
298                status_text,
299                response_body,
300            } => Self::HttpOperationFailed {
301                method: method.clone(),
302                url: url.clone(),
303                status_code: *status_code,
304                status_text: status_text.clone(),
305                response_body: response_body.clone(),
306            },
307            Self::ConsumerStopping => Self::ConsumerStopping,
308            Self::Config(msg) => Self::Config(msg.clone()),
309            Self::ConfigValidation(e) => Self::ConfigValidation(e.clone()),
310            Self::AlreadyConsumed => Self::AlreadyConsumed,
311            Self::StreamLimitExceeded(limit) => Self::StreamLimitExceeded(*limit),
312            Self::Unauthenticated(msg) => Self::Unauthenticated(msg.clone()),
313            Self::Unauthorized(msg) => Self::Unauthorized(msg.clone()),
314            Self::AuthProviderUnavailable(msg) => Self::AuthProviderUnavailable(msg.clone()),
315            Self::ValidationError(msg) => Self::ValidationError(msg.clone()),
316            Self::TemplateReload(msg) => Self::TemplateReload(msg.clone()),
317            Self::EndpointUri(e) => Self::EndpointUri(e.clone()),
318            Self::UnsupportedMediaType { consumed, declared } => Self::UnsupportedMediaType {
319                consumed: consumed.clone(),
320                declared: declared.clone(),
321            },
322            Self::NotAcceptable { accept, produced } => Self::NotAcceptable {
323                accept: accept.clone(),
324                produced: produced.clone(),
325            },
326        }
327    }
328}
329
330/// Classification marker for `CamelError::CircuitOpen`.
331///
332/// Shared named constant so the pipeline tracer's circuit-open exclusion
333/// (skip `increment_errors` — the breaker already recorded the rejection)
334/// and `classify` itself cannot drift apart (dashboard-observability D2).
335pub const CIRCUIT_OPEN: &str = "circuit_open";
336
337impl CamelError {
338    pub fn classify(&self) -> &'static str {
339        #[allow(unreachable_patterns)]
340        match self {
341            Self::ComponentNotFound(_) => "component",
342            Self::EndpointCreationFailed(_)
343            | Self::EndpointCreationFailedWithSource(_, _)
344            | Self::InvalidUri(_)
345            | Self::EndpointUri(_) => "endpoint",
346            Self::ProcessorError(_)
347            | Self::ProcessorErrorWithSource(_, _)
348            | Self::AuthProviderUnavailable(_) => "processor",
349            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
350            Self::Io(_) => "io",
351            Self::RouteError(_) => "route",
352            Self::CircuitOpen(_) => CIRCUIT_OPEN,
353            Self::HttpOperationFailed { .. } => "http",
354            Self::Config(_) | Self::ConfigValidation(_) => "config",
355            Self::DeadLetterChannelFailed(_) => "dead_letter",
356            Self::ConsumerStopping => "consumer_stop",
357            Self::StreamLimitExceeded(_) => "stream",
358            Self::ChannelClosed => "channel",
359            Self::Unauthenticated(_) => "unauthenticated",
360            Self::Unauthorized(_) => "unauthorized",
361            Self::ValidationError(_) => "validation",
362            Self::TemplateReload(_) => "template",
363            Self::UnsupportedMediaType { .. } => "unsupported_media_type",
364            Self::NotAcceptable { .. } => "not_acceptable",
365            _ => "unknown",
366        }
367    }
368
369    /// Stable variant name used by `doTry` catch-by-variant matchers.
370    ///
371    /// `ProcessorErrorWithSource` and `AuthProviderUnavailable` alias to
372    /// `"ProcessorError"`, and `EndpointCreationFailedWithSource` aliases to
373    /// `"EndpointCreationFailed"` — the aliased variants are not distinguishable
374    /// by name in MVP (see spec §5.4), so existing `doTry` catch handlers keep
375    /// matching.
376    ///
377    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
378    /// so internal exhaustive matching is allowed. Adding a new variant without updating
379    /// this method will fail to compile, surfaced by `variant_name_tests`.
380    pub fn variant_name(&self) -> &'static str {
381        match self {
382            Self::ComponentNotFound(_) => "ComponentNotFound",
383            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
384            Self::EndpointCreationFailedWithSource(_, _) => "EndpointCreationFailed",
385            Self::ProcessorError(_) => "ProcessorError",
386            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
387            Self::AuthProviderUnavailable(_) => "ProcessorError",
388            Self::TypeConversionFailed(_) => "TypeConversionFailed",
389            Self::InvalidUri(_) => "InvalidUri",
390            Self::ChannelClosed => "ChannelClosed",
391            Self::RouteError(_) => "RouteError",
392            Self::Io(_) => "Io",
393            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
394            Self::CircuitOpen(_) => "CircuitOpen",
395            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
396            Self::ConsumerStopping => "ConsumerStopping",
397            Self::Config(_) => "Config",
398            Self::ConfigValidation(_) => "ConfigValidation",
399            Self::AlreadyConsumed => "AlreadyConsumed",
400            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
401            Self::Unauthenticated(_) => "Unauthenticated",
402            Self::Unauthorized(_) => "Unauthorized",
403            Self::ValidationError(_) => "ValidationError",
404            Self::TemplateReload(_) => "TemplateReload",
405            Self::EndpointUri(_) => "EndpointUri",
406            Self::UnsupportedMediaType { .. } => "UnsupportedMediaType",
407            Self::NotAcceptable { .. } => "NotAcceptable",
408        }
409    }
410}
411
412impl From<std::io::Error> for CamelError {
413    fn from(err: std::io::Error) -> Self {
414        CamelError::Io(err.to_string())
415    }
416}
417
418impl From<crate::template::TemplateError> for CamelError {
419    fn from(err: crate::template::TemplateError) -> Self {
420        CamelError::Config(err.to_string())
421    }
422}
423
424impl From<ConfigValidationError> for CamelError {
425    fn from(e: ConfigValidationError) -> Self {
426        CamelError::ConfigValidation(e)
427    }
428}
429
430impl From<EndpointUriError> for CamelError {
431    fn from(e: EndpointUriError) -> Self {
432        CamelError::EndpointUri(e)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    // `super::*` brings in thiserror's `Error` derive macro; import the trait
440    // anonymously so `source()` is callable in tests.
441    use std::error::Error as _;
442
443    /// Minimal source error for opaque-provenance tests.
444    #[derive(Debug)]
445    struct SampleSource;
446
447    impl fmt::Display for SampleSource {
448        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449            f.write_str("sample source")
450        }
451    }
452
453    impl std::error::Error for SampleSource {}
454
455    fn all_error_samples() -> Vec<CamelError> {
456        vec![
457            CamelError::ComponentNotFound("x".to_string()),
458            CamelError::EndpointCreationFailed("x".to_string()),
459            CamelError::EndpointCreationFailedWithSource(
460                "x".to_string(),
461                OpaqueErrorSource::new(Arc::new(SampleSource)),
462            ),
463            CamelError::ProcessorError("x".to_string()),
464            CamelError::ProcessorErrorWithSource(
465                "x".to_string(),
466                Arc::new(std::io::Error::other("inner")),
467            ),
468            CamelError::TypeConversionFailed("x".to_string()),
469            CamelError::InvalidUri("x".to_string()),
470            CamelError::ChannelClosed,
471            CamelError::RouteError("x".to_string()),
472            CamelError::Io("x".to_string()),
473            CamelError::DeadLetterChannelFailed("x".to_string()),
474            CamelError::CircuitOpen("x".to_string()),
475            CamelError::HttpOperationFailed {
476                method: "GET".to_string(),
477                url: "https://example.com".to_string(),
478                status_code: 500,
479                status_text: "Internal Server Error".to_string(),
480                response_body: Some("error".to_string()),
481            },
482            CamelError::ConsumerStopping,
483            CamelError::Config("x".to_string()),
484            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
485            CamelError::AlreadyConsumed,
486            CamelError::StreamLimitExceeded(42),
487            CamelError::Unauthenticated("token expired".to_string()),
488            CamelError::Unauthorized("missing admin role".to_string()),
489            CamelError::AuthProviderUnavailable("jwks down".to_string()),
490            CamelError::ValidationError("body does not match schema".to_string()),
491            CamelError::TemplateReload("reload failed".to_string()),
492            CamelError::EndpointUri(EndpointUriError::MissingScheme),
493            CamelError::UnsupportedMediaType {
494                consumed: "text/plain".to_string(),
495                declared: "application/json".to_string(),
496            },
497            CamelError::NotAcceptable {
498                accept: "application/xml".to_string(),
499                produced: "application/json".to_string(),
500            },
501        ]
502    }
503
504    #[test]
505    fn test_http_operation_failed_display() {
506        let err = CamelError::HttpOperationFailed {
507            method: "GET".to_string(),
508            url: "https://example.com/test".to_string(),
509            status_code: 404,
510            status_text: "Not Found".to_string(),
511            response_body: Some("page not found".to_string()),
512        };
513        let msg = format!("{err}");
514        assert!(msg.contains("404"));
515        assert!(msg.contains("Not Found"));
516    }
517
518    #[test]
519    fn test_http_operation_failed_clone() {
520        let err = CamelError::HttpOperationFailed {
521            method: "POST".to_string(),
522            url: "https://api.example.com/users".to_string(),
523            status_code: 500,
524            status_text: "Internal Server Error".to_string(),
525            response_body: None,
526        };
527        let cloned = err.clone();
528        assert!(matches!(
529            cloned,
530            CamelError::HttpOperationFailed {
531                status_code: 500,
532                ..
533            }
534        ));
535    }
536
537    #[test]
538    fn test_classify_maps_all_variants() {
539        assert_eq!(
540            CamelError::ComponentNotFound("x".to_string()).classify(),
541            "component"
542        );
543        assert_eq!(
544            CamelError::EndpointCreationFailed("x".to_string()).classify(),
545            "endpoint"
546        );
547        assert_eq!(
548            CamelError::ProcessorError("x".to_string()).classify(),
549            "processor"
550        );
551        assert_eq!(
552            CamelError::TypeConversionFailed("x".to_string()).classify(),
553            "type_conversion"
554        );
555        assert_eq!(
556            CamelError::InvalidUri("x".to_string()).classify(),
557            "endpoint"
558        );
559        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
560        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
561        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
562        assert_eq!(
563            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
564            "dead_letter"
565        );
566        assert_eq!(
567            CamelError::CircuitOpen("x".to_string()).classify(),
568            "circuit_open"
569        );
570        assert_eq!(
571            CamelError::HttpOperationFailed {
572                method: "GET".to_string(),
573                url: "https://example.com".to_string(),
574                status_code: 500,
575                status_text: "Internal Server Error".to_string(),
576                response_body: None,
577            }
578            .classify(),
579            "http"
580        );
581        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
582        assert_eq!(
583            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
584                .classify(),
585            "config"
586        );
587        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
588        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
589        assert_eq!(
590            CamelError::ValidationError("bad".to_string()).classify(),
591            "validation"
592        );
593    }
594
595    #[test]
596    fn test_classify_output_is_ascii_and_short() {
597        for error in all_error_samples() {
598            let class = error.classify();
599            assert!(class.is_ascii());
600            // "unsupported_media_type" (REST negotiation, L2) sets the floor at 22
601            assert!(class.len() <= 22, "class too long: {class}");
602        }
603    }
604
605    #[test]
606    fn test_auth_variants_classify() {
607        assert_eq!(
608            CamelError::Unauthenticated("x".to_string()).classify(),
609            "unauthenticated"
610        );
611        assert_eq!(
612            CamelError::Unauthorized("x".to_string()).classify(),
613            "unauthorized"
614        );
615    }
616
617    #[test]
618    fn test_validation_error_classify() {
619        assert_eq!(
620            CamelError::ValidationError("bad".to_string()).classify(),
621            "validation"
622        );
623    }
624
625    #[test]
626    fn template_reload_classifies_as_template() {
627        let err = CamelError::TemplateReload("boom".into());
628        assert_eq!(err.classify(), "template");
629    }
630
631    #[test]
632    fn template_reload_variant_name() {
633        let err = CamelError::TemplateReload("boom".into());
634        assert_eq!(err.variant_name(), "TemplateReload");
635    }
636
637    #[test]
638    fn test_auth_variants_are_clone() {
639        let err = CamelError::Unauthenticated("test".to_string());
640        let cloned = err.clone();
641        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
642
643        let err2 = CamelError::Unauthorized("test".to_string());
644        let cloned2 = err2.clone();
645        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
646    }
647
648    #[test]
649    fn classification_unchanged_for_callers() {
650        // Pins the contract the pipeline-tracer circuit_open exclusion
651        // relies on (dashboard-observability D2): CircuitOpen must keep
652        // classifying as "circuit_open" — callers and the tracer skip
653        // branch match on exactly this literal.
654        assert_eq!(
655            CamelError::CircuitOpen("breaker open".into()).classify(),
656            "circuit_open"
657        );
658    }
659
660    #[test]
661    fn auth_provider_unavailable_display_carries_detail() {
662        let err = CamelError::AuthProviderUnavailable("conn refused".into());
663        let msg = err.to_string();
664        assert!(msg.contains("conn refused"));
665        assert!(
666            msg.starts_with("Auth provider unavailable"),
667            "display should start with 'Auth provider unavailable', got: {msg}"
668        );
669    }
670
671    #[test]
672    fn classify_negotiation_errors() {
673        let unsupported = CamelError::UnsupportedMediaType {
674            consumed: "text/plain".into(),
675            declared: "application/json".into(),
676        };
677        let not_acceptable = CamelError::NotAcceptable {
678            accept: "application/xml".into(),
679            produced: "application/json".into(),
680        };
681        assert_eq!(unsupported.classify(), "unsupported_media_type");
682        assert_eq!(not_acceptable.classify(), "not_acceptable");
683    }
684
685    #[test]
686    fn variant_names_negotiation_errors() {
687        let unsupported = CamelError::UnsupportedMediaType {
688            consumed: "text/plain".into(),
689            declared: "application/json".into(),
690        };
691        let not_acceptable = CamelError::NotAcceptable {
692            accept: "application/xml".into(),
693            produced: "application/json".into(),
694        };
695        assert_eq!(unsupported.variant_name(), "UnsupportedMediaType");
696        assert_eq!(not_acceptable.variant_name(), "NotAcceptable");
697    }
698
699    #[test]
700    fn display_negotiation_errors() {
701        let unsupported = CamelError::UnsupportedMediaType {
702            consumed: "text/plain".into(),
703            declared: "application/json".into(),
704        };
705        let not_acceptable = CamelError::NotAcceptable {
706            accept: "application/xml".into(),
707            produced: "application/json".into(),
708        };
709        let unsupported_msg = unsupported.to_string();
710        assert!(unsupported_msg.contains("text/plain"));
711        assert!(unsupported_msg.contains("application/json"));
712        let not_acceptable_msg = not_acceptable.to_string();
713        assert!(not_acceptable_msg.contains("application/xml"));
714        assert!(not_acceptable_msg.contains("application/json"));
715    }
716
717    #[test]
718    fn config_validation_error_on_exception_conflict_display() {
719        let err = ConfigValidationError::OnExceptionStepsHandledByConflict;
720        let msg = format!("{err}");
721        assert!(msg.contains("steps"));
722        assert!(msg.contains("handled_by"));
723        assert!(msg.contains("exclusive"));
724    }
725
726    #[test]
727    fn opaque_error_source_exposes_only_pointee() {
728        let src = OpaqueErrorSource::new(Arc::new(SampleSource));
729        let pointee = src.source().unwrap();
730        assert!(pointee.downcast_ref::<SampleSource>().is_some());
731    }
732
733    #[test]
734    fn endpoint_creation_failed_with_source_aliases_to_plain() {
735        let e = CamelError::EndpointCreationFailedWithSource(
736            "d".to_string(),
737            OpaqueErrorSource::new(Arc::new(SampleSource)),
738        );
739        assert_eq!(e.variant_name(), "EndpointCreationFailed");
740        assert_eq!(e.classify(), "endpoint");
741        assert_eq!(e.to_string(), "Endpoint creation failed: d");
742    }
743
744    #[test]
745    fn clone_preserves_variant_identity_for_all_error_samples() {
746        for e in all_error_samples() {
747            let c = e.clone();
748            assert_eq!(c.variant_name(), e.variant_name());
749            assert_eq!(c.classify(), e.classify());
750            assert_eq!(c.to_string(), e.to_string());
751        }
752    }
753
754    #[test]
755    fn camel_error_clone_preserves_source_provenance() {
756        let e = CamelError::EndpointCreationFailedWithSource(
757            "d".to_string(),
758            OpaqueErrorSource::new(Arc::new(SampleSource)),
759        );
760        let c = e.clone();
761        // `CamelError::source()` (thiserror #[source]) yields the wrapper
762        // itself; the pointee is one more `source()` hop away — that hop is
763        // the pointee-only mechanism under test (no Arc wrapper in between).
764        let wrapper = c.source().unwrap();
765        let pointee = wrapper.source().unwrap();
766        assert!(pointee.downcast_ref::<SampleSource>().is_some());
767    }
768}
769
770#[cfg(test)]
771mod variant_name_tests {
772    use super::{CamelError, ConfigValidationError, EndpointUriError, OpaqueErrorSource};
773    use std::sync::Arc;
774
775    /// Representative value for each enum variant. This test fails to compile
776    /// when a new variant is added to CamelError without updating variant_name().
777    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
778    /// exhaustive matching is allowed.
779    ///
780    /// The table must list every variant exactly once (`cases.len()` is asserted
781    /// below). When adding a CamelError variant, also update
782    /// `test_exception_kind_vocabulary_classification_guard` in
783    /// crates/camel-dsl/src/compile.rs and make the register-or-document
784    /// decision (bd rc-5u8co).
785    #[test]
786    fn variant_name_covers_all_variants() {
787        let cases: Vec<(CamelError, &str)> = vec![
788            (
789                CamelError::ComponentNotFound("x".into()),
790                "ComponentNotFound",
791            ),
792            (
793                CamelError::EndpointCreationFailed("x".into()),
794                "EndpointCreationFailed",
795            ),
796            (
797                CamelError::EndpointCreationFailedWithSource(
798                    "x".into(),
799                    OpaqueErrorSource::new(Arc::new(std::io::Error::other("y"))),
800                ),
801                "EndpointCreationFailed", // aliased
802            ),
803            (CamelError::ProcessorError("x".into()), "ProcessorError"),
804            (
805                CamelError::ProcessorErrorWithSource(
806                    "x".into(),
807                    Arc::new(std::io::Error::other("y")),
808                ),
809                "ProcessorError", // aliased
810            ),
811            (
812                CamelError::TypeConversionFailed("x".into()),
813                "TypeConversionFailed",
814            ),
815            (CamelError::InvalidUri("x".into()), "InvalidUri"),
816            (CamelError::ChannelClosed, "ChannelClosed"),
817            (CamelError::RouteError("x".into()), "RouteError"),
818            (CamelError::Io("x".into()), "Io"),
819            (
820                CamelError::DeadLetterChannelFailed("x".into()),
821                "DeadLetterChannelFailed",
822            ),
823            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
824            (
825                CamelError::HttpOperationFailed {
826                    method: "GET".into(),
827                    url: "https://example.com".into(),
828                    status_code: 500,
829                    status_text: "Internal Server Error".into(),
830                    response_body: None,
831                },
832                "HttpOperationFailed",
833            ),
834            (CamelError::ConsumerStopping, "ConsumerStopping"),
835            (CamelError::Config("x".into()), "Config"),
836            (
837                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
838                "ConfigValidation",
839            ),
840            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
841            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
842            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
843            (CamelError::Unauthorized("x".into()), "Unauthorized"),
844            (CamelError::ValidationError("bad".into()), "ValidationError"),
845            (CamelError::TemplateReload("x".into()), "TemplateReload"),
846            (
847                CamelError::EndpointUri(EndpointUriError::MissingScheme),
848                "EndpointUri",
849            ),
850            (
851                CamelError::UnsupportedMediaType {
852                    consumed: "text/plain".into(),
853                    declared: "application/json".into(),
854                },
855                "UnsupportedMediaType",
856            ),
857            (
858                CamelError::NotAcceptable {
859                    accept: "application/xml".into(),
860                    produced: "application/json".into(),
861                },
862                "NotAcceptable",
863            ),
864            (
865                CamelError::AuthProviderUnavailable("x".into()),
866                "ProcessorError",
867            ),
868        ];
869
870        assert_eq!(
871            cases.len(),
872            26,
873            "variant_name_covers_all_variants must cover every CamelError variant; \
874             extend this table and the camel-dsl classification guard"
875        );
876
877        for (err, expected) in cases {
878            assert_eq!(
879                err.variant_name(),
880                expected,
881                "variant_name mismatch for {:?}",
882                err
883            );
884        }
885    }
886
887    #[test]
888    fn auth_provider_unavailable_classifies_as_processor() {
889        let err = CamelError::AuthProviderUnavailable("jwks down".into());
890        assert_eq!(err.classify(), "processor");
891    }
892
893    #[test]
894    fn auth_provider_unavailable_variant_name_aliases_processor_error() {
895        let err = CamelError::AuthProviderUnavailable("jwks down".into());
896        assert_eq!(err.variant_name(), "ProcessorError");
897    }
898}