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