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/// Structured diagnostics payload whose `Debug` never prints its contents.
259///
260/// Wraps a [`serde_json::Value`] while redacting it from every `Debug`
261/// rendering, so accidental structured logging of a [`CamelError::Business`]
262/// cannot leak exchange data. Structural comparison (`PartialEq`/`Eq`) and
263/// cloning still go through the wrapped value; the payload is reachable only
264/// through the explicit [`RedactedJson::as_value`] accessor.
265///
266/// # Examples
267///
268/// ```
269/// use camel_api::RedactedJson;
270///
271/// let data = RedactedJson::new(serde_json::json!({"secret": "hunter2"}));
272/// assert_eq!(format!("{data:?}"), "[redacted]");
273/// assert_eq!(data.as_value()["secret"], "hunter2");
274/// ```
275#[derive(Clone, PartialEq, Eq)]
276pub struct RedactedJson(serde_json::Value);
277
278impl RedactedJson {
279    /// Wrap a JSON value as redacted diagnostics.
280    pub fn new(value: serde_json::Value) -> Self {
281        Self(value)
282    }
283
284    /// Borrow the wrapped value for internal consumers (Simple evaluator,
285    /// HTTP error mapping), which must not render it through `Debug`.
286    pub fn as_value(&self) -> &serde_json::Value {
287        &self.0
288    }
289}
290
291impl fmt::Debug for RedactedJson {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.write_str("[redacted]")
294    }
295}
296
297/// Core error type for the Camel framework.
298#[derive(Debug, Error)]
299#[non_exhaustive]
300pub enum CamelError {
301    #[error("Component not found: {0}")]
302    ComponentNotFound(String),
303
304    #[error("Endpoint creation failed: {0}")]
305    EndpointCreationFailed(String),
306
307    /// Like `EndpointCreationFailed` but preserves the source error chain
308    /// for downstream inspection (e.g. typed gate-rejection classification).
309    #[error("Endpoint creation failed: {0}")]
310    EndpointCreationFailedWithSource(String, #[source] OpaqueErrorSource),
311
312    #[error("Processor error: {0}")]
313    ProcessorError(String),
314
315    /// Like `ProcessorError` but preserves the source error chain
316    /// for downstream inspection (e.g. via `std::error::Error::source()`).
317    #[error("Processor error: {0}")]
318    ProcessorErrorWithSource(String, #[source] Arc<dyn std::error::Error + Send + Sync>),
319
320    #[error("Type conversion failed: {0}")]
321    TypeConversionFailed(String),
322
323    #[error("Invalid URI: {0}")]
324    InvalidUri(String),
325
326    #[error("Channel closed")]
327    ChannelClosed,
328
329    #[error("Route error: {0}")]
330    RouteError(String),
331
332    #[error("IO error: {0}")]
333    Io(String),
334
335    #[error("Dead letter channel failed: {0}")]
336    DeadLetterChannelFailed(String),
337
338    #[error("Circuit breaker open: {0}")]
339    CircuitOpen(String),
340
341    #[error("HTTP {method} {url} failed: {status_code} {status_text}")]
342    HttpOperationFailed {
343        method: String,
344        url: String,
345        status_code: u16,
346        status_text: String,
347        response_body: Option<String>,
348    },
349
350    /// Producer `call()` failed on a shutdown signal — the consumer/semaphore
351    /// is closing and the producer cannot acquire a permit. Distinct from Stop EIP
352    /// (which is successful control flow). Used by JMS/OpenSearch producers. See ADR-0024.
353    #[error("Consumer stopping: semaphore closed during call")]
354    ConsumerStopping,
355
356    #[error("Configuration error: {0}")]
357    Config(String),
358
359    /// Typed security-validation error (ADR-0033). Promotes Batch 1+ config/startup
360    /// failure modes from stringly-typed `Config(_)` to a matchable enum so
361    /// operators can discriminate programmatically.
362    #[error("Configuration validation error: {0}")]
363    ConfigValidation(ConfigValidationError),
364
365    #[error("Body stream has already been consumed")]
366    AlreadyConsumed,
367
368    #[error("Stream size exceeded limit: {0}")]
369    StreamLimitExceeded(usize),
370
371    #[error("Unauthenticated: {0}")]
372    Unauthenticated(String),
373
374    #[error("Unauthorized: {0}")]
375    Unauthorized(String),
376
377    /// Auth provider (JWKS/introspection/token endpoint) is unreachable or failing.
378    /// Promotes the auth-provider-down signal from a stringly-typed ProcessorError to
379    /// a matchable variant; WebSocket and gRPC transports map it to 503 / UNAVAILABLE.
380    #[error("Auth provider unavailable: {0}")]
381    AuthProviderUnavailable(String),
382
383    #[error("Validation failed: {0}")]
384    ValidationError(String),
385
386    #[error("Template reload failed: {0}")]
387    TemplateReload(String),
388
389    /// Typed endpoint-URI construction error (see [`EndpointUriError`]). Promotes the
390    /// fail-closed `EndpointUri` merge failures from stringly-typed errors to a matchable
391    /// variant so operators can discriminate programmatically.
392    #[error("Endpoint URI error: {0}")]
393    EndpointUri(EndpointUriError),
394
395    /// The request body media type does not match the declared/consumed type
396    /// (REST DSL default-strict content negotiation, HTTP 415).
397    #[error("Unsupported media type: consumed {consumed}, declared {declared}")]
398    UnsupportedMediaType { consumed: String, declared: String },
399
400    /// The response representation cannot satisfy the client's Accept header
401    /// (REST DSL default-strict content negotiation, HTTP 406).
402    #[error("Not acceptable: accept {accept}, produced {produced}")]
403    NotAcceptable { accept: String, produced: String },
404
405    /// Evaluation of a language expression failed during a route step
406    /// (language-value-boundary spec). All fields are route metadata and typed
407    /// diagnostics — no exchange data enters any field or the Display output.
408    #[error(
409        "expression failed: {class} in {language} `{verb}` step `{step_id}` (route `{route_id}`){}{}",
410        expression_position_suffix(.position),
411        expression_conversion_suffix(.conversion),
412    )]
413    ExpressionFailed {
414        /// Language the failing expression was written in (e.g. `rhai`).
415        language: String,
416        /// Id of the route that owned the failing step.
417        route_id: String,
418        /// Id of the failing step.
419        step_id: String,
420        /// DSL verb that evaluated the expression (e.g. `set_property`).
421        verb: String,
422        /// Typed failure classification.
423        class: ExpressionErrorClass,
424        /// Position inside the expression source, when the engine reports one.
425        position: Option<ErrorPosition>,
426        /// Failed result-conversion detail, when the failure was a conversion.
427        conversion: Option<ConversionDetail>,
428        /// Chained underlying error, when the failure wraps another
429        /// [`CamelError`] (e.g. a catch predicate failure chaining the
430        /// original route error).
431        #[source]
432        cause: Option<Box<CamelError>>,
433    },
434
435    /// Business (domain) failure raised by the `throw` step or a component that
436    /// classifies a failure as a client/domain fault.
437    ///
438    /// # Constructor contract
439    ///
440    /// - `code` and `kind` must be non-blank. Validation happens at the
441    ///   builders' boundaries (route load for the `throw` step); the variant
442    ///   itself makes no runtime guarantee.
443    /// - `message` is log-and-diagnostics only: it never reaches a client
444    ///   through any mapping, disposition, or configuration, and it is
445    ///   deliberately absent from the `Display` rendering.
446    /// - `data` holds optional structured diagnostics as a [`RedactedJson`]:
447    ///   its `Debug` prints `[redacted]`, so accidental structured logging
448    ///   cannot leak the payload.
449    #[error("business error {code} ({kind})")]
450    Business {
451        /// Stable machine-readable error code (e.g. `ORDER_NOT_FOUND`).
452        code: String,
453        /// Business kind used by `on_exceptions` / catch matchers (default
454        /// `failed` at the `throw` boundary).
455        kind: String,
456        /// Human-readable log-only summary; never sent to a client.
457        message: String,
458        /// Redacted structured diagnostics; never rendered by `Debug`.
459        data: Option<RedactedJson>,
460    },
461}
462
463/// Manual `Clone` impl: every arm clones its fields normally, except
464/// `EndpointCreationFailedWithSource`, which duplicates the opaque source
465/// handle via the crate-private `OpaqueErrorSource::clone_handle` (the wrapper
466/// itself is deliberately not `Clone`). Exhaustive like `variant_name()` — a
467/// new variant without an arm fails compilation.
468impl Clone for CamelError {
469    fn clone(&self) -> Self {
470        match self {
471            Self::ComponentNotFound(msg) => Self::ComponentNotFound(msg.clone()),
472            Self::EndpointCreationFailed(msg) => Self::EndpointCreationFailed(msg.clone()),
473            Self::EndpointCreationFailedWithSource(msg, source) => {
474                Self::EndpointCreationFailedWithSource(msg.clone(), source.clone_handle())
475            }
476            Self::ProcessorError(msg) => Self::ProcessorError(msg.clone()),
477            Self::ProcessorErrorWithSource(msg, source) => {
478                Self::ProcessorErrorWithSource(msg.clone(), Arc::clone(source))
479            }
480            Self::TypeConversionFailed(msg) => Self::TypeConversionFailed(msg.clone()),
481            Self::InvalidUri(msg) => Self::InvalidUri(msg.clone()),
482            Self::ChannelClosed => Self::ChannelClosed,
483            Self::RouteError(msg) => Self::RouteError(msg.clone()),
484            Self::Io(msg) => Self::Io(msg.clone()),
485            Self::DeadLetterChannelFailed(msg) => Self::DeadLetterChannelFailed(msg.clone()),
486            Self::CircuitOpen(msg) => Self::CircuitOpen(msg.clone()),
487            Self::HttpOperationFailed {
488                method,
489                url,
490                status_code,
491                status_text,
492                response_body,
493            } => Self::HttpOperationFailed {
494                method: method.clone(),
495                url: url.clone(),
496                status_code: *status_code,
497                status_text: status_text.clone(),
498                response_body: response_body.clone(),
499            },
500            Self::ConsumerStopping => Self::ConsumerStopping,
501            Self::Config(msg) => Self::Config(msg.clone()),
502            Self::ConfigValidation(e) => Self::ConfigValidation(e.clone()),
503            Self::AlreadyConsumed => Self::AlreadyConsumed,
504            Self::StreamLimitExceeded(limit) => Self::StreamLimitExceeded(*limit),
505            Self::Unauthenticated(msg) => Self::Unauthenticated(msg.clone()),
506            Self::Unauthorized(msg) => Self::Unauthorized(msg.clone()),
507            Self::AuthProviderUnavailable(msg) => Self::AuthProviderUnavailable(msg.clone()),
508            Self::ValidationError(msg) => Self::ValidationError(msg.clone()),
509            Self::TemplateReload(msg) => Self::TemplateReload(msg.clone()),
510            Self::EndpointUri(e) => Self::EndpointUri(e.clone()),
511            Self::UnsupportedMediaType { consumed, declared } => Self::UnsupportedMediaType {
512                consumed: consumed.clone(),
513                declared: declared.clone(),
514            },
515            Self::NotAcceptable { accept, produced } => Self::NotAcceptable {
516                accept: accept.clone(),
517                produced: produced.clone(),
518            },
519            Self::ExpressionFailed {
520                language,
521                route_id,
522                step_id,
523                verb,
524                class,
525                position,
526                conversion,
527                cause,
528            } => Self::ExpressionFailed {
529                language: language.clone(),
530                route_id: route_id.clone(),
531                step_id: step_id.clone(),
532                verb: verb.clone(),
533                class: *class,
534                position: *position,
535                conversion: conversion.clone(),
536                cause: cause.clone(),
537            },
538            Self::Business {
539                code,
540                kind,
541                message,
542                data,
543            } => Self::Business {
544                code: code.clone(),
545                kind: kind.clone(),
546                message: message.clone(),
547                data: data.clone(),
548            },
549        }
550    }
551}
552
553/// Classification marker for `CamelError::CircuitOpen`.
554///
555/// Shared named constant so the pipeline tracer's circuit-open exclusion
556/// (skip `increment_errors` — the breaker already recorded the rejection)
557/// and `classify` itself cannot drift apart (dashboard-observability D2).
558pub const CIRCUIT_OPEN: &str = "circuit_open";
559
560impl CamelError {
561    pub fn classify(&self) -> &'static str {
562        #[allow(unreachable_patterns)]
563        match self {
564            Self::ComponentNotFound(_) => "component",
565            Self::EndpointCreationFailed(_)
566            | Self::EndpointCreationFailedWithSource(_, _)
567            | Self::InvalidUri(_)
568            | Self::EndpointUri(_) => "endpoint",
569            Self::ProcessorError(_)
570            | Self::ProcessorErrorWithSource(_, _)
571            | Self::AuthProviderUnavailable(_) => "processor",
572            Self::TypeConversionFailed(_) | Self::AlreadyConsumed => "type_conversion",
573            Self::Io(_) => "io",
574            Self::RouteError(_) => "route",
575            Self::CircuitOpen(_) => CIRCUIT_OPEN,
576            Self::HttpOperationFailed { .. } => "http",
577            Self::Config(_) | Self::ConfigValidation(_) => "config",
578            Self::DeadLetterChannelFailed(_) => "dead_letter",
579            Self::ConsumerStopping => "consumer_stop",
580            Self::StreamLimitExceeded(_) => "stream",
581            Self::ChannelClosed => "channel",
582            Self::Unauthenticated(_) => "unauthenticated",
583            Self::Unauthorized(_) => "unauthorized",
584            Self::ValidationError(_) => "validation",
585            Self::TemplateReload(_) => "template",
586            Self::UnsupportedMediaType { .. } => "unsupported_media_type",
587            Self::NotAcceptable { .. } => "not_acceptable",
588            Self::Business { .. } => "business",
589            _ => "unknown",
590        }
591    }
592
593    /// Stable variant name used by `doTry` catch-by-variant matchers.
594    ///
595    /// `ProcessorErrorWithSource` and `AuthProviderUnavailable` alias to
596    /// `"ProcessorError"`, and `EndpointCreationFailedWithSource` aliases to
597    /// `"EndpointCreationFailed"` — the aliased variants are not distinguishable
598    /// by name in MVP (see spec §5.4), so existing `doTry` catch handlers keep
599    /// matching.
600    ///
601    /// The enum is `#[non_exhaustive]`; this match lives in the defining crate (camel-api),
602    /// so internal exhaustive matching is allowed. Adding a new variant without updating
603    /// this method will fail to compile, surfaced by `variant_name_tests`.
604    pub fn variant_name(&self) -> &'static str {
605        match self {
606            Self::ComponentNotFound(_) => "ComponentNotFound",
607            Self::EndpointCreationFailed(_) => "EndpointCreationFailed",
608            Self::EndpointCreationFailedWithSource(_, _) => "EndpointCreationFailed",
609            Self::ProcessorError(_) => "ProcessorError",
610            Self::ProcessorErrorWithSource(_, _) => "ProcessorError",
611            Self::AuthProviderUnavailable(_) => "ProcessorError",
612            Self::TypeConversionFailed(_) => "TypeConversionFailed",
613            Self::InvalidUri(_) => "InvalidUri",
614            Self::ChannelClosed => "ChannelClosed",
615            Self::RouteError(_) => "RouteError",
616            Self::Io(_) => "Io",
617            Self::DeadLetterChannelFailed(_) => "DeadLetterChannelFailed",
618            Self::CircuitOpen(_) => "CircuitOpen",
619            Self::HttpOperationFailed { .. } => "HttpOperationFailed",
620            Self::ConsumerStopping => "ConsumerStopping",
621            Self::Config(_) => "Config",
622            Self::ConfigValidation(_) => "ConfigValidation",
623            Self::AlreadyConsumed => "AlreadyConsumed",
624            Self::StreamLimitExceeded(_) => "StreamLimitExceeded",
625            Self::Unauthenticated(_) => "Unauthenticated",
626            Self::Unauthorized(_) => "Unauthorized",
627            Self::ValidationError(_) => "ValidationError",
628            Self::TemplateReload(_) => "TemplateReload",
629            Self::EndpointUri(_) => "EndpointUri",
630            Self::UnsupportedMediaType { .. } => "UnsupportedMediaType",
631            Self::NotAcceptable { .. } => "NotAcceptable",
632            Self::ExpressionFailed { .. } => "ExpressionFailed",
633            Self::Business { .. } => "Business",
634        }
635    }
636}
637
638impl From<std::io::Error> for CamelError {
639    fn from(err: std::io::Error) -> Self {
640        CamelError::Io(err.to_string())
641    }
642}
643
644impl From<crate::template::TemplateError> for CamelError {
645    fn from(err: crate::template::TemplateError) -> Self {
646        CamelError::Config(err.to_string())
647    }
648}
649
650impl From<ConfigValidationError> for CamelError {
651    fn from(e: ConfigValidationError) -> Self {
652        CamelError::ConfigValidation(e)
653    }
654}
655
656impl From<EndpointUriError> for CamelError {
657    fn from(e: EndpointUriError) -> Self {
658        CamelError::EndpointUri(e)
659    }
660}
661
662/// Boxed trait-object error used by Tower middleware (`Service::Error`).
663pub type BoxError = std::boxed::Box<dyn std::error::Error + Send + Sync + 'static>;
664
665/// Returns `true` when an [`tower::timeout::error::Elapsed`] appears anywhere
666/// in the source chain rooted at `err`.
667///
668/// The walk starts at `err` itself: `TimeoutLayer` boxes a bare `Elapsed` with
669/// no wrapper, so skipping the root would miss the common timeout case.
670fn source_chain_contains_elapsed(err: &(dyn std::error::Error + 'static)) -> bool {
671    let mut current = Some(err);
672    while let Some(source) = current {
673        if source
674            .downcast_ref::<tower::timeout::error::Elapsed>()
675            .is_some()
676        {
677            return true;
678        }
679        current = std::error::Error::source(source);
680    }
681    false
682}
683
684/// Converts a boxed middleware error into a [`CamelError`].
685///
686/// A boxed [`CamelError`] passes through unchanged. This keeps native exception
687/// matching and fault classification intact after a middleware hop instead of
688/// double-wrapping it as a processor error.
689///
690/// Any other boxed error becomes [`CamelError::ProcessorErrorWithSource`] with
691/// the original error preserved as the source, so the chain stays inspectable.
692/// A Tower timeout anywhere in the chain (not only at its root) is reported as
693/// a middleware timeout.
694///
695/// The payload is an [`OpaqueErrorSource`] around the `Arc`, not a bare `Arc`.
696/// With a bare `Arc` the chain would skip the pointee: thiserror's generated
697/// `source()` delegates through std's forwarding `Error for Arc<T>` impl to the
698/// pointee's own `source()`. The wrapper instead returns the pointee DIRECTLY
699/// from its own `source()`, with no `Arc` hop, so the concrete error is the
700/// FIRST node of the public chain — `err.source()` already yields it, and
701/// `downcast_ref` reaches it at hop 1 (e.g.
702/// `tower::timeout::error::Elapsed`).
703///
704/// # Examples
705///
706/// A boxed [`CamelError`] is returned unchanged:
707///
708/// ```
709/// use std::error::Error;
710///
711/// use camel_api::error::CamelError;
712///
713/// let boxed: Box<dyn Error + Send + Sync> = Box::new(CamelError::InvalidUri("u".into()));
714/// match CamelError::from(boxed) {
715///     CamelError::InvalidUri(uri) => assert_eq!(uri, "u"),
716///     other => panic!("expected pass-through, got {other:?}"),
717/// }
718/// ```
719///
720/// A timeout nested in the source chain is classified as a middleware timeout,
721/// and the source is preserved:
722///
723/// ```
724/// use std::error::Error;
725///
726/// use camel_api::error::CamelError;
727/// use tower::timeout::error::Elapsed;
728///
729/// #[derive(Debug)]
730/// struct TimedOut(Elapsed);
731///
732/// impl std::fmt::Display for TimedOut {
733///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
734///         f.write_str("middleware call timed out")
735///     }
736/// }
737///
738/// impl Error for TimedOut {
739///     fn source(&self) -> Option<&(dyn Error + 'static)> {
740///         Some(&self.0)
741///     }
742/// }
743///
744/// let boxed: Box<dyn Error + Send + Sync> = Box::new(TimedOut(Elapsed::new()));
745/// let err = CamelError::from(boxed);
746/// assert!(err.to_string().contains("middleware timeout elapsed"));
747///
748/// let mut source = err.source();
749/// let mut reached_elapsed = false;
750/// while let Some(inner) = source {
751///     if inner.downcast_ref::<Elapsed>().is_some() {
752///         reached_elapsed = true;
753///         break;
754///     }
755///     source = inner.source();
756/// }
757/// assert!(reached_elapsed, "source chain must reach the original Elapsed");
758/// ```
759impl From<BoxError> for CamelError {
760    fn from(err: BoxError) -> Self {
761        // Pass a native CamelError through untouched: double-wrapping it would
762        // break doTry catch-by-variant matching and fault classification.
763        match err.downcast::<CamelError>() {
764            Ok(camel_error) => *camel_error,
765            Err(err) => {
766                let message = if source_chain_contains_elapsed(err.as_ref()) {
767                    "middleware timeout elapsed"
768                } else {
769                    "middleware error"
770                };
771                CamelError::ProcessorErrorWithSource(
772                    message.to_string(),
773                    Arc::new(OpaqueErrorSource::new(Arc::from(err))),
774                )
775            }
776        }
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    // `super::*` brings in thiserror's `Error` derive macro; import the trait
784    // anonymously so `source()` is callable in tests.
785    use std::error::Error as _;
786
787    /// Minimal source error for opaque-provenance tests.
788    #[derive(Debug)]
789    struct SampleSource;
790
791    impl fmt::Display for SampleSource {
792        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793            f.write_str("sample source")
794        }
795    }
796
797    impl std::error::Error for SampleSource {}
798
799    /// Wrapper whose only source is a tower timeout [`Elapsed`], used to prove
800    /// the `BoxError` conversion walks the whole source chain.
801    ///
802    /// [`Elapsed`]: tower::timeout::error::Elapsed
803    #[derive(Debug)]
804    struct ElapsedSourcedError(tower::timeout::error::Elapsed);
805
806    impl fmt::Display for ElapsedSourcedError {
807        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
808            f.write_str("wrapped middleware failure")
809        }
810    }
811
812    impl std::error::Error for ElapsedSourcedError {
813        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
814            Some(&self.0)
815        }
816    }
817
818    #[test]
819    fn boxed_camel_error_passthrough_unchanged() {
820        let boxed: Box<dyn std::error::Error + Send + Sync> =
821            Box::<dyn std::error::Error + Send + Sync>::from(CamelError::InvalidUri(
822                "u".to_string(),
823            ));
824        let err = CamelError::from(boxed);
825        match err {
826            CamelError::InvalidUri(uri) => assert_eq!(uri, "u"),
827            other => panic!("expected InvalidUri passthrough, got {other:?}"),
828        }
829    }
830
831    /// Asserts a tower [`Elapsed`] is reachable through the public
832    /// [`std::error::Error::source`] chain within `max_hops` hops.
833    ///
834    /// This is the real consumer path: the `ProcessorErrorWithSource` payload is
835    /// opaque, so callers can only walk `source()` and `downcast_ref`.
836    ///
837    /// [`Elapsed`]: tower::timeout::error::Elapsed
838    fn assert_elapsed_within_source_hops(err: &CamelError, max_hops: usize) {
839        let mut current = err.source();
840        for _ in 0..max_hops {
841            let Some(node) = current else { break };
842            if node
843                .downcast_ref::<tower::timeout::error::Elapsed>()
844                .is_some()
845            {
846                return;
847            }
848            current = node.source();
849        }
850        panic!("source chain does not reach tower Elapsed within {max_hops} hops: {err:?}");
851    }
852
853    #[test]
854    fn timeout_error_classified_through_source_chain() {
855        let boxed: Box<dyn std::error::Error + Send + Sync> =
856            Box::new(ElapsedSourcedError(tower::timeout::error::Elapsed::new()));
857        let err = CamelError::from(boxed);
858        assert!(
859            err.to_string().contains("middleware timeout elapsed"),
860            "unexpected display: {err}"
861        );
862        assert_elapsed_within_source_hops(&err, 3);
863    }
864
865    #[test]
866    fn bare_elapsed_root_error_classified_as_timeout() {
867        // Mirrors probe P4: `TimeoutLayer` boxes a bare `Elapsed` at the chain
868        // root, with no wrapper error to walk through. The `OpaqueErrorSource`
869        // payload still surfaces it as a downcastable chain node (hop 1).
870        let boxed: Box<dyn std::error::Error + Send + Sync> =
871            Box::new(tower::timeout::error::Elapsed::new());
872        let err = CamelError::from(boxed);
873        assert!(
874            err.to_string().contains("middleware timeout elapsed"),
875            "unexpected display: {err}"
876        );
877        assert_elapsed_within_source_hops(&err, 3);
878    }
879
880    #[test]
881    #[allow(clippy::io_other_error)]
882    fn plain_box_error_preserves_source() {
883        let boxed: Box<dyn std::error::Error + Send + Sync> =
884            Box::new(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
885        let err = CamelError::from(boxed);
886        match &err {
887            CamelError::ProcessorErrorWithSource(message, _) => {
888                assert!(
889                    message.contains("middleware error"),
890                    "unexpected message: {message}"
891                );
892            }
893            other => panic!("expected ProcessorErrorWithSource, got {other:?}"),
894        }
895        let source = err
896            .source()
897            .expect("ProcessorErrorWithSource carries a source");
898        assert_eq!(source.to_string(), "boom");
899    }
900
901    fn all_error_samples() -> Vec<CamelError> {
902        vec![
903            CamelError::ComponentNotFound("x".to_string()),
904            CamelError::EndpointCreationFailed("x".to_string()),
905            CamelError::EndpointCreationFailedWithSource(
906                "x".to_string(),
907                OpaqueErrorSource::new(Arc::new(SampleSource)),
908            ),
909            CamelError::ProcessorError("x".to_string()),
910            CamelError::ProcessorErrorWithSource(
911                "x".to_string(),
912                Arc::new(std::io::Error::other("inner")),
913            ),
914            CamelError::TypeConversionFailed("x".to_string()),
915            CamelError::InvalidUri("x".to_string()),
916            CamelError::ChannelClosed,
917            CamelError::RouteError("x".to_string()),
918            CamelError::Io("x".to_string()),
919            CamelError::DeadLetterChannelFailed("x".to_string()),
920            CamelError::CircuitOpen("x".to_string()),
921            CamelError::HttpOperationFailed {
922                method: "GET".to_string(),
923                url: "https://example.com".to_string(),
924                status_code: 500,
925                status_text: "Internal Server Error".to_string(),
926                response_body: Some("error".to_string()),
927            },
928            CamelError::ConsumerStopping,
929            CamelError::Config("x".to_string()),
930            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
931            CamelError::AlreadyConsumed,
932            CamelError::StreamLimitExceeded(42),
933            CamelError::Unauthenticated("token expired".to_string()),
934            CamelError::Unauthorized("missing admin role".to_string()),
935            CamelError::AuthProviderUnavailable("jwks down".to_string()),
936            CamelError::ValidationError("body does not match schema".to_string()),
937            CamelError::TemplateReload("reload failed".to_string()),
938            CamelError::EndpointUri(EndpointUriError::MissingScheme),
939            CamelError::UnsupportedMediaType {
940                consumed: "text/plain".to_string(),
941                declared: "application/json".to_string(),
942            },
943            CamelError::NotAcceptable {
944                accept: "application/xml".to_string(),
945                produced: "application/json".to_string(),
946            },
947        ]
948    }
949
950    #[test]
951    fn test_http_operation_failed_display() {
952        let err = CamelError::HttpOperationFailed {
953            method: "GET".to_string(),
954            url: "https://example.com/test".to_string(),
955            status_code: 404,
956            status_text: "Not Found".to_string(),
957            response_body: Some("page not found".to_string()),
958        };
959        let msg = format!("{err}");
960        assert!(msg.contains("404"));
961        assert!(msg.contains("Not Found"));
962    }
963
964    #[test]
965    fn test_http_operation_failed_clone() {
966        let err = CamelError::HttpOperationFailed {
967            method: "POST".to_string(),
968            url: "https://api.example.com/users".to_string(),
969            status_code: 500,
970            status_text: "Internal Server Error".to_string(),
971            response_body: None,
972        };
973        let cloned = err.clone();
974        assert!(matches!(
975            cloned,
976            CamelError::HttpOperationFailed {
977                status_code: 500,
978                ..
979            }
980        ));
981    }
982
983    #[test]
984    fn test_classify_maps_all_variants() {
985        assert_eq!(
986            CamelError::ComponentNotFound("x".to_string()).classify(),
987            "component"
988        );
989        assert_eq!(
990            CamelError::EndpointCreationFailed("x".to_string()).classify(),
991            "endpoint"
992        );
993        assert_eq!(
994            CamelError::ProcessorError("x".to_string()).classify(),
995            "processor"
996        );
997        assert_eq!(
998            CamelError::TypeConversionFailed("x".to_string()).classify(),
999            "type_conversion"
1000        );
1001        assert_eq!(
1002            CamelError::InvalidUri("x".to_string()).classify(),
1003            "endpoint"
1004        );
1005        assert_eq!(CamelError::ChannelClosed.classify(), "channel");
1006        assert_eq!(CamelError::RouteError("x".to_string()).classify(), "route");
1007        assert_eq!(CamelError::Io("x".to_string()).classify(), "io");
1008        assert_eq!(
1009            CamelError::DeadLetterChannelFailed("x".to_string()).classify(),
1010            "dead_letter"
1011        );
1012        assert_eq!(
1013            CamelError::CircuitOpen("x".to_string()).classify(),
1014            "circuit_open"
1015        );
1016        assert_eq!(
1017            CamelError::HttpOperationFailed {
1018                method: "GET".to_string(),
1019                url: "https://example.com".to_string(),
1020                status_code: 500,
1021                status_text: "Internal Server Error".to_string(),
1022                response_body: None,
1023            }
1024            .classify(),
1025            "http"
1026        );
1027        assert_eq!(CamelError::Config("x".to_string()).classify(), "config");
1028        assert_eq!(
1029            CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero)
1030                .classify(),
1031            "config"
1032        );
1033        assert_eq!(CamelError::AlreadyConsumed.classify(), "type_conversion");
1034        assert_eq!(CamelError::StreamLimitExceeded(42).classify(), "stream");
1035        assert_eq!(
1036            CamelError::ValidationError("bad".to_string()).classify(),
1037            "validation"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_classify_output_is_ascii_and_short() {
1043        for error in all_error_samples() {
1044            let class = error.classify();
1045            assert!(class.is_ascii());
1046            // "unsupported_media_type" (REST negotiation, L2) sets the floor at 22
1047            assert!(class.len() <= 22, "class too long: {class}");
1048        }
1049    }
1050
1051    #[test]
1052    fn test_auth_variants_classify() {
1053        assert_eq!(
1054            CamelError::Unauthenticated("x".to_string()).classify(),
1055            "unauthenticated"
1056        );
1057        assert_eq!(
1058            CamelError::Unauthorized("x".to_string()).classify(),
1059            "unauthorized"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_validation_error_classify() {
1065        assert_eq!(
1066            CamelError::ValidationError("bad".to_string()).classify(),
1067            "validation"
1068        );
1069    }
1070
1071    #[test]
1072    fn template_reload_classifies_as_template() {
1073        let err = CamelError::TemplateReload("boom".into());
1074        assert_eq!(err.classify(), "template");
1075    }
1076
1077    #[test]
1078    fn template_reload_variant_name() {
1079        let err = CamelError::TemplateReload("boom".into());
1080        assert_eq!(err.variant_name(), "TemplateReload");
1081    }
1082
1083    #[test]
1084    fn test_auth_variants_are_clone() {
1085        let err = CamelError::Unauthenticated("test".to_string());
1086        let cloned = err.clone();
1087        assert!(matches!(cloned, CamelError::Unauthenticated(_)));
1088
1089        let err2 = CamelError::Unauthorized("test".to_string());
1090        let cloned2 = err2.clone();
1091        assert!(matches!(cloned2, CamelError::Unauthorized(_)));
1092    }
1093
1094    #[test]
1095    fn classification_unchanged_for_callers() {
1096        // Pins the contract the pipeline-tracer circuit_open exclusion
1097        // relies on (dashboard-observability D2): CircuitOpen must keep
1098        // classifying as "circuit_open" — callers and the tracer skip
1099        // branch match on exactly this literal.
1100        assert_eq!(
1101            CamelError::CircuitOpen("breaker open".into()).classify(),
1102            "circuit_open"
1103        );
1104    }
1105
1106    #[test]
1107    fn auth_provider_unavailable_display_carries_detail() {
1108        let err = CamelError::AuthProviderUnavailable("conn refused".into());
1109        let msg = err.to_string();
1110        assert!(msg.contains("conn refused"));
1111        assert!(
1112            msg.starts_with("Auth provider unavailable"),
1113            "display should start with 'Auth provider unavailable', got: {msg}"
1114        );
1115    }
1116
1117    #[test]
1118    fn classify_negotiation_errors() {
1119        let unsupported = CamelError::UnsupportedMediaType {
1120            consumed: "text/plain".into(),
1121            declared: "application/json".into(),
1122        };
1123        let not_acceptable = CamelError::NotAcceptable {
1124            accept: "application/xml".into(),
1125            produced: "application/json".into(),
1126        };
1127        assert_eq!(unsupported.classify(), "unsupported_media_type");
1128        assert_eq!(not_acceptable.classify(), "not_acceptable");
1129    }
1130
1131    #[test]
1132    fn variant_names_negotiation_errors() {
1133        let unsupported = CamelError::UnsupportedMediaType {
1134            consumed: "text/plain".into(),
1135            declared: "application/json".into(),
1136        };
1137        let not_acceptable = CamelError::NotAcceptable {
1138            accept: "application/xml".into(),
1139            produced: "application/json".into(),
1140        };
1141        assert_eq!(unsupported.variant_name(), "UnsupportedMediaType");
1142        assert_eq!(not_acceptable.variant_name(), "NotAcceptable");
1143    }
1144
1145    #[test]
1146    fn error_classification_label_is_pinned() {
1147        // Pins `classify()` labels for every variant the ADR-0096 fault-blame
1148        // change touches, so a silent label move fails here first.
1149        let type_conversion = CamelError::TypeConversionFailed(String::new());
1150        let validation = CamelError::ValidationError(String::new());
1151        let unsupported = CamelError::UnsupportedMediaType {
1152            consumed: String::new(),
1153            declared: String::new(),
1154        };
1155        let not_acceptable = CamelError::NotAcceptable {
1156            accept: String::new(),
1157            produced: String::new(),
1158        };
1159        let unauthenticated = CamelError::Unauthenticated(String::new());
1160        let unauthorized = CamelError::Unauthorized(String::new());
1161        let processor_with_source = CamelError::ProcessorErrorWithSource(
1162            String::new(),
1163            Arc::new(std::io::Error::other("inner")),
1164        );
1165        let consumer_stopping = CamelError::ConsumerStopping;
1166
1167        assert_eq!(
1168            type_conversion.classify(),
1169            "type_conversion",
1170            "TypeConversionFailed must classify as type_conversion (ADR-0096)"
1171        );
1172        assert_eq!(
1173            validation.classify(),
1174            "validation",
1175            "ValidationError must classify as validation"
1176        );
1177        assert_eq!(
1178            unsupported.classify(),
1179            "unsupported_media_type",
1180            "UnsupportedMediaType must classify as unsupported_media_type"
1181        );
1182        assert_eq!(
1183            not_acceptable.classify(),
1184            "not_acceptable",
1185            "NotAcceptable must classify as not_acceptable"
1186        );
1187        assert_eq!(
1188            unauthenticated.classify(),
1189            "unauthenticated",
1190            "Unauthenticated must classify as unauthenticated"
1191        );
1192        assert_eq!(
1193            unauthorized.classify(),
1194            "unauthorized",
1195            "Unauthorized must classify as unauthorized"
1196        );
1197        assert_eq!(
1198            processor_with_source.classify(),
1199            "processor",
1200            "ProcessorErrorWithSource must classify as processor"
1201        );
1202        assert_eq!(
1203            consumer_stopping.classify(),
1204            "consumer_stop",
1205            "ConsumerStopping must classify as consumer_stop"
1206        );
1207    }
1208
1209    #[test]
1210    fn display_negotiation_errors() {
1211        let unsupported = CamelError::UnsupportedMediaType {
1212            consumed: "text/plain".into(),
1213            declared: "application/json".into(),
1214        };
1215        let not_acceptable = CamelError::NotAcceptable {
1216            accept: "application/xml".into(),
1217            produced: "application/json".into(),
1218        };
1219        let unsupported_msg = unsupported.to_string();
1220        assert!(unsupported_msg.contains("text/plain"));
1221        assert!(unsupported_msg.contains("application/json"));
1222        let not_acceptable_msg = not_acceptable.to_string();
1223        assert!(not_acceptable_msg.contains("application/xml"));
1224        assert!(not_acceptable_msg.contains("application/json"));
1225    }
1226
1227    #[test]
1228    fn expression_failed_display_has_no_exchange_data() {
1229        let err = CamelError::ExpressionFailed {
1230            language: "rhai".to_string(),
1231            route_id: "r1".to_string(),
1232            step_id: "set_property#0".to_string(),
1233            verb: "set_property".to_string(),
1234            class: ExpressionErrorClass::Arithmetic,
1235            position: Some(ErrorPosition { line: 3, column: 8 }),
1236            conversion: None,
1237            cause: None,
1238        };
1239        let msg = err.to_string();
1240        assert!(msg.contains("arithmetic"), "missing class: {msg}");
1241        assert!(msg.contains("rhai"), "missing language: {msg}");
1242        assert!(msg.contains("set_property"), "missing verb/step: {msg}");
1243        assert!(msg.contains("3:8"), "missing position: {msg}");
1244        // Nothing beyond the metadata fields: the full rendering is pinned.
1245        assert_eq!(
1246            msg,
1247            "expression failed: arithmetic in rhai `set_property` step \
1248             `set_property#0` (route `r1`) at 3:8"
1249        );
1250    }
1251
1252    #[test]
1253    fn expression_failed_variant_name() {
1254        let err = CamelError::ExpressionFailed {
1255            language: "rhai".to_string(),
1256            route_id: "r1".to_string(),
1257            step_id: "set_property#0".to_string(),
1258            verb: "set_property".to_string(),
1259            class: ExpressionErrorClass::Runtime,
1260            position: None,
1261            conversion: None,
1262            cause: None,
1263        };
1264        assert_eq!(err.variant_name(), "ExpressionFailed");
1265    }
1266
1267    #[test]
1268    fn config_validation_error_on_exception_conflict_display() {
1269        let err = ConfigValidationError::OnExceptionStepsHandledByConflict;
1270        let msg = format!("{err}");
1271        assert!(msg.contains("steps"));
1272        assert!(msg.contains("handled_by"));
1273        assert!(msg.contains("exclusive"));
1274    }
1275
1276    #[test]
1277    fn opaque_error_source_exposes_only_pointee() {
1278        let src = OpaqueErrorSource::new(Arc::new(SampleSource));
1279        let pointee = src.source().unwrap();
1280        assert!(pointee.downcast_ref::<SampleSource>().is_some());
1281    }
1282
1283    #[test]
1284    fn endpoint_creation_failed_with_source_aliases_to_plain() {
1285        let e = CamelError::EndpointCreationFailedWithSource(
1286            "d".to_string(),
1287            OpaqueErrorSource::new(Arc::new(SampleSource)),
1288        );
1289        assert_eq!(e.variant_name(), "EndpointCreationFailed");
1290        assert_eq!(e.classify(), "endpoint");
1291        assert_eq!(e.to_string(), "Endpoint creation failed: d");
1292    }
1293
1294    #[test]
1295    fn clone_preserves_variant_identity_for_all_error_samples() {
1296        for e in all_error_samples() {
1297            let c = e.clone();
1298            assert_eq!(c.variant_name(), e.variant_name());
1299            assert_eq!(c.classify(), e.classify());
1300            assert_eq!(c.to_string(), e.to_string());
1301        }
1302    }
1303
1304    #[test]
1305    fn camel_error_clone_preserves_source_provenance() {
1306        let e = CamelError::EndpointCreationFailedWithSource(
1307            "d".to_string(),
1308            OpaqueErrorSource::new(Arc::new(SampleSource)),
1309        );
1310        let c = e.clone();
1311        // `CamelError::source()` (thiserror #[source]) yields the wrapper
1312        // itself; the pointee is one more `source()` hop away — that hop is
1313        // the pointee-only mechanism under test (no Arc wrapper in between).
1314        let wrapper = c.source().unwrap();
1315        let pointee = wrapper.source().unwrap();
1316        assert!(pointee.downcast_ref::<SampleSource>().is_some());
1317    }
1318}
1319
1320#[cfg(test)]
1321mod variant_name_tests {
1322    use super::{
1323        CamelError, ConfigValidationError, EndpointUriError, ExpressionErrorClass,
1324        OpaqueErrorSource, RedactedJson,
1325    };
1326    use std::sync::Arc;
1327
1328    /// Representative value for each enum variant. This test fails to compile
1329    /// when a new variant is added to CamelError without updating variant_name().
1330    /// The enum is `#[non_exhaustive]` but this match lives in the same crate, so internal
1331    /// exhaustive matching is allowed.
1332    ///
1333    /// The table must list every variant exactly once (`cases.len()` is asserted
1334    /// below). When adding a CamelError variant, also update
1335    /// `test_exception_kind_vocabulary_classification_guard` in
1336    /// crates/camel-dsl/src/compile.rs and make the register-or-document
1337    /// decision (bd rc-5u8co).
1338    #[test]
1339    fn variant_name_covers_all_variants() {
1340        let cases: Vec<(CamelError, &str)> = vec![
1341            (
1342                CamelError::ComponentNotFound("x".into()),
1343                "ComponentNotFound",
1344            ),
1345            (
1346                CamelError::EndpointCreationFailed("x".into()),
1347                "EndpointCreationFailed",
1348            ),
1349            (
1350                CamelError::EndpointCreationFailedWithSource(
1351                    "x".into(),
1352                    OpaqueErrorSource::new(Arc::new(std::io::Error::other("y"))),
1353                ),
1354                "EndpointCreationFailed", // aliased
1355            ),
1356            (CamelError::ProcessorError("x".into()), "ProcessorError"),
1357            (
1358                CamelError::ProcessorErrorWithSource(
1359                    "x".into(),
1360                    Arc::new(std::io::Error::other("y")),
1361                ),
1362                "ProcessorError", // aliased
1363            ),
1364            (
1365                CamelError::TypeConversionFailed("x".into()),
1366                "TypeConversionFailed",
1367            ),
1368            (CamelError::InvalidUri("x".into()), "InvalidUri"),
1369            (CamelError::ChannelClosed, "ChannelClosed"),
1370            (CamelError::RouteError("x".into()), "RouteError"),
1371            (CamelError::Io("x".into()), "Io"),
1372            (
1373                CamelError::DeadLetterChannelFailed("x".into()),
1374                "DeadLetterChannelFailed",
1375            ),
1376            (CamelError::CircuitOpen("x".into()), "CircuitOpen"),
1377            (
1378                CamelError::HttpOperationFailed {
1379                    method: "GET".into(),
1380                    url: "https://example.com".into(),
1381                    status_code: 500,
1382                    status_text: "Internal Server Error".into(),
1383                    response_body: None,
1384                },
1385                "HttpOperationFailed",
1386            ),
1387            (CamelError::ConsumerStopping, "ConsumerStopping"),
1388            (CamelError::Config("x".into()), "Config"),
1389            (
1390                CamelError::ConfigValidation(ConfigValidationError::ThrottlerMaxRequestsZero),
1391                "ConfigValidation",
1392            ),
1393            (CamelError::AlreadyConsumed, "AlreadyConsumed"),
1394            (CamelError::StreamLimitExceeded(42), "StreamLimitExceeded"),
1395            (CamelError::Unauthenticated("x".into()), "Unauthenticated"),
1396            (CamelError::Unauthorized("x".into()), "Unauthorized"),
1397            (CamelError::ValidationError("bad".into()), "ValidationError"),
1398            (CamelError::TemplateReload("x".into()), "TemplateReload"),
1399            (
1400                CamelError::EndpointUri(EndpointUriError::MissingScheme),
1401                "EndpointUri",
1402            ),
1403            (
1404                CamelError::UnsupportedMediaType {
1405                    consumed: "text/plain".into(),
1406                    declared: "application/json".into(),
1407                },
1408                "UnsupportedMediaType",
1409            ),
1410            (
1411                CamelError::NotAcceptable {
1412                    accept: "application/xml".into(),
1413                    produced: "application/json".into(),
1414                },
1415                "NotAcceptable",
1416            ),
1417            (
1418                CamelError::ExpressionFailed {
1419                    language: "rhai".into(),
1420                    route_id: "r1".into(),
1421                    step_id: "set_property#0".into(),
1422                    verb: "set_property".into(),
1423                    class: ExpressionErrorClass::Runtime,
1424                    position: None,
1425                    conversion: None,
1426                    cause: None,
1427                },
1428                "ExpressionFailed",
1429            ),
1430            (
1431                CamelError::AuthProviderUnavailable("x".into()),
1432                "ProcessorError",
1433            ),
1434            (
1435                CamelError::Business {
1436                    code: "ORDER_NOT_FOUND".into(),
1437                    kind: "not-found".into(),
1438                    message: "m".into(),
1439                    data: None,
1440                },
1441                "Business",
1442            ),
1443        ];
1444
1445        assert_eq!(
1446            cases.len(),
1447            28,
1448            "variant_name_covers_all_variants must cover every CamelError variant; \
1449             extend this table and the camel-dsl classification guard"
1450        );
1451
1452        for (err, expected) in cases {
1453            assert_eq!(
1454                err.variant_name(),
1455                expected,
1456                "variant_name mismatch for {:?}",
1457                err
1458            );
1459        }
1460    }
1461
1462    #[test]
1463    fn classify_and_variant_name_business() {
1464        let err = CamelError::Business {
1465            code: "ORDER_NOT_FOUND".to_string(),
1466            kind: "not-found".to_string(),
1467            message: "m".to_string(),
1468            data: None,
1469        };
1470
1471        assert_eq!(err.classify(), "business");
1472        assert_eq!(err.variant_name(), "Business");
1473    }
1474
1475    #[test]
1476    fn debug_on_data_newtype_redacts() {
1477        let data = RedactedJson::new(serde_json::json!({"secret": "hunter2"}));
1478        let err = CamelError::Business {
1479            code: "ORDER_NOT_FOUND".to_string(),
1480            kind: "not-found".to_string(),
1481            message: "m".to_string(),
1482            data: Some(data.clone()),
1483        };
1484
1485        let rendered = format!("{err:?}");
1486        assert!(
1487            rendered.contains("[redacted]"),
1488            "missing redaction marker: {rendered}"
1489        );
1490        assert!(!rendered.contains("hunter2"), "data leaked: {rendered}");
1491        assert_eq!(format!("{data:?}"), "[redacted]");
1492        // Redaction must not weaken structural equality of the payload.
1493        assert_eq!(
1494            data,
1495            RedactedJson::new(serde_json::json!({"secret": "hunter2"}))
1496        );
1497    }
1498
1499    #[test]
1500    fn display_includes_code_and_kind_but_redacts_message_and_data() {
1501        let err = CamelError::Business {
1502            code: "ORDER_NOT_FOUND".to_string(),
1503            kind: "not-found".to_string(),
1504            message: "secret-message".to_string(),
1505            data: Some(RedactedJson::new(serde_json::json!({"secret": "hunter2"}))),
1506        };
1507
1508        let rendered = format!("{err}");
1509        assert!(
1510            rendered.contains("ORDER_NOT_FOUND"),
1511            "Display must include the code: {rendered}"
1512        );
1513        assert!(
1514            rendered.contains("not-found"),
1515            "Display must include the kind: {rendered}"
1516        );
1517        assert!(
1518            !rendered.contains("secret-message"),
1519            "Display leaked the log-only message: {rendered}"
1520        );
1521        assert!(
1522            !rendered.contains("hunter2"),
1523            "Display leaked the data payload: {rendered}"
1524        );
1525    }
1526
1527    #[test]
1528    fn auth_provider_unavailable_classifies_as_processor() {
1529        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1530        assert_eq!(err.classify(), "processor");
1531    }
1532
1533    #[test]
1534    fn auth_provider_unavailable_variant_name_aliases_processor_error() {
1535        let err = CamelError::AuthProviderUnavailable("jwks down".into());
1536        assert_eq!(err.variant_name(), "ProcessorError");
1537    }
1538}