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/// Classification of a language-expression evaluation failure.
80///
81/// Matchable class attached to [`CamelError::ExpressionFailed`] so route-level
82/// error handlers can discriminate failure kinds programmatically without
83/// parsing message text. Variants carry no data — diagnostics that need detail
84/// live on the `ExpressionFailed` variant itself.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum ExpressionErrorClass {
88    /// Evaluation failed at runtime (engine error not covered by a finer class).
89    Runtime,
90    /// Arithmetic failure (overflow, division by zero, NaN rejection, ...).
91    Arithmetic,
92    /// Value type does not match the expected type.
93    TypeMismatch,
94    /// Referenced function is not registered with the language engine.
95    FunctionNotFound,
96    /// A configured evaluation limit was exceeded.
97    Limit,
98    /// Evaluation exceeded its time budget.
99    Timeout,
100    /// The result could not be converted to the declared destination type.
101    Conversion,
102    /// The expression source could not be parsed.
103    Parse,
104}
105
106impl ExpressionErrorClass {
107    /// Stable lowercase kebab-case name used in diagnostic messages.
108    pub fn as_str(&self) -> &'static str {
109        match self {
110            Self::Runtime => "runtime",
111            Self::Arithmetic => "arithmetic",
112            Self::TypeMismatch => "type-mismatch",
113            Self::FunctionNotFound => "function-not-found",
114            Self::Limit => "limit",
115            Self::Timeout => "timeout",
116            Self::Conversion => "conversion",
117            Self::Parse => "parse",
118        }
119    }
120}
121
122impl fmt::Display for ExpressionErrorClass {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(self.as_str())
125    }
126}
127
128/// 1-based position inside the expression source text, as reported by the
129/// language engine. Not a route-definition position.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub struct ErrorPosition {
132    /// 1-based line inside the expression source.
133    pub line: u32,
134    /// 1-based column inside the expression source.
135    pub column: u32,
136}
137
138impl fmt::Display for ErrorPosition {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(f, "{}:{}", self.line, self.column)
141    }
142}
143
144/// Source and destination type names for a failed result conversion.
145///
146/// Both fields are TYPE NAMES (trusted config strings, e.g. `f64`,
147/// `property p`); runtime values never enter this struct.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct ConversionDetail {
150    /// Type name of the value the engine produced.
151    pub source_type: String,
152    /// Type name (or destination description) the value was converted to.
153    pub target: String,
154}
155
156/// Position suffix: `" at {line}:{column}"` when present, empty otherwise.
157fn expression_position_suffix(position: &Option<ErrorPosition>) -> String {
158    match position {
159        Some(pos) => format!(" at {pos}"),
160        None => String::new(),
161    }
162}
163
164/// Conversion suffix: `" while converting {source_type} to {target}"` when
165/// present, empty otherwise.
166fn expression_conversion_suffix(conversion: &Option<ConversionDetail>) -> String {
167    match conversion {
168        Some(detail) => format!(
169            " while converting {} to {}",
170            detail.source_type, detail.target
171        ),
172        None => String::new(),
173    }
174}
175
176/// Opaque handle to an underlying error, preserving its source chain without
177/// exposing the concrete type.
178///
179/// The opacity contract: the pointee is reachable only through
180/// [`std::error::Error::source()`] (returned directly — no `Arc` wrapper hop),
181/// the inner handle is private, there is no public `Clone`, and provenance
182/// cannot be extracted outside camel-api (short of `unsafe`). Crate internals
183/// duplicate the handle via `OpaqueErrorSource::clone_handle` when cloning a
184/// [`CamelError`].
185///
186/// # Examples
187///
188/// The inner handle cannot be destructured out of the wrapper (private field):
189///
190/// ```compile_fail
191/// use camel_api::OpaqueErrorSource;
192///
193/// #[derive(Debug)]
194/// struct MyError;
195///
196/// impl std::fmt::Display for MyError {
197///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198///         f.write_str("my error")
199///     }
200/// }
201///
202/// impl std::error::Error for MyError {}
203///
204/// let OpaqueErrorSource(inner) = OpaqueErrorSource::new(std::sync::Arc::new(MyError));
205/// ```
206///
207/// The wrapper is deliberately not `Clone`, so callers cannot copy the handle
208/// out of camel-api:
209///
210/// ```compile_fail
211/// use camel_api::OpaqueErrorSource;
212///
213/// #[derive(Debug)]
214/// struct MyError;
215///
216/// impl std::fmt::Display for MyError {
217///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218///         f.write_str("my error")
219///     }
220/// }
221///
222/// impl std::error::Error for MyError {}
223///
224/// let s = OpaqueErrorSource::new(std::sync::Arc::new(MyError));
225/// let _ = s.clone();
226/// ```
227#[derive(Debug)]
228pub struct OpaqueErrorSource(Arc<dyn std::error::Error + Send + Sync>);
229
230impl OpaqueErrorSource {
231    /// Wrap an existing error as an opaque source.
232    pub fn new(source: Arc<dyn std::error::Error + Send + Sync>) -> Self {
233        Self(source)
234    }
235
236    /// Duplicate the inner handle for the manual `Clone` impl on
237    /// [`CamelError`]. Crate-private by design: the wrapper itself is not
238    /// `Clone`, so external code cannot duplicate provenance out of camel-api.
239    fn clone_handle(&self) -> Self {
240        Self(Arc::clone(&self.0))
241    }
242}
243
244impl fmt::Display for OpaqueErrorSource {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        self.0.fmt(f)
247    }
248}
249
250impl std::error::Error for OpaqueErrorSource {
251    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
252        // Pointee directly — NO Arc wrapper hop, so `downcast_ref` on the
253        // returned trait object reaches the wrapped error itself.
254        Some(self.0.as_ref())
255    }
256}
257
258/// Core error type for the Camel framework.
259#[derive(Debug, Error)]
260#[non_exhaustive]
261pub enum CamelError {
262    #[error("Component not found: {0}")]
263    ComponentNotFound(String),
264
265    #[error("Endpoint creation failed: {0}")]
266    EndpointCreationFailed(String),
267
268    /// Like `EndpointCreationFailed` but preserves the source error chain
269    /// for downstream inspection (e.g. typed gate-rejection classification).
270    #[error("Endpoint creation failed: {0}")]
271    EndpointCreationFailedWithSource(String, #[source] OpaqueErrorSource),
272
273    #[error("Processor error: {0}")]
274    ProcessorError(String),
275
276    /// Like `ProcessorError` but preserves the source error chain
277    /// for downstream inspection (e.g. via `std::error::Error::source()`).
278    #[error("Processor error: {0}")]
279    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
280
281    #[error("Type conversion failed: {0}")]
282    TypeConversionFailed(String),
283
284    #[error("Invalid URI: {0}")]
285    InvalidUri(String),
286
287    #[error("Channel closed")]
288    ChannelClosed,
289
290    #[error("Route error: {0}")]
291    RouteError(String),
292
293    #[error("IO error: {0}")]
294    Io(String),
295
296    #[error("Dead letter channel failed: {0}")]
297    DeadLetterChannelFailed(String),
298
299    #[error("Circuit breaker open: {0}")]
300    CircuitOpen(String),
301
302    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
303    HttpOperationFailed {
304        method: String,
305        url: String,
306        status_code: u16,
307        status_text: String,
308        response_body: Option<String>,
309    },
310
311    /// Producer `call()` failed on a shutdown signal — the consumer/semaphore
312    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
313    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
314    #[error("Consumer stopping: semaphore closed during call")]
315    ConsumerStopping,
316
317    #[error("Configuration error: {0}")]
318    Config(String),
319
320    /// Typed security-validation error (ADR-0033). Promotes Batch 1+ config/startup
321    /// failure modes from stringly-typed `Config(_)` to a matchable enum so
322    /// operators can discriminate programmatically.
323    #[error("Configuration validation error: {0}")]
324    ConfigValidation(ConfigValidationError),
325
326    #[error("Body stream has already been consumed")]
327    AlreadyConsumed,
328
329    #[error("Stream size exceeded limit: {0}")]
330    StreamLimitExceeded(usize),
331
332    #[error("Unauthenticated: {0}")]
333    Unauthenticated(String),
334
335    #[error("Unauthorized: {0}")]
336    Unauthorized(String),
337
338    /// Auth provider (JWKS/introspection/token endpoint) is unreachable or failing.
339    /// Promotes the auth-provider-down signal from a stringly-typed ProcessorError to
340    /// a matchable variant; WebSocket and gRPC transports map it to 503 / UNAVAILABLE.
341    #[error("Auth provider unavailable: {0}")]
342    AuthProviderUnavailable(String),
343
344    #[error("Validation failed: {0}")]
345    ValidationError(String),
346
347    #[error("Template reload failed: {0}")]
348    TemplateReload(String),
349
350    /// Typed endpoint-URI construction error (see [`EndpointUriError`]). Promotes the
351    /// fail-closed `EndpointUri` merge failures from stringly-typed errors to a matchable
352    /// variant so operators can discriminate programmatically.
353    #[error("Endpoint URI error: {0}")]
354    EndpointUri(EndpointUriError),
355
356    /// The request body media type does not match the declared/consumed type
357    /// (REST DSL default-strict content negotiation, HTTP 415).
358    #[error("Unsupported media type: consumed {consumed}, declared {declared}")]
359    UnsupportedMediaType { consumed: String, declared: String },
360
361    /// The response representation cannot satisfy the client's Accept header
362    /// (REST DSL default-strict content negotiation, HTTP 406).
363    #[error("Not acceptable: accept {accept}, produced {produced}")]
364    NotAcceptable { accept: String, produced: String },
365
366    /// Evaluation of a language expression failed during a route step
367    /// (language-value-boundary spec). All fields are route metadata and typed
368    /// diagnostics — no exchange data enters any field or the Display output.
369    #[error(
370        "expression failed: {class} in {language} `{verb}` step `{step_id}` (route `{route_id}`){}{}",
371        expression_position_suffix(.position),
372        expression_conversion_suffix(.conversion),
373    )]
374    ExpressionFailed {
375        /// Language the failing expression was written in (e.g. `rhai`).
376        language: String,
377        /// Id of the route that owned the failing step.
378        route_id: String,
379        /// Id of the failing step.
380        step_id: String,
381        /// DSL verb that evaluated the expression (e.g. `set_property`).
382        verb: String,
383        /// Typed failure classification.
384        class: ExpressionErrorClass,
385        /// Position inside the expression source, when the engine reports one.
386        position: Option<ErrorPosition>,
387        /// Failed result-conversion detail, when the failure was a conversion.
388        conversion: Option<ConversionDetail>,
389        /// Chained underlying error, when the failure wraps another
390        /// [`CamelError`] (e.g. a catch predicate failure chaining the
391        /// original route error).
392        #[source]
393        cause: Option<Box<CamelError>>,
394    },
395}
396
397/// Manual `Clone` impl: every arm clones its fields normally, except
398/// `EndpointCreationFailedWithSource`, which duplicates the opaque source
399/// handle via the crate-private `OpaqueErrorSource::clone_handle` (the wrapper
400/// itself is deliberately not `Clone`). Exhaustive like `variant_name()` — a
401/// new variant without an arm fails compilation.
402impl Clone for CamelError {
403    fn clone(&self) -> Self {
404        match self {
405            Self::ComponentNotFound(msg) => Self::ComponentNotFound(msg.clone()),
406            Self::EndpointCreationFailed(msg) => Self::EndpointCreationFailed(msg.clone()),
407            Self::EndpointCreationFailedWithSource(msg, source) => {
408                Self::EndpointCreationFailedWithSource(msg.clone(), source.clone_handle())
409            }
410            Self::ProcessorError(msg) => Self::ProcessorError(msg.clone()),
411            Self::ProcessorErrorWithSource(msg, source) => {
412                Self::ProcessorErrorWithSource(msg.clone(), Arc::clone(source))
413            }
414            Self::TypeConversionFailed(msg) => Self::TypeConversionFailed(msg.clone()),
415            Self::InvalidUri(msg) => Self::InvalidUri(msg.clone()),
416            Self::ChannelClosed => Self::ChannelClosed,
417            Self::RouteError(msg) => Self::RouteError(msg.clone()),
418            Self::Io(msg) => Self::Io(msg.clone()),
419            Self::DeadLetterChannelFailed(msg) => Self::DeadLetterChannelFailed(msg.clone()),
420            Self::CircuitOpen(msg) => Self::CircuitOpen(msg.clone()),
421            Self::HttpOperationFailed {
422                method,
423                url,
424                status_code,
425                status_text,
426                response_body,
427            } => Self::HttpOperationFailed {
428                method: method.clone(),
429                url: url.clone(),
430                status_code: *status_code,
431                status_text: status_text.clone(),
432                response_body: response_body.clone(),
433            },
434            Self::ConsumerStopping => Self::ConsumerStopping,
435            Self::Config(msg) => Self::Config(msg.clone()),
436            Self::ConfigValidation(e) => Self::ConfigValidation(e.clone()),
437            Self::AlreadyConsumed => Self::AlreadyConsumed,
438            Self::StreamLimitExceeded(limit) => Self::StreamLimitExceeded(*limit),
439            Self::Unauthenticated(msg) => Self::Unauthenticated(msg.clone()),
440            Self::Unauthorized(msg) => Self::Unauthorized(msg.clone()),
441            Self::AuthProviderUnavailable(msg) => Self::AuthProviderUnavailable(msg.clone()),
442            Self::ValidationError(msg) => Self::ValidationError(msg.clone()),
443            Self::TemplateReload(msg) => Self::TemplateReload(msg.clone()),
444            Self::EndpointUri(e) => Self::EndpointUri(e.clone()),
445            Self::UnsupportedMediaType { consumed, declared } => Self::UnsupportedMediaType {
446                consumed: consumed.clone(),
447                declared: declared.clone(),
448            },
449            Self::NotAcceptable { accept, produced } => Self::NotAcceptable {
450                accept: accept.clone(),
451                produced: produced.clone(),
452            },
453            Self::ExpressionFailed {
454                language,
455                route_id,
456                step_id,
457                verb,
458                class,
459                position,
460                conversion,
461                cause,
462            } => Self::ExpressionFailed {
463                language: language.clone(),
464                route_id: route_id.clone(),
465                step_id: step_id.clone(),
466                verb: verb.clone(),
467                class: *class,
468                position: *position,
469                conversion: conversion.clone(),
470                cause: cause.clone(),
471            },
472        }
473    }
474}
475
476/// Classification marker for `CamelError::CircuitOpen`.
477///
478/// Shared named constant so the pipeline tracer's circuit-open exclusion
479/// (skip `increment_errors` — the breaker already recorded the rejection)
480/// and `classify` itself cannot drift apart (dashboard-observability D2).
481pub const CIRCUIT_OPEN: &str = "circuit_open";
482
483impl CamelError {
484    pub fn classify(&self) -> &'static str {
485        #[allow(unreachable_patterns)]
486        match self {
487            Self::ComponentNotFound(_) => "component",
488            Self::EndpointCreationFailed(_)
489            | Self::EndpointCreationFailedWithSource(_, _)
490            | Self::InvalidUri(_)
491            | Self::EndpointUri(_) => "endpoint",
492            Self::ProcessorError(_)
493            | Self::ProcessorErrorWithSource(_, _)
494            | Self::AuthProviderUnavailable(_) => "processor",
495            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
496            Self::Io(_) => "io",
497            Self::RouteError(_) => "route",
498            Self::CircuitOpen(_) => CIRCUIT_OPEN,
499            Self::HttpOperationFailed { .. } => "http",
500            Self::Config(_) | Self::ConfigValidation(_) => "config",
501            Self::DeadLetterChannelFailed(_) => "dead_letter",
502            Self::ConsumerStopping => "consumer_stop",
503            Self::StreamLimitExceeded(_) => "stream",
504            Self::ChannelClosed => "channel",
505            Self::Unauthenticated(_) => "unauthenticated",
506            Self::Unauthorized(_) => "unauthorized",
507            Self::ValidationError(_) => "validation",
508            Self::TemplateReload(_) => "template",
509            Self::UnsupportedMediaType { .. } => "unsupported_media_type",
510            Self::NotAcceptable { .. } => "not_acceptable",
511            _ => "unknown",
512        }
513    }
514
515    /// Stable variant name used by `doTry` catch-by-variant matchers.
516    ///
517    /// `ProcessorErrorWithSource` and `AuthProviderUnavailable` alias to
518    /// `"ProcessorError"`, and `EndpointCreationFailedWithSource` aliases to
519    /// `"EndpointCreationFailed"` — the aliased variants are not distinguishable
520    /// by name in MVP (see spec §5.4), so existing `doTry` catch handlers keep
521    /// matching.
522    ///
523    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
524    /// so internal exhaustive matching is allowed. Adding a new variant without updating
525    /// this method will fail to compile, surfaced by `variant_name_tests`.
526    pub fn variant_name(&self) -> &'static str {
527        match self {
528            Self::ComponentNotFound(_) => "ComponentNotFound",
529            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
530            Self::EndpointCreationFailedWithSource(_, _) => "EndpointCreationFailed",
531            Self::ProcessorError(_) => "ProcessorError",
532            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
533            Self::AuthProviderUnavailable(_) => "ProcessorError",
534            Self::TypeConversionFailed(_) => "TypeConversionFailed",
535            Self::InvalidUri(_) => "InvalidUri",
536            Self::ChannelClosed => "ChannelClosed",
537            Self::RouteError(_) => "RouteError",
538            Self::Io(_) => "Io",
539            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
540            Self::CircuitOpen(_) => "CircuitOpen",
541            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
542            Self::ConsumerStopping => "ConsumerStopping",
543            Self::Config(_) => "Config",
544            Self::ConfigValidation(_) => "ConfigValidation",
545            Self::AlreadyConsumed => "AlreadyConsumed",
546            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
547            Self::Unauthenticated(_) => "Unauthenticated",
548            Self::Unauthorized(_) => "Unauthorized",
549            Self::ValidationError(_) => "ValidationError",
550            Self::TemplateReload(_) => "TemplateReload",
551            Self::EndpointUri(_) => "EndpointUri",
552            Self::UnsupportedMediaType { .. } => "UnsupportedMediaType",
553            Self::NotAcceptable { .. } => "NotAcceptable",
554            Self::ExpressionFailed { .. } => "ExpressionFailed",
555        }
556    }
557}
558
559impl From<std::io::Error> for CamelError {
560    fn from(err: std::io::Error) -> Self {
561        CamelError::Io(err.to_string())
562    }
563}
564
565impl From<crate::template::TemplateError> for CamelError {
566    fn from(err: crate::template::TemplateError) -> Self {
567        CamelError::Config(err.to_string())
568    }
569}
570
571impl From<ConfigValidationError> for CamelError {
572    fn from(e: ConfigValidationError) -> Self {
573        CamelError::ConfigValidation(e)
574    }
575}
576
577impl From<EndpointUriError> for CamelError {
578    fn from(e: EndpointUriError) -> Self {
579        CamelError::EndpointUri(e)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    // `super::*` brings in thiserror's `Error` derive macro; import the trait
587    // anonymously so `source()` is callable in tests.
588    use std::error::Error as _;
589
590    /// Minimal source error for opaque-provenance tests.
591    #[derive(Debug)]
592    struct SampleSource;
593
594    impl fmt::Display for SampleSource {
595        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596            f.write_str("sample source")
597        }
598    }
599
600    impl std::error::Error for SampleSource {}
601
602    fn all_error_samples() -> Vec<CamelError> {
603        vec![
604            CamelError::ComponentNotFound("x".to_string()),
605            CamelError::EndpointCreationFailed("x".to_string()),
606            CamelError::EndpointCreationFailedWithSource(
607                "x".to_string(),
608                OpaqueErrorSource::new(Arc::new(SampleSource)),
609            ),
610            CamelError::ProcessorError("x".to_string()),
611            CamelError::ProcessorErrorWithSource(
612                "x".to_string(),
613                Arc::new(std::io::Error::other("inner")),
614            ),
615            CamelError::TypeConversionFailed("x".to_string()),
616            CamelError::InvalidUri("x".to_string()),
617            CamelError::ChannelClosed,
618            CamelError::RouteError("x".to_string()),
619            CamelError::Io("x".to_string()),
620            CamelError::DeadLetterChannelFailed("x".to_string()),
621            CamelError::CircuitOpen("x".to_string()),
622            CamelError::HttpOperationFailed {
623                method: "GET".to_string(),
624                url: "https://example.com".to_string(),
625                status_code: 500,
626                status_text: "Internal Server Error".to_string(),
627                response_body: Some("error".to_string()),
628            },
629            CamelError::ConsumerStopping,
630            CamelError::Config("x".to_string()),
631            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
632            CamelError::AlreadyConsumed,
633            CamelError::StreamLimitExceeded(42),
634            CamelError::Unauthenticated("token expired".to_string()),
635            CamelError::Unauthorized("missing admin role".to_string()),
636            CamelError::AuthProviderUnavailable("jwks down".to_string()),
637            CamelError::ValidationError("body does not match schema".to_string()),
638            CamelError::TemplateReload("reload failed".to_string()),
639            CamelError::EndpointUri(EndpointUriError::MissingScheme),
640            CamelError::UnsupportedMediaType {
641                consumed: "text/plain".to_string(),
642                declared: "application/json".to_string(),
643            },
644            CamelError::NotAcceptable {
645                accept: "application/xml".to_string(),
646                produced: "application/json".to_string(),
647            },
648        ]
649    }
650
651    #[test]
652    fn test_http_operation_failed_display() {
653        let err = CamelError::HttpOperationFailed {
654            method: "GET".to_string(),
655            url: "https://example.com/test".to_string(),
656            status_code: 404,
657            status_text: "Not Found".to_string(),
658            response_body: Some("page not found".to_string()),
659        };
660        let msg = format!("{err}");
661        assert!(msg.contains("404"));
662        assert!(msg.contains("Not Found"));
663    }
664
665    #[test]
666    fn test_http_operation_failed_clone() {
667        let err = CamelError::HttpOperationFailed {
668            method: "POST".to_string(),
669            url: "https://api.example.com/users".to_string(),
670            status_code: 500,
671            status_text: "Internal Server Error".to_string(),
672            response_body: None,
673        };
674        let cloned = err.clone();
675        assert!(matches!(
676            cloned,
677            CamelError::HttpOperationFailed {
678                status_code: 500,
679                ..
680            }
681        ));
682    }
683
684    #[test]
685    fn test_classify_maps_all_variants() {
686        assert_eq!(
687            CamelError::ComponentNotFound("x".to_string()).classify(),
688            "component"
689        );
690        assert_eq!(
691            CamelError::EndpointCreationFailed("x".to_string()).classify(),
692            "endpoint"
693        );
694        assert_eq!(
695            CamelError::ProcessorError("x".to_string()).classify(),
696            "processor"
697        );
698        assert_eq!(
699            CamelError::TypeConversionFailed("x".to_string()).classify(),
700            "type_conversion"
701        );
702        assert_eq!(
703            CamelError::InvalidUri("x".to_string()).classify(),
704            "endpoint"
705        );
706        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
707        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
708        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
709        assert_eq!(
710            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
711            "dead_letter"
712        );
713        assert_eq!(
714            CamelError::CircuitOpen("x".to_string()).classify(),
715            "circuit_open"
716        );
717        assert_eq!(
718            CamelError::HttpOperationFailed {
719                method: "GET".to_string(),
720                url: "https://example.com".to_string(),
721                status_code: 500,
722                status_text: "Internal Server Error".to_string(),
723                response_body: None,
724            }
725            .classify(),
726            "http"
727        );
728        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
729        assert_eq!(
730            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
731                .classify(),
732            "config"
733        );
734        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
735        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
736        assert_eq!(
737            CamelError::ValidationError("bad".to_string()).classify(),
738            "validation"
739        );
740    }
741
742    #[test]
743    fn test_classify_output_is_ascii_and_short() {
744        for error in all_error_samples() {
745            let class = error.classify();
746            assert!(class.is_ascii());
747            // "unsupported_media_type" (REST negotiation, L2) sets the floor at 22
748            assert!(class.len() <= 22, "class too long: {class}");
749        }
750    }
751
752    #[test]
753    fn test_auth_variants_classify() {
754        assert_eq!(
755            CamelError::Unauthenticated("x".to_string()).classify(),
756            "unauthenticated"
757        );
758        assert_eq!(
759            CamelError::Unauthorized("x".to_string()).classify(),
760            "unauthorized"
761        );
762    }
763
764    #[test]
765    fn test_validation_error_classify() {
766        assert_eq!(
767            CamelError::ValidationError("bad".to_string()).classify(),
768            "validation"
769        );
770    }
771
772    #[test]
773    fn template_reload_classifies_as_template() {
774        let err = CamelError::TemplateReload("boom".into());
775        assert_eq!(err.classify(), "template");
776    }
777
778    #[test]
779    fn template_reload_variant_name() {
780        let err = CamelError::TemplateReload("boom".into());
781        assert_eq!(err.variant_name(), "TemplateReload");
782    }
783
784    #[test]
785    fn test_auth_variants_are_clone() {
786        let err = CamelError::Unauthenticated("test".to_string());
787        let cloned = err.clone();
788        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
789
790        let err2 = CamelError::Unauthorized("test".to_string());
791        let cloned2 = err2.clone();
792        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
793    }
794
795    #[test]
796    fn classification_unchanged_for_callers() {
797        // Pins the contract the pipeline-tracer circuit_open exclusion
798        // relies on (dashboard-observability D2): CircuitOpen must keep
799        // classifying as "circuit_open" — callers and the tracer skip
800        // branch match on exactly this literal.
801        assert_eq!(
802            CamelError::CircuitOpen("breaker open".into()).classify(),
803            "circuit_open"
804        );
805    }
806
807    #[test]
808    fn auth_provider_unavailable_display_carries_detail() {
809        let err = CamelError::AuthProviderUnavailable("conn refused".into());
810        let msg = err.to_string();
811        assert!(msg.contains("conn refused"));
812        assert!(
813            msg.starts_with("Auth provider unavailable"),
814            "display should start with 'Auth provider unavailable', got: {msg}"
815        );
816    }
817
818    #[test]
819    fn classify_negotiation_errors() {
820        let unsupported = CamelError::UnsupportedMediaType {
821            consumed: "text/plain".into(),
822            declared: "application/json".into(),
823        };
824        let not_acceptable = CamelError::NotAcceptable {
825            accept: "application/xml".into(),
826            produced: "application/json".into(),
827        };
828        assert_eq!(unsupported.classify(), "unsupported_media_type");
829        assert_eq!(not_acceptable.classify(), "not_acceptable");
830    }
831
832    #[test]
833    fn variant_names_negotiation_errors() {
834        let unsupported = CamelError::UnsupportedMediaType {
835            consumed: "text/plain".into(),
836            declared: "application/json".into(),
837        };
838        let not_acceptable = CamelError::NotAcceptable {
839            accept: "application/xml".into(),
840            produced: "application/json".into(),
841        };
842        assert_eq!(unsupported.variant_name(), "UnsupportedMediaType");
843        assert_eq!(not_acceptable.variant_name(), "NotAcceptable");
844    }
845
846    #[test]
847    fn display_negotiation_errors() {
848        let unsupported = CamelError::UnsupportedMediaType {
849            consumed: "text/plain".into(),
850            declared: "application/json".into(),
851        };
852        let not_acceptable = CamelError::NotAcceptable {
853            accept: "application/xml".into(),
854            produced: "application/json".into(),
855        };
856        let unsupported_msg = unsupported.to_string();
857        assert!(unsupported_msg.contains("text/plain"));
858        assert!(unsupported_msg.contains("application/json"));
859        let not_acceptable_msg = not_acceptable.to_string();
860        assert!(not_acceptable_msg.contains("application/xml"));
861        assert!(not_acceptable_msg.contains("application/json"));
862    }
863
864    #[test]
865    fn expression_failed_display_has_no_exchange_data() {
866        let err = CamelError::ExpressionFailed {
867            language: "rhai".to_string(),
868            route_id: "r1".to_string(),
869            step_id: "set_property#0".to_string(),
870            verb: "set_property".to_string(),
871            class: ExpressionErrorClass::Arithmetic,
872            position: Some(ErrorPosition { line: 3, column: 8 }),
873            conversion: None,
874            cause: None,
875        };
876        let msg = err.to_string();
877        assert!(msg.contains("arithmetic"), "missing class: {msg}");
878        assert!(msg.contains("rhai"), "missing language: {msg}");
879        assert!(msg.contains("set_property"), "missing verb/step: {msg}");
880        assert!(msg.contains("3:8"), "missing position: {msg}");
881        // Nothing beyond the metadata fields: the full rendering is pinned.
882        assert_eq!(
883            msg,
884            "expression failed: arithmetic in rhai `set_property` step \
885             `set_property#0` (route `r1`) at 3:8"
886        );
887    }
888
889    #[test]
890    fn expression_failed_variant_name() {
891        let err = CamelError::ExpressionFailed {
892            language: "rhai".to_string(),
893            route_id: "r1".to_string(),
894            step_id: "set_property#0".to_string(),
895            verb: "set_property".to_string(),
896            class: ExpressionErrorClass::Runtime,
897            position: None,
898            conversion: None,
899            cause: None,
900        };
901        assert_eq!(err.variant_name(), "ExpressionFailed");
902    }
903
904    #[test]
905    fn config_validation_error_on_exception_conflict_display() {
906        let err = ConfigValidationError::OnExceptionStepsHandledByConflict;
907        let msg = format!("{err}");
908        assert!(msg.contains("steps"));
909        assert!(msg.contains("handled_by"));
910        assert!(msg.contains("exclusive"));
911    }
912
913    #[test]
914    fn opaque_error_source_exposes_only_pointee() {
915        let src = OpaqueErrorSource::new(Arc::new(SampleSource));
916        let pointee = src.source().unwrap();
917        assert!(pointee.downcast_ref::<SampleSource>().is_some());
918    }
919
920    #[test]
921    fn endpoint_creation_failed_with_source_aliases_to_plain() {
922        let e = CamelError::EndpointCreationFailedWithSource(
923            "d".to_string(),
924            OpaqueErrorSource::new(Arc::new(SampleSource)),
925        );
926        assert_eq!(e.variant_name(), "EndpointCreationFailed");
927        assert_eq!(e.classify(), "endpoint");
928        assert_eq!(e.to_string(), "Endpoint creation failed: d");
929    }
930
931    #[test]
932    fn clone_preserves_variant_identity_for_all_error_samples() {
933        for e in all_error_samples() {
934            let c = e.clone();
935            assert_eq!(c.variant_name(), e.variant_name());
936            assert_eq!(c.classify(), e.classify());
937            assert_eq!(c.to_string(), e.to_string());
938        }
939    }
940
941    #[test]
942    fn camel_error_clone_preserves_source_provenance() {
943        let e = CamelError::EndpointCreationFailedWithSource(
944            "d".to_string(),
945            OpaqueErrorSource::new(Arc::new(SampleSource)),
946        );
947        let c = e.clone();
948        // `CamelError::source()` (thiserror #[source]) yields the wrapper
949        // itself; the pointee is one more `source()` hop away — that hop is
950        // the pointee-only mechanism under test (no Arc wrapper in between).
951        let wrapper = c.source().unwrap();
952        let pointee = wrapper.source().unwrap();
953        assert!(pointee.downcast_ref::<SampleSource>().is_some());
954    }
955}
956
957#[cfg(test)]
958mod variant_name_tests {
959    use super::{
960        CamelError, ConfigValidationError, EndpointUriError, ExpressionErrorClass,
961        OpaqueErrorSource,
962    };
963    use std::sync::Arc;
964
965    /// Representative value for each enum variant. This test fails to compile
966    /// when a new variant is added to CamelError without updating variant_name().
967    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
968    /// exhaustive matching is allowed.
969    ///
970    /// The table must list every variant exactly once (`cases.len()` is asserted
971    /// below). When adding a CamelError variant, also update
972    /// `test_exception_kind_vocabulary_classification_guard` in
973    /// crates/camel-dsl/src/compile.rs and make the register-or-document
974    /// decision (bd rc-5u8co).
975    #[test]
976    fn variant_name_covers_all_variants() {
977        let cases: Vec<(CamelError, &str)> = vec![
978            (
979                CamelError::ComponentNotFound("x".into()),
980                "ComponentNotFound",
981            ),
982            (
983                CamelError::EndpointCreationFailed("x".into()),
984                "EndpointCreationFailed",
985            ),
986            (
987                CamelError::EndpointCreationFailedWithSource(
988                    "x".into(),
989                    OpaqueErrorSource::new(Arc::new(std::io::Error::other("y"))),
990                ),
991                "EndpointCreationFailed", // aliased
992            ),
993            (CamelError::ProcessorError("x".into()), "ProcessorError"),
994            (
995                CamelError::ProcessorErrorWithSource(
996                    "x".into(),
997                    Arc::new(std::io::Error::other("y")),
998                ),
999                "ProcessorError", // aliased
1000            ),
1001            (
1002                CamelError::TypeConversionFailed("x".into()),
1003                "TypeConversionFailed",
1004            ),
1005            (CamelError::InvalidUri("x".into()), "InvalidUri"),
1006            (CamelError::ChannelClosed, "ChannelClosed"),
1007            (CamelError::RouteError("x".into()), "RouteError"),
1008            (CamelError::Io("x".into()), "Io"),
1009            (
1010                CamelError::DeadLetterChannelFailed("x".into()),
1011                "DeadLetterChannelFailed",
1012            ),
1013            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
1014            (
1015                CamelError::HttpOperationFailed {
1016                    method: "GET".into(),
1017                    url: "https://example.com".into(),
1018                    status_code: 500,
1019                    status_text: "Internal Server Error".into(),
1020                    response_body: None,
1021                },
1022                "HttpOperationFailed",
1023            ),
1024            (CamelError::ConsumerStopping, "ConsumerStopping"),
1025            (CamelError::Config("x".into()), "Config"),
1026            (
1027                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
1028                "ConfigValidation",
1029            ),
1030            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
1031            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
1032            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
1033            (CamelError::Unauthorized("x".into()), "Unauthorized"),
1034            (CamelError::ValidationError("bad".into()), "ValidationError"),
1035            (CamelError::TemplateReload("x".into()), "TemplateReload"),
1036            (
1037                CamelError::EndpointUri(EndpointUriError::MissingScheme),
1038                "EndpointUri",
1039            ),
1040            (
1041                CamelError::UnsupportedMediaType {
1042                    consumed: "text/plain".into(),
1043                    declared: "application/json".into(),
1044                },
1045                "UnsupportedMediaType",
1046            ),
1047            (
1048                CamelError::NotAcceptable {
1049                    accept: "application/xml".into(),
1050                    produced: "application/json".into(),
1051                },
1052                "NotAcceptable",
1053            ),
1054            (
1055                CamelError::ExpressionFailed {
1056                    language: "rhai".into(),
1057                    route_id: "r1".into(),
1058                    step_id: "set_property#0".into(),
1059                    verb: "set_property".into(),
1060                    class: ExpressionErrorClass::Runtime,
1061                    position: None,
1062                    conversion: None,
1063                    cause: None,
1064                },
1065                "ExpressionFailed",
1066            ),
1067            (
1068                CamelError::AuthProviderUnavailable("x".into()),
1069                "ProcessorError",
1070            ),
1071        ];
1072
1073        assert_eq!(
1074            cases.len(),
1075            27,
1076            "variant_name_covers_all_variants must cover every CamelError variant; \
1077             extend this table and the camel-dsl classification guard"
1078        );
1079
1080        for (err, expected) in cases {
1081            assert_eq!(
1082                err.variant_name(),
1083                expected,
1084                "variant_name mismatch for {:?}",
1085                err
1086            );
1087        }
1088    }
1089
1090    #[test]
1091    fn auth_provider_unavailable_classifies_as_processor() {
1092        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1093        assert_eq!(err.classify(), "processor");
1094    }
1095
1096    #[test]
1097    fn auth_provider_unavailable_variant_name_aliases_processor_error() {
1098        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1099        assert_eq!(err.variant_name(), "ProcessorError");
1100    }
1101}