1use std::fmt;
2use std::sync::Arc;
3use thiserror::Error;
4
5#[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 #[error("aggregator requires at least one of max_buckets, completionTimeout, or bucket_ttl")]
23 AggregatorMissingMemoryBound,
24
25 #[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#[derive(Debug, Clone, PartialEq, Eq, Error)]
58#[non_exhaustive]
59pub enum EndpointUriError {
60 #[error(
62 "endpoint URI parameter `{key}` duplicates a key already present in the base URI query"
63 )]
64 DuplicateKey { key: String },
65
66 #[error("endpoint URI is missing a scheme (expected `scheme:path`)")]
68 MissingScheme,
69
70 #[error("endpoint URI query contains a pair with an empty key")]
72 EmptyQueryKey,
73
74 #[error("endpoint URI parameter key `{key}` is empty or contains a reserved character")]
76 InvalidParamKey { key: String },
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum ExpressionErrorClass {
88 Runtime,
90 Arithmetic,
92 TypeMismatch,
94 FunctionNotFound,
96 Limit,
98 Timeout,
100 Conversion,
102 Parse,
104}
105
106impl ExpressionErrorClass {
107 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub struct ErrorPosition {
132 pub line: u32,
134 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#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct ConversionDetail {
150 pub source_type: String,
152 pub target: String,
154}
155
156fn expression_position_suffix(position: &Option<ErrorPosition>) -> String {
158 match position {
159 Some(pos) => format!(" at {pos}"),
160 None => String::new(),
161 }
162}
163
164fn 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#[derive(Debug)]
228pub struct OpaqueErrorSource(Arc<dyn std::error::Error + Send + Sync>);
229
230impl OpaqueErrorSource {
231 pub fn new(source: Arc<dyn std::error::Error + Send + Sync>) -> Self {
233 Self(source)
234 }
235
236 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 Some(self.0.as_ref())
255 }
256}
257
258#[derive(Clone, PartialEq, Eq)]
276pub struct RedactedJson(serde_json::Value);
277
278impl RedactedJson {
279 pub fn new(value: serde_json::Value) -> Self {
281 Self(value)
282 }
283
284 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#[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 #[error("Endpoint creation failed: {0}")]
310 EndpointCreationFailedWithSource(String, #[source] OpaqueErrorSource),
311
312 #[error("Processor error: {0}")]
313 ProcessorError(String),
314
315 #[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 #[error("Consumer stopping: semaphore closed during call")]
354 ConsumerStopping,
355
356 #[error("Configuration error: {0}")]
357 Config(String),
358
359 #[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 #[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 #[error("Endpoint URI error: {0}")]
393 EndpointUri(EndpointUriError),
394
395 #[error("Unsupported media type: consumed {consumed}, declared {declared}")]
398 UnsupportedMediaType { consumed: String, declared: String },
399
400 #[error("Not acceptable: accept {accept}, produced {produced}")]
403 NotAcceptable { accept: String, produced: String },
404
405 #[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: String,
416 route_id: String,
418 step_id: String,
420 verb: String,
422 class: ExpressionErrorClass,
424 position: Option<ErrorPosition>,
426 conversion: Option<ConversionDetail>,
428 #[source]
432 cause: Option<Box<CamelError>>,
433 },
434
435 #[error("business error {code} ({kind})")]
450 Business {
451 code: String,
453 kind: String,
456 message: String,
458 data: Option<RedactedJson>,
460 },
461}
462
463impl 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
553pub 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 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
662pub type BoxError = std::boxed::Box<dyn std::error::Error + Send + Sync + 'static>;
664
665fn 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
684impl From<BoxError> for CamelError {
760 fn from(err: BoxError) -> Self {
761 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 use std::error::Error as _;
786
787 #[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 #[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 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 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 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 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 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 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 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 #[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", ),
1356 (CamelError::ProcessorError("x".into()), "ProcessorError"),
1357 (
1358 CamelError::ProcessorErrorWithSource(
1359 "x".into(),
1360 Arc::new(std::io::Error::other("y")),
1361 ),
1362 "ProcessorError", ),
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 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}