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 or input body could not be converted to the declared destination type.
101    Conversion,
102    /// The expression or document 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/// Boxed trait-object error used by Tower middleware (`Service::Error`).
584pub type BoxError = std::boxed::Box<dyn std::error::Error + Send + Sync + 'static>;
585
586/// Returns `true` when an [`tower::timeout::error::Elapsed`] appears anywhere
587/// in the source chain rooted at `err`.
588///
589/// The walk starts at `err` itself: `TimeoutLayer` boxes a bare `Elapsed` with
590/// no wrapper, so skipping the root would miss the common timeout case.
591fn source_chain_contains_elapsed(err: &(dyn std::error::Error + 'static)) -> bool {
592    let mut current = Some(err);
593    while let Some(source) = current {
594        if source
595            .downcast_ref::<tower::timeout::error::Elapsed>()
596            .is_some()
597        {
598            return true;
599        }
600        current = std::error::Error::source(source);
601    }
602    false
603}
604
605/// Converts a boxed middleware error into a [`CamelError`].
606///
607/// A boxed [`CamelError`] passes through unchanged. This keeps native exception
608/// matching and fault classification intact after a middleware hop instead of
609/// double-wrapping it as a processor error.
610///
611/// Any other boxed error becomes [`CamelError::ProcessorErrorWithSource`] with
612/// the original error preserved as the source, so the chain stays inspectable.
613/// A Tower timeout anywhere in the chain (not only at its root) is reported as
614/// a middleware timeout.
615///
616/// The payload is an [`OpaqueErrorSource`] around the `Arc`, not a bare `Arc`.
617/// With a bare `Arc` the chain would skip the pointee: thiserror's generated
618/// `source()` delegates through std's forwarding `Error for Arc<T>` impl to the
619/// pointee's own `source()`. The wrapper instead returns the pointee DIRECTLY
620/// from its own `source()`, with no `Arc` hop, so the concrete error is the
621/// FIRST node of the public chain — `err.source()` already yields it, and
622/// `downcast_ref` reaches it at hop 1 (e.g.
623/// `tower::timeout::error::Elapsed`).
624///
625/// # Examples
626///
627/// A boxed [`CamelError`] is returned unchanged:
628///
629/// ```
630/// use std::error::Error;
631///
632/// use camel_api::error::CamelError;
633///
634/// let boxed: Box<dyn Error + Send + Sync> = Box::new(CamelError::InvalidUri("u".into()));
635/// match CamelError::from(boxed) {
636///     CamelError::InvalidUri(uri) => assert_eq!(uri, "u"),
637///     other => panic!("expected pass-through, got {other:?}"),
638/// }
639/// ```
640///
641/// A timeout nested in the source chain is classified as a middleware timeout,
642/// and the source is preserved:
643///
644/// ```
645/// use std::error::Error;
646///
647/// use camel_api::error::CamelError;
648/// use tower::timeout::error::Elapsed;
649///
650/// #[derive(Debug)]
651/// struct TimedOut(Elapsed);
652///
653/// impl std::fmt::Display for TimedOut {
654///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655///         f.write_str("middleware call timed out")
656///     }
657/// }
658///
659/// impl Error for TimedOut {
660///     fn source(&self) -> Option<&(dyn Error + 'static)> {
661///         Some(&self.0)
662///     }
663/// }
664///
665/// let boxed: Box<dyn Error + Send + Sync> = Box::new(TimedOut(Elapsed::new()));
666/// let err = CamelError::from(boxed);
667/// assert!(err.to_string().contains("middleware timeout elapsed"));
668///
669/// let mut source = err.source();
670/// let mut reached_elapsed = false;
671/// while let Some(inner) = source {
672///     if inner.downcast_ref::<Elapsed>().is_some() {
673///         reached_elapsed = true;
674///         break;
675///     }
676///     source = inner.source();
677/// }
678/// assert!(reached_elapsed, "source chain must reach the original Elapsed");
679/// ```
680impl From<BoxError> for CamelError {
681    fn from(err: BoxError) -> Self {
682        // Pass a native CamelError through untouched: double-wrapping it would
683        // break doTry catch-by-variant matching and fault classification.
684        match err.downcast::<CamelError>() {
685            Ok(camel_error) => *camel_error,
686            Err(err) => {
687                let message = if source_chain_contains_elapsed(err.as_ref()) {
688                    "middleware timeout elapsed"
689                } else {
690                    "middleware error"
691                };
692                CamelError::ProcessorErrorWithSource(
693                    message.to_string(),
694                    Arc::new(OpaqueErrorSource::new(Arc::from(err))),
695                )
696            }
697        }
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    // `super::*` brings in thiserror's `Error` derive macro; import the trait
705    // anonymously so `source()` is callable in tests.
706    use std::error::Error as _;
707
708    /// Minimal source error for opaque-provenance tests.
709    #[derive(Debug)]
710    struct SampleSource;
711
712    impl fmt::Display for SampleSource {
713        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714            f.write_str("sample source")
715        }
716    }
717
718    impl std::error::Error for SampleSource {}
719
720    /// Wrapper whose only source is a tower timeout [`Elapsed`], used to prove
721    /// the `BoxError` conversion walks the whole source chain.
722    ///
723    /// [`Elapsed`]: tower::timeout::error::Elapsed
724    #[derive(Debug)]
725    struct ElapsedSourcedError(tower::timeout::error::Elapsed);
726
727    impl fmt::Display for ElapsedSourcedError {
728        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
729            f.write_str("wrapped middleware failure")
730        }
731    }
732
733    impl std::error::Error for ElapsedSourcedError {
734        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
735            Some(&self.0)
736        }
737    }
738
739    #[test]
740    fn boxed_camel_error_passthrough_unchanged() {
741        let boxed: Box<dyn std::error::Error + Send + Sync> =
742            Box::<dyn std::error::Error + Send + Sync>::from(CamelError::InvalidUri(
743                "u".to_string(),
744            ));
745        let err = CamelError::from(boxed);
746        match err {
747            CamelError::InvalidUri(uri) => assert_eq!(uri, "u"),
748            other => panic!("expected InvalidUri passthrough, got {other:?}"),
749        }
750    }
751
752    /// Asserts a tower [`Elapsed`] is reachable through the public
753    /// [`std::error::Error::source`] chain within `max_hops` hops.
754    ///
755    /// This is the real consumer path: the `ProcessorErrorWithSource` payload is
756    /// opaque, so callers can only walk `source()` and `downcast_ref`.
757    ///
758    /// [`Elapsed`]: tower::timeout::error::Elapsed
759    fn assert_elapsed_within_source_hops(err: &CamelError, max_hops: usize) {
760        let mut current = err.source();
761        for _ in 0..max_hops {
762            let Some(node) = current else { break };
763            if node
764                .downcast_ref::<tower::timeout::error::Elapsed>()
765                .is_some()
766            {
767                return;
768            }
769            current = node.source();
770        }
771        panic!("source chain does not reach tower Elapsed within {max_hops} hops: {err:?}");
772    }
773
774    #[test]
775    fn timeout_error_classified_through_source_chain() {
776        let boxed: Box<dyn std::error::Error + Send + Sync> =
777            Box::new(ElapsedSourcedError(tower::timeout::error::Elapsed::new()));
778        let err = CamelError::from(boxed);
779        assert!(
780            err.to_string().contains("middleware timeout elapsed"),
781            "unexpected display: {err}"
782        );
783        assert_elapsed_within_source_hops(&err, 3);
784    }
785
786    #[test]
787    fn bare_elapsed_root_error_classified_as_timeout() {
788        // Mirrors probe P4: `TimeoutLayer` boxes a bare `Elapsed` at the chain
789        // root, with no wrapper error to walk through. The `OpaqueErrorSource`
790        // payload still surfaces it as a downcastable chain node (hop 1).
791        let boxed: Box<dyn std::error::Error + Send + Sync> =
792            Box::new(tower::timeout::error::Elapsed::new());
793        let err = CamelError::from(boxed);
794        assert!(
795            err.to_string().contains("middleware timeout elapsed"),
796            "unexpected display: {err}"
797        );
798        assert_elapsed_within_source_hops(&err, 3);
799    }
800
801    #[test]
802    #[allow(clippy::io_other_error)]
803    fn plain_box_error_preserves_source() {
804        let boxed: Box<dyn std::error::Error + Send + Sync> =
805            Box::new(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
806        let err = CamelError::from(boxed);
807        match &err {
808            CamelError::ProcessorErrorWithSource(message, _) => {
809                assert!(
810                    message.contains("middleware error"),
811                    "unexpected message: {message}"
812                );
813            }
814            other => panic!("expected ProcessorErrorWithSource, got {other:?}"),
815        }
816        let source = err
817            .source()
818            .expect("ProcessorErrorWithSource carries a source");
819        assert_eq!(source.to_string(), "boom");
820    }
821
822    fn all_error_samples() -> Vec<CamelError> {
823        vec![
824            CamelError::ComponentNotFound("x".to_string()),
825            CamelError::EndpointCreationFailed("x".to_string()),
826            CamelError::EndpointCreationFailedWithSource(
827                "x".to_string(),
828                OpaqueErrorSource::new(Arc::new(SampleSource)),
829            ),
830            CamelError::ProcessorError("x".to_string()),
831            CamelError::ProcessorErrorWithSource(
832                "x".to_string(),
833                Arc::new(std::io::Error::other("inner")),
834            ),
835            CamelError::TypeConversionFailed("x".to_string()),
836            CamelError::InvalidUri("x".to_string()),
837            CamelError::ChannelClosed,
838            CamelError::RouteError("x".to_string()),
839            CamelError::Io("x".to_string()),
840            CamelError::DeadLetterChannelFailed("x".to_string()),
841            CamelError::CircuitOpen("x".to_string()),
842            CamelError::HttpOperationFailed {
843                method: "GET".to_string(),
844                url: "https://example.com".to_string(),
845                status_code: 500,
846                status_text: "Internal Server Error".to_string(),
847                response_body: Some("error".to_string()),
848            },
849            CamelError::ConsumerStopping,
850            CamelError::Config("x".to_string()),
851            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
852            CamelError::AlreadyConsumed,
853            CamelError::StreamLimitExceeded(42),
854            CamelError::Unauthenticated("token expired".to_string()),
855            CamelError::Unauthorized("missing admin role".to_string()),
856            CamelError::AuthProviderUnavailable("jwks down".to_string()),
857            CamelError::ValidationError("body does not match schema".to_string()),
858            CamelError::TemplateReload("reload failed".to_string()),
859            CamelError::EndpointUri(EndpointUriError::MissingScheme),
860            CamelError::UnsupportedMediaType {
861                consumed: "text/plain".to_string(),
862                declared: "application/json".to_string(),
863            },
864            CamelError::NotAcceptable {
865                accept: "application/xml".to_string(),
866                produced: "application/json".to_string(),
867            },
868        ]
869    }
870
871    #[test]
872    fn test_http_operation_failed_display() {
873        let err = CamelError::HttpOperationFailed {
874            method: "GET".to_string(),
875            url: "https://example.com/test".to_string(),
876            status_code: 404,
877            status_text: "Not Found".to_string(),
878            response_body: Some("page not found".to_string()),
879        };
880        let msg = format!("{err}");
881        assert!(msg.contains("404"));
882        assert!(msg.contains("Not Found"));
883    }
884
885    #[test]
886    fn test_http_operation_failed_clone() {
887        let err = CamelError::HttpOperationFailed {
888            method: "POST".to_string(),
889            url: "https://api.example.com/users".to_string(),
890            status_code: 500,
891            status_text: "Internal Server Error".to_string(),
892            response_body: None,
893        };
894        let cloned = err.clone();
895        assert!(matches!(
896            cloned,
897            CamelError::HttpOperationFailed {
898                status_code: 500,
899                ..
900            }
901        ));
902    }
903
904    #[test]
905    fn test_classify_maps_all_variants() {
906        assert_eq!(
907            CamelError::ComponentNotFound("x".to_string()).classify(),
908            "component"
909        );
910        assert_eq!(
911            CamelError::EndpointCreationFailed("x".to_string()).classify(),
912            "endpoint"
913        );
914        assert_eq!(
915            CamelError::ProcessorError("x".to_string()).classify(),
916            "processor"
917        );
918        assert_eq!(
919            CamelError::TypeConversionFailed("x".to_string()).classify(),
920            "type_conversion"
921        );
922        assert_eq!(
923            CamelError::InvalidUri("x".to_string()).classify(),
924            "endpoint"
925        );
926        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
927        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
928        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
929        assert_eq!(
930            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
931            "dead_letter"
932        );
933        assert_eq!(
934            CamelError::CircuitOpen("x".to_string()).classify(),
935            "circuit_open"
936        );
937        assert_eq!(
938            CamelError::HttpOperationFailed {
939                method: "GET".to_string(),
940                url: "https://example.com".to_string(),
941                status_code: 500,
942                status_text: "Internal Server Error".to_string(),
943                response_body: None,
944            }
945            .classify(),
946            "http"
947        );
948        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
949        assert_eq!(
950            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
951                .classify(),
952            "config"
953        );
954        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
955        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
956        assert_eq!(
957            CamelError::ValidationError("bad".to_string()).classify(),
958            "validation"
959        );
960    }
961
962    #[test]
963    fn test_classify_output_is_ascii_and_short() {
964        for error in all_error_samples() {
965            let class = error.classify();
966            assert!(class.is_ascii());
967            // "unsupported_media_type" (REST negotiation, L2) sets the floor at 22
968            assert!(class.len() <= 22, "class too long: {class}");
969        }
970    }
971
972    #[test]
973    fn test_auth_variants_classify() {
974        assert_eq!(
975            CamelError::Unauthenticated("x".to_string()).classify(),
976            "unauthenticated"
977        );
978        assert_eq!(
979            CamelError::Unauthorized("x".to_string()).classify(),
980            "unauthorized"
981        );
982    }
983
984    #[test]
985    fn test_validation_error_classify() {
986        assert_eq!(
987            CamelError::ValidationError("bad".to_string()).classify(),
988            "validation"
989        );
990    }
991
992    #[test]
993    fn template_reload_classifies_as_template() {
994        let err = CamelError::TemplateReload("boom".into());
995        assert_eq!(err.classify(), "template");
996    }
997
998    #[test]
999    fn template_reload_variant_name() {
1000        let err = CamelError::TemplateReload("boom".into());
1001        assert_eq!(err.variant_name(), "TemplateReload");
1002    }
1003
1004    #[test]
1005    fn test_auth_variants_are_clone() {
1006        let err = CamelError::Unauthenticated("test".to_string());
1007        let cloned = err.clone();
1008        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
1009
1010        let err2 = CamelError::Unauthorized("test".to_string());
1011        let cloned2 = err2.clone();
1012        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
1013    }
1014
1015    #[test]
1016    fn classification_unchanged_for_callers() {
1017        // Pins the contract the pipeline-tracer circuit_open exclusion
1018        // relies on (dashboard-observability D2): CircuitOpen must keep
1019        // classifying as "circuit_open" — callers and the tracer skip
1020        // branch match on exactly this literal.
1021        assert_eq!(
1022            CamelError::CircuitOpen("breaker open".into()).classify(),
1023            "circuit_open"
1024        );
1025    }
1026
1027    #[test]
1028    fn auth_provider_unavailable_display_carries_detail() {
1029        let err = CamelError::AuthProviderUnavailable("conn refused".into());
1030        let msg = err.to_string();
1031        assert!(msg.contains("conn refused"));
1032        assert!(
1033            msg.starts_with("Auth provider unavailable"),
1034            "display should start with 'Auth provider unavailable', got: {msg}"
1035        );
1036    }
1037
1038    #[test]
1039    fn classify_negotiation_errors() {
1040        let unsupported = CamelError::UnsupportedMediaType {
1041            consumed: "text/plain".into(),
1042            declared: "application/json".into(),
1043        };
1044        let not_acceptable = CamelError::NotAcceptable {
1045            accept: "application/xml".into(),
1046            produced: "application/json".into(),
1047        };
1048        assert_eq!(unsupported.classify(), "unsupported_media_type");
1049        assert_eq!(not_acceptable.classify(), "not_acceptable");
1050    }
1051
1052    #[test]
1053    fn variant_names_negotiation_errors() {
1054        let unsupported = CamelError::UnsupportedMediaType {
1055            consumed: "text/plain".into(),
1056            declared: "application/json".into(),
1057        };
1058        let not_acceptable = CamelError::NotAcceptable {
1059            accept: "application/xml".into(),
1060            produced: "application/json".into(),
1061        };
1062        assert_eq!(unsupported.variant_name(), "UnsupportedMediaType");
1063        assert_eq!(not_acceptable.variant_name(), "NotAcceptable");
1064    }
1065
1066    #[test]
1067    fn error_classification_label_is_pinned() {
1068        // Pins `classify()` labels for every variant the ADR-0096 fault-blame
1069        // change touches, so a silent label move fails here first.
1070        let type_conversion = CamelError::TypeConversionFailed(String::new());
1071        let validation = CamelError::ValidationError(String::new());
1072        let unsupported = CamelError::UnsupportedMediaType {
1073            consumed: String::new(),
1074            declared: String::new(),
1075        };
1076        let not_acceptable = CamelError::NotAcceptable {
1077            accept: String::new(),
1078            produced: String::new(),
1079        };
1080        let unauthenticated = CamelError::Unauthenticated(String::new());
1081        let unauthorized = CamelError::Unauthorized(String::new());
1082        let processor_with_source = CamelError::ProcessorErrorWithSource(
1083            String::new(),
1084            Arc::new(std::io::Error::other("inner")),
1085        );
1086        let consumer_stopping = CamelError::ConsumerStopping;
1087
1088        assert_eq!(
1089            type_conversion.classify(),
1090            "type_conversion",
1091            "TypeConversionFailed must classify as type_conversion (ADR-0096)"
1092        );
1093        assert_eq!(
1094            validation.classify(),
1095            "validation",
1096            "ValidationError must classify as validation"
1097        );
1098        assert_eq!(
1099            unsupported.classify(),
1100            "unsupported_media_type",
1101            "UnsupportedMediaType must classify as unsupported_media_type"
1102        );
1103        assert_eq!(
1104            not_acceptable.classify(),
1105            "not_acceptable",
1106            "NotAcceptable must classify as not_acceptable"
1107        );
1108        assert_eq!(
1109            unauthenticated.classify(),
1110            "unauthenticated",
1111            "Unauthenticated must classify as unauthenticated"
1112        );
1113        assert_eq!(
1114            unauthorized.classify(),
1115            "unauthorized",
1116            "Unauthorized must classify as unauthorized"
1117        );
1118        assert_eq!(
1119            processor_with_source.classify(),
1120            "processor",
1121            "ProcessorErrorWithSource must classify as processor"
1122        );
1123        assert_eq!(
1124            consumer_stopping.classify(),
1125            "consumer_stop",
1126            "ConsumerStopping must classify as consumer_stop"
1127        );
1128    }
1129
1130    #[test]
1131    fn display_negotiation_errors() {
1132        let unsupported = CamelError::UnsupportedMediaType {
1133            consumed: "text/plain".into(),
1134            declared: "application/json".into(),
1135        };
1136        let not_acceptable = CamelError::NotAcceptable {
1137            accept: "application/xml".into(),
1138            produced: "application/json".into(),
1139        };
1140        let unsupported_msg = unsupported.to_string();
1141        assert!(unsupported_msg.contains("text/plain"));
1142        assert!(unsupported_msg.contains("application/json"));
1143        let not_acceptable_msg = not_acceptable.to_string();
1144        assert!(not_acceptable_msg.contains("application/xml"));
1145        assert!(not_acceptable_msg.contains("application/json"));
1146    }
1147
1148    #[test]
1149    fn expression_failed_display_has_no_exchange_data() {
1150        let err = CamelError::ExpressionFailed {
1151            language: "rhai".to_string(),
1152            route_id: "r1".to_string(),
1153            step_id: "set_property#0".to_string(),
1154            verb: "set_property".to_string(),
1155            class: ExpressionErrorClass::Arithmetic,
1156            position: Some(ErrorPosition { line: 3, column: 8 }),
1157            conversion: None,
1158            cause: None,
1159        };
1160        let msg = err.to_string();
1161        assert!(msg.contains("arithmetic"), "missing class: {msg}");
1162        assert!(msg.contains("rhai"), "missing language: {msg}");
1163        assert!(msg.contains("set_property"), "missing verb/step: {msg}");
1164        assert!(msg.contains("3:8"), "missing position: {msg}");
1165        // Nothing beyond the metadata fields: the full rendering is pinned.
1166        assert_eq!(
1167            msg,
1168            "expression failed: arithmetic in rhai `set_property` step \
1169             `set_property#0` (route `r1`) at 3:8"
1170        );
1171    }
1172
1173    #[test]
1174    fn expression_failed_variant_name() {
1175        let err = CamelError::ExpressionFailed {
1176            language: "rhai".to_string(),
1177            route_id: "r1".to_string(),
1178            step_id: "set_property#0".to_string(),
1179            verb: "set_property".to_string(),
1180            class: ExpressionErrorClass::Runtime,
1181            position: None,
1182            conversion: None,
1183            cause: None,
1184        };
1185        assert_eq!(err.variant_name(), "ExpressionFailed");
1186    }
1187
1188    #[test]
1189    fn config_validation_error_on_exception_conflict_display() {
1190        let err = ConfigValidationError::OnExceptionStepsHandledByConflict;
1191        let msg = format!("{err}");
1192        assert!(msg.contains("steps"));
1193        assert!(msg.contains("handled_by"));
1194        assert!(msg.contains("exclusive"));
1195    }
1196
1197    #[test]
1198    fn opaque_error_source_exposes_only_pointee() {
1199        let src = OpaqueErrorSource::new(Arc::new(SampleSource));
1200        let pointee = src.source().unwrap();
1201        assert!(pointee.downcast_ref::<SampleSource>().is_some());
1202    }
1203
1204    #[test]
1205    fn endpoint_creation_failed_with_source_aliases_to_plain() {
1206        let e = CamelError::EndpointCreationFailedWithSource(
1207            "d".to_string(),
1208            OpaqueErrorSource::new(Arc::new(SampleSource)),
1209        );
1210        assert_eq!(e.variant_name(), "EndpointCreationFailed");
1211        assert_eq!(e.classify(), "endpoint");
1212        assert_eq!(e.to_string(), "Endpoint creation failed: d");
1213    }
1214
1215    #[test]
1216    fn clone_preserves_variant_identity_for_all_error_samples() {
1217        for e in all_error_samples() {
1218            let c = e.clone();
1219            assert_eq!(c.variant_name(), e.variant_name());
1220            assert_eq!(c.classify(), e.classify());
1221            assert_eq!(c.to_string(), e.to_string());
1222        }
1223    }
1224
1225    #[test]
1226    fn camel_error_clone_preserves_source_provenance() {
1227        let e = CamelError::EndpointCreationFailedWithSource(
1228            "d".to_string(),
1229            OpaqueErrorSource::new(Arc::new(SampleSource)),
1230        );
1231        let c = e.clone();
1232        // `CamelError::source()` (thiserror #[source]) yields the wrapper
1233        // itself; the pointee is one more `source()` hop away — that hop is
1234        // the pointee-only mechanism under test (no Arc wrapper in between).
1235        let wrapper = c.source().unwrap();
1236        let pointee = wrapper.source().unwrap();
1237        assert!(pointee.downcast_ref::<SampleSource>().is_some());
1238    }
1239}
1240
1241#[cfg(test)]
1242mod variant_name_tests {
1243    use super::{
1244        CamelError, ConfigValidationError, EndpointUriError, ExpressionErrorClass,
1245        OpaqueErrorSource,
1246    };
1247    use std::sync::Arc;
1248
1249    /// Representative value for each enum variant. This test fails to compile
1250    /// when a new variant is added to CamelError without updating variant_name().
1251    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
1252    /// exhaustive matching is allowed.
1253    ///
1254    /// The table must list every variant exactly once (`cases.len()` is asserted
1255    /// below). When adding a CamelError variant, also update
1256    /// `test_exception_kind_vocabulary_classification_guard` in
1257    /// crates/camel-dsl/src/compile.rs and make the register-or-document
1258    /// decision (bd rc-5u8co).
1259    #[test]
1260    fn variant_name_covers_all_variants() {
1261        let cases: Vec<(CamelError, &str)> = vec![
1262            (
1263                CamelError::ComponentNotFound("x".into()),
1264                "ComponentNotFound",
1265            ),
1266            (
1267                CamelError::EndpointCreationFailed("x".into()),
1268                "EndpointCreationFailed",
1269            ),
1270            (
1271                CamelError::EndpointCreationFailedWithSource(
1272                    "x".into(),
1273                    OpaqueErrorSource::new(Arc::new(std::io::Error::other("y"))),
1274                ),
1275                "EndpointCreationFailed", // aliased
1276            ),
1277            (CamelError::ProcessorError("x".into()), "ProcessorError"),
1278            (
1279                CamelError::ProcessorErrorWithSource(
1280                    "x".into(),
1281                    Arc::new(std::io::Error::other("y")),
1282                ),
1283                "ProcessorError", // aliased
1284            ),
1285            (
1286                CamelError::TypeConversionFailed("x".into()),
1287                "TypeConversionFailed",
1288            ),
1289            (CamelError::InvalidUri("x".into()), "InvalidUri"),
1290            (CamelError::ChannelClosed, "ChannelClosed"),
1291            (CamelError::RouteError("x".into()), "RouteError"),
1292            (CamelError::Io("x".into()), "Io"),
1293            (
1294                CamelError::DeadLetterChannelFailed("x".into()),
1295                "DeadLetterChannelFailed",
1296            ),
1297            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
1298            (
1299                CamelError::HttpOperationFailed {
1300                    method: "GET".into(),
1301                    url: "https://example.com".into(),
1302                    status_code: 500,
1303                    status_text: "Internal Server Error".into(),
1304                    response_body: None,
1305                },
1306                "HttpOperationFailed",
1307            ),
1308            (CamelError::ConsumerStopping, "ConsumerStopping"),
1309            (CamelError::Config("x".into()), "Config"),
1310            (
1311                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
1312                "ConfigValidation",
1313            ),
1314            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
1315            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
1316            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
1317            (CamelError::Unauthorized("x".into()), "Unauthorized"),
1318            (CamelError::ValidationError("bad".into()), "ValidationError"),
1319            (CamelError::TemplateReload("x".into()), "TemplateReload"),
1320            (
1321                CamelError::EndpointUri(EndpointUriError::MissingScheme),
1322                "EndpointUri",
1323            ),
1324            (
1325                CamelError::UnsupportedMediaType {
1326                    consumed: "text/plain".into(),
1327                    declared: "application/json".into(),
1328                },
1329                "UnsupportedMediaType",
1330            ),
1331            (
1332                CamelError::NotAcceptable {
1333                    accept: "application/xml".into(),
1334                    produced: "application/json".into(),
1335                },
1336                "NotAcceptable",
1337            ),
1338            (
1339                CamelError::ExpressionFailed {
1340                    language: "rhai".into(),
1341                    route_id: "r1".into(),
1342                    step_id: "set_property#0".into(),
1343                    verb: "set_property".into(),
1344                    class: ExpressionErrorClass::Runtime,
1345                    position: None,
1346                    conversion: None,
1347                    cause: None,
1348                },
1349                "ExpressionFailed",
1350            ),
1351            (
1352                CamelError::AuthProviderUnavailable("x".into()),
1353                "ProcessorError",
1354            ),
1355        ];
1356
1357        assert_eq!(
1358            cases.len(),
1359            27,
1360            "variant_name_covers_all_variants must cover every CamelError variant; \
1361             extend this table and the camel-dsl classification guard"
1362        );
1363
1364        for (err, expected) in cases {
1365            assert_eq!(
1366                err.variant_name(),
1367                expected,
1368                "variant_name mismatch for {:?}",
1369                err
1370            );
1371        }
1372    }
1373
1374    #[test]
1375    fn auth_provider_unavailable_classifies_as_processor() {
1376        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1377        assert_eq!(err.classify(), "processor");
1378    }
1379
1380    #[test]
1381    fn auth_provider_unavailable_variant_name_aliases_processor_error() {
1382        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1383        assert_eq!(err.variant_name(), "ProcessorError");
1384    }
1385}