1use crate::engine::utils::{compute_path_parts, strip_hash_prefix};
2use chrono::Utc;
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5use thiserror::Error;
6
7#[derive(Debug, Error, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum DataflowError {
15 #[error("Validation error: {0}")]
17 Validation(String),
18
19 #[error("Function execution error: {context}")]
21 FunctionExecution {
22 context: String,
23 #[source]
24 #[serde(skip)]
25 source: Option<Box<DataflowError>>,
26 },
27
28 #[error("Workflow error: {0}")]
30 Workflow(String),
31
32 #[error("Task error: {0}")]
34 Task(String),
35
36 #[error("Function not found: {0}")]
38 FunctionNotFound(String),
39
40 #[error("Deserialization error: {0}")]
42 Deserialization(String),
43
44 #[error("IO error: {0}")]
46 Io(String),
47
48 #[error("Logic evaluation error: {0}")]
50 LogicEvaluation(String),
51
52 #[error("HTTP error: {status} - {message}")]
54 Http { status: u16, message: String },
55
56 #[error("Timeout error: {0}")]
58 Timeout(String),
59
60 #[error("Unknown error: {0}")]
62 Unknown(String),
63
64 #[error("{message}")]
77 Service {
78 kind: String,
80 message: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 detail: Option<String>,
86 retryable: bool,
89 },
90}
91
92impl DataflowError {
93 pub fn function_execution<S: Into<String>>(context: S, source: Option<DataflowError>) -> Self {
95 DataflowError::FunctionExecution {
96 context: context.into(),
97 source: source.map(Box::new),
98 }
99 }
100
101 pub fn http<S: Into<String>>(status: u16, message: S) -> Self {
103 DataflowError::Http {
104 status,
105 message: message.into(),
106 }
107 }
108
109 pub fn from_io(err: std::io::Error) -> Self {
111 DataflowError::Io(err.to_string())
112 }
113
114 pub fn from_serde(err: serde_json::Error) -> Self {
116 DataflowError::Deserialization(err.to_string())
117 }
118
119 pub fn retryable(&self) -> bool {
125 match self {
126 DataflowError::Http { status, .. } => {
128 *status >= 500 || *status == 429 || *status == 408 || *status == 0
130 }
132 DataflowError::Timeout(_) => true,
133 DataflowError::Io(_) => true,
134 DataflowError::FunctionExecution { source, .. } => {
135 source.as_ref().map(|e| e.retryable()).unwrap_or(false)
137 }
138
139 DataflowError::Validation(_) => false,
141 DataflowError::LogicEvaluation(_) => false,
142 DataflowError::Deserialization(_) => false,
143 DataflowError::Workflow(_) => false,
144 DataflowError::Task(_) => false,
145 DataflowError::FunctionNotFound(_) => false,
146 DataflowError::Unknown(_) => false,
147
148 DataflowError::Service { retryable, .. } => *retryable,
150 }
151 }
152
153 pub fn kind(&self) -> Option<&str> {
159 match self {
160 DataflowError::Service { kind, .. } => Some(kind),
161 DataflowError::FunctionExecution { source, .. } => {
164 source.as_deref().and_then(DataflowError::kind)
165 }
166 _ => None,
167 }
168 }
169
170 pub fn detail(&self) -> Option<&str> {
176 match self {
177 DataflowError::Service { detail, .. } => detail.as_deref(),
178 DataflowError::FunctionExecution { source, .. } => {
179 source.as_deref().and_then(DataflowError::detail)
180 }
181 _ => None,
182 }
183 }
184
185 pub fn service(kind: impl Into<String>, message: impl Into<String>) -> ServiceErrorBuilder {
205 ServiceErrorBuilder {
206 kind: kind.into(),
207 message: message.into(),
208 detail: None,
209 retryable: false,
210 }
211 }
212}
213
214#[must_use = "ServiceErrorBuilder must be `.build()` to produce a DataflowError"]
216pub struct ServiceErrorBuilder {
217 kind: String,
218 message: String,
219 detail: Option<String>,
220 retryable: bool,
221}
222
223impl ServiceErrorBuilder {
224 pub fn detail(mut self, detail: impl Into<String>) -> Self {
227 self.detail = Some(detail.into());
228 self
229 }
230
231 pub fn retryable(mut self, retryable: bool) -> Self {
235 self.retryable = retryable;
236 self
237 }
238
239 pub fn build(self) -> DataflowError {
240 DataflowError::Service {
241 kind: self.kind,
242 message: self.message,
243 detail: self.detail,
244 retryable: self.retryable,
245 }
246 }
247}
248
249pub type Result<T> = std::result::Result<T, DataflowError>;
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
259#[non_exhaustive]
260pub struct ErrorInfo {
261 pub code: String,
263
264 pub message: String,
266
267 pub path: Option<String>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub workflow_id: Option<String>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub task_id: Option<String>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub timestamp: Option<String>,
281
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub retry_attempted: Option<bool>,
285
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub retry_count: Option<u32>,
289
290 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub detail: Option<String>,
298}
299
300pub(crate) const DEFAULT_ERROR_CONTEXT_LIMIT: usize = 32;
305
306#[derive(Debug, Clone)]
313pub(crate) struct ErrorContextConfig {
314 pub(crate) path: String,
316 pub(crate) path_parts: Arc<[Arc<str>]>,
318 pub(crate) limit: usize,
320}
321
322impl ErrorContextConfig {
323 pub(crate) fn new(path: String, limit: usize) -> Result<Self> {
332 let mut segments = path.split('.');
333 let first = segments.next().unwrap_or_default();
334 let root = strip_hash_prefix(first);
335 if !matches!(root, "data" | "metadata" | "temp_data") {
336 return Err(DataflowError::Workflow(format!(
337 "error context path must start with `data`, `metadata` or `temp_data`, got {path:?}"
338 )));
339 }
340 let rest: Vec<&str> = segments.collect();
341 if rest.is_empty() || rest.iter().any(|s| s.is_empty()) {
342 return Err(DataflowError::Workflow(format!(
343 "error context path must name a slot inside `{root}`, got {path:?}"
344 )));
345 }
346 if root == "metadata" && rest == ["progress"] {
347 return Err(DataflowError::Workflow(
348 "error context path may not be `metadata.progress` — the engine owns \
349 that slot for cross-workflow chaining"
350 .to_string(),
351 ));
352 }
353 if limit == 0 {
354 return Err(DataflowError::Workflow(
355 "error context limit must be at least 1".to_string(),
356 ));
357 }
358 let path_parts = compute_path_parts(first, &rest.join("."));
359 Ok(Self {
360 path,
361 path_parts,
362 limit,
363 })
364 }
365}
366
367fn variant_code(error: &DataflowError) -> String {
374 match error {
375 DataflowError::Validation(_) => "VALIDATION_ERROR".to_string(),
376 DataflowError::Workflow(_) => "WORKFLOW_ERROR".to_string(),
377 DataflowError::Task(_) => "TASK_ERROR".to_string(),
378 DataflowError::FunctionNotFound(_) => "FUNCTION_NOT_FOUND".to_string(),
379 DataflowError::FunctionExecution { .. } => "FUNCTION_ERROR".to_string(),
380 DataflowError::LogicEvaluation(_) => "LOGIC_ERROR".to_string(),
381 DataflowError::Http { .. } => "HTTP_ERROR".to_string(),
382 DataflowError::Timeout(_) => "TIMEOUT_ERROR".to_string(),
383 DataflowError::Io(_) => "IO_ERROR".to_string(),
384 DataflowError::Deserialization(_) => "DESERIALIZATION_ERROR".to_string(),
385 DataflowError::Unknown(_) => "UNKNOWN_ERROR".to_string(),
386 DataflowError::Service { .. } => "TASK_ERROR".to_string(),
387 }
388}
389
390pub(crate) fn service_error_code(error: &DataflowError) -> String {
405 match error.kind() {
406 Some(kind) if !kind.is_empty() => kind.to_string(),
407 _ => variant_code(error),
408 }
409}
410
411impl ErrorInfo {
412 pub fn new(workflow_id: Option<String>, task_id: Option<String>, error: DataflowError) -> Self {
414 Self {
415 code: service_error_code(&error),
418 detail: error.detail().map(str::to_string),
419 message: error.to_string(),
420 path: None,
421 workflow_id,
422 task_id,
423 timestamp: Some(Utc::now().to_rfc3339()),
424 retry_attempted: Some(false),
425 retry_count: Some(0),
426 }
427 }
428
429 pub fn simple(code: String, message: String, path: Option<String>) -> Self {
431 Self {
432 code,
433 message,
434 path,
435 workflow_id: None,
436 task_id: None,
437 timestamp: Some(Utc::now().to_rfc3339()),
438 retry_attempted: None,
439 retry_count: None,
440 detail: None,
441 }
442 }
443
444 pub fn simple_ref(code: &str, message: &str, path: Option<&str>) -> Self {
446 Self {
447 code: code.to_string(),
448 message: message.to_string(),
449 path: path.map(|s| s.to_string()),
450 workflow_id: None,
451 task_id: None,
452 timestamp: Some(Utc::now().to_rfc3339()),
453 retry_attempted: None,
454 retry_count: None,
455 detail: None,
456 }
457 }
458
459 pub fn with_retry(mut self) -> Self {
461 self.retry_attempted = Some(true);
462 self.retry_count = Some(self.retry_count.unwrap_or(0) + 1);
463 self
464 }
465
466 pub fn builder(code: impl Into<String>, message: impl Into<String>) -> ErrorInfoBuilder {
468 ErrorInfoBuilder::new(code, message)
469 }
470}
471
472#[must_use = "ErrorInfoBuilder must be `.build()` to produce an ErrorInfo"]
474pub struct ErrorInfoBuilder {
475 code: String,
476 message: String,
477 path: Option<String>,
478 workflow_id: Option<String>,
479 task_id: Option<String>,
480 timestamp: Option<String>,
481 retry_attempted: Option<bool>,
482 retry_count: Option<u32>,
483 detail: Option<String>,
484}
485
486impl ErrorInfoBuilder {
487 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
489 Self {
490 code: code.into(),
491 message: message.into(),
492 path: None,
493 workflow_id: None,
494 task_id: None,
495 timestamp: Some(Utc::now().to_rfc3339()),
496 retry_attempted: None,
497 retry_count: None,
498 detail: None,
499 }
500 }
501
502 pub fn path(mut self, path: impl Into<String>) -> Self {
504 self.path = Some(path.into());
505 self
506 }
507
508 pub fn workflow_id(mut self, id: impl Into<String>) -> Self {
510 self.workflow_id = Some(id.into());
511 self
512 }
513
514 pub fn task_id(mut self, id: impl Into<String>) -> Self {
516 self.task_id = Some(id.into());
517 self
518 }
519
520 pub fn timestamp(mut self, timestamp: impl Into<String>) -> Self {
522 self.timestamp = Some(timestamp.into());
523 self
524 }
525
526 pub fn retry_attempted(mut self, attempted: bool) -> Self {
528 self.retry_attempted = Some(attempted);
529 self
530 }
531
532 pub fn retry_count(mut self, count: u32) -> Self {
534 self.retry_count = Some(count);
535 self
536 }
537
538 pub fn detail(mut self, detail: impl Into<String>) -> Self {
541 self.detail = Some(detail.into());
542 self
543 }
544
545 pub fn build(self) -> ErrorInfo {
547 ErrorInfo {
548 code: self.code,
549 message: self.message,
550 path: self.path,
551 workflow_id: self.workflow_id,
552 task_id: self.task_id,
553 timestamp: self.timestamp,
554 retry_attempted: self.retry_attempted,
555 retry_count: self.retry_count,
556 detail: self.detail,
557 }
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn test_retryable_errors() {
567 assert!(
569 DataflowError::Http {
570 status: 500,
571 message: "Internal Server Error".to_string()
572 }
573 .retryable()
574 );
575 assert!(
576 DataflowError::Http {
577 status: 502,
578 message: "Bad Gateway".to_string()
579 }
580 .retryable()
581 );
582 assert!(
583 DataflowError::Http {
584 status: 503,
585 message: "Service Unavailable".to_string()
586 }
587 .retryable()
588 );
589 assert!(
590 DataflowError::Http {
591 status: 429,
592 message: "Too Many Requests".to_string()
593 }
594 .retryable()
595 );
596 assert!(
597 DataflowError::Http {
598 status: 408,
599 message: "Request Timeout".to_string()
600 }
601 .retryable()
602 );
603 assert!(
604 DataflowError::Http {
605 status: 0,
606 message: "Connection Error".to_string()
607 }
608 .retryable()
609 );
610 assert!(DataflowError::Timeout("Connection timeout".to_string()).retryable());
611 assert!(DataflowError::Io("Network error".to_string()).retryable());
612 }
613
614 #[test]
615 fn test_non_retryable_errors() {
616 assert!(
618 !DataflowError::Http {
619 status: 400,
620 message: "Bad Request".to_string()
621 }
622 .retryable()
623 );
624 assert!(
625 !DataflowError::Http {
626 status: 401,
627 message: "Unauthorized".to_string()
628 }
629 .retryable()
630 );
631 assert!(
632 !DataflowError::Http {
633 status: 403,
634 message: "Forbidden".to_string()
635 }
636 .retryable()
637 );
638 assert!(
639 !DataflowError::Http {
640 status: 404,
641 message: "Not Found".to_string()
642 }
643 .retryable()
644 );
645 assert!(!DataflowError::Validation("Invalid input".to_string()).retryable());
646 assert!(!DataflowError::LogicEvaluation("Invalid logic".to_string()).retryable());
647 assert!(!DataflowError::Deserialization("Invalid JSON".to_string()).retryable());
648 assert!(!DataflowError::Workflow("Invalid workflow".to_string()).retryable());
649 assert!(!DataflowError::Unknown("Unknown error".to_string()).retryable());
650 }
651
652 #[test]
653 fn test_function_execution_error_retryability() {
654 let retryable_source = DataflowError::Http {
656 status: 500,
657 message: "Server Error".to_string(),
658 };
659 let non_retryable_source = DataflowError::Validation("Invalid data".to_string());
660
661 let retryable_func_error =
662 DataflowError::function_execution("HTTP call failed", Some(retryable_source));
663 let non_retryable_func_error =
664 DataflowError::function_execution("Validation failed", Some(non_retryable_source));
665 let no_source_func_error = DataflowError::function_execution("Unknown failure", None);
666
667 assert!(retryable_func_error.retryable());
668 assert!(!non_retryable_func_error.retryable());
669 assert!(!no_source_func_error.retryable());
670 }
671
672 #[test]
673 fn test_error_info_builder() {
674 let error = ErrorInfo::builder("TEST_ERROR", "Test message").build();
676 assert_eq!(error.code, "TEST_ERROR");
677 assert_eq!(error.message, "Test message");
678 assert!(error.timestamp.is_some());
679 assert!(error.path.is_none());
680
681 let error = ErrorInfo::builder("VALIDATION_ERROR", "Field validation failed")
683 .path("data.email")
684 .workflow_id("workflow_1")
685 .task_id("validate_email")
686 .retry_attempted(true)
687 .retry_count(2)
688 .build();
689
690 assert_eq!(error.code, "VALIDATION_ERROR");
691 assert_eq!(error.message, "Field validation failed");
692 assert_eq!(error.path, Some("data.email".to_string()));
693 assert_eq!(error.workflow_id, Some("workflow_1".to_string()));
694 assert_eq!(error.task_id, Some("validate_email".to_string()));
695 assert_eq!(error.retry_attempted, Some(true));
696 assert_eq!(error.retry_count, Some(2));
697 }
698
699 #[test]
700 fn test_error_info_new_from_dataflow_error() {
701 let test_cases = vec![
703 (
704 DataflowError::Validation("test".to_string()),
705 "VALIDATION_ERROR",
706 ),
707 (
708 DataflowError::Workflow("test".to_string()),
709 "WORKFLOW_ERROR",
710 ),
711 (DataflowError::Task("test".to_string()), "TASK_ERROR"),
712 (
713 DataflowError::FunctionNotFound("test".to_string()),
714 "FUNCTION_NOT_FOUND",
715 ),
716 (
717 DataflowError::function_execution("test", None),
718 "FUNCTION_ERROR",
719 ),
720 (
721 DataflowError::LogicEvaluation("test".to_string()),
722 "LOGIC_ERROR",
723 ),
724 (DataflowError::http(404, "Not Found"), "HTTP_ERROR"),
725 (DataflowError::Timeout("test".to_string()), "TIMEOUT_ERROR"),
726 (DataflowError::Io("test".to_string()), "IO_ERROR"),
727 (
728 DataflowError::Deserialization("test".to_string()),
729 "DESERIALIZATION_ERROR",
730 ),
731 (DataflowError::Unknown("test".to_string()), "UNKNOWN_ERROR"),
732 ];
733
734 for (error, expected_code) in test_cases {
735 let info = ErrorInfo::new(
736 Some("workflow_1".to_string()),
737 Some("task_1".to_string()),
738 error,
739 );
740 assert_eq!(info.code, expected_code);
741 assert_eq!(info.workflow_id, Some("workflow_1".to_string()));
742 assert_eq!(info.task_id, Some("task_1".to_string()));
743 assert!(info.timestamp.is_some());
744 assert_eq!(info.retry_attempted, Some(false));
745 assert_eq!(info.retry_count, Some(0));
746 }
747 }
748
749 #[test]
750 fn test_error_info_simple_constructors() {
751 let error = ErrorInfo::simple(
753 "CUSTOM_ERROR".to_string(),
754 "Custom message".to_string(),
755 Some("data.field".to_string()),
756 );
757 assert_eq!(error.code, "CUSTOM_ERROR");
758 assert_eq!(error.message, "Custom message");
759 assert_eq!(error.path, Some("data.field".to_string()));
760 assert!(error.workflow_id.is_none());
761 assert!(error.task_id.is_none());
762 assert!(error.timestamp.is_some());
763
764 let error = ErrorInfo::simple_ref("REF_ERROR", "Ref message", Some("data.path"));
766 assert_eq!(error.code, "REF_ERROR");
767 assert_eq!(error.message, "Ref message");
768 assert_eq!(error.path, Some("data.path".to_string()));
769
770 let error = ErrorInfo::simple_ref("NO_PATH", "No path message", None);
772 assert!(error.path.is_none());
773 }
774
775 #[test]
776 fn test_error_info_with_retry() {
777 let error = ErrorInfo::simple_ref("TEST", "Test", None);
778 assert!(error.retry_attempted.is_none());
779 assert!(error.retry_count.is_none());
780
781 let error = error.with_retry();
782 assert_eq!(error.retry_attempted, Some(true));
783 assert_eq!(error.retry_count, Some(1));
784
785 let error = error.with_retry();
786 assert_eq!(error.retry_attempted, Some(true));
787 assert_eq!(error.retry_count, Some(2));
788 }
789
790 #[test]
791 fn test_error_display_messages() {
792 assert_eq!(
794 DataflowError::Validation("test".to_string()).to_string(),
795 "Validation error: test"
796 );
797 assert_eq!(
798 DataflowError::Workflow("test".to_string()).to_string(),
799 "Workflow error: test"
800 );
801 assert_eq!(
802 DataflowError::Task("test".to_string()).to_string(),
803 "Task error: test"
804 );
805 assert_eq!(
806 DataflowError::FunctionNotFound("test".to_string()).to_string(),
807 "Function not found: test"
808 );
809 assert_eq!(
810 DataflowError::http(404, "Not Found").to_string(),
811 "HTTP error: 404 - Not Found"
812 );
813 assert_eq!(
814 DataflowError::Timeout("test".to_string()).to_string(),
815 "Timeout error: test"
816 );
817 }
818
819 #[test]
820 fn test_error_conversions() {
821 let json_str = "invalid json";
823 let serde_result: std::result::Result<serde_json::Value, _> =
824 serde_json::from_str(json_str);
825 if let Err(e) = serde_result {
826 let dataflow_err = DataflowError::from_serde(e);
827 assert!(matches!(dataflow_err, DataflowError::Deserialization(_)));
828 }
829 }
830
831 #[test]
832 fn service_error_carries_kind_detail_and_declared_retryability() {
833 let e = DataflowError::service("circuit_open", "upstream unavailable")
834 .detail("connector 'billing' breaker open")
835 .retryable(true)
836 .build();
837
838 assert_eq!(e.kind(), Some("circuit_open"));
839 assert_eq!(e.detail(), Some("connector 'billing' breaker open"));
840 assert!(e.retryable());
841 }
842
843 #[test]
844 fn function_execution_inherits_kind_detail_and_retryable_from_a_wrapped_service_source() {
845 let inner = DataflowError::service("circuit_open", "upstream unavailable")
851 .detail("connector 'billing' breaker open since 12:04")
852 .retryable(true)
853 .build();
854 let wrapped = DataflowError::function_execution("calling billing connector", Some(inner));
855
856 assert_eq!(wrapped.kind(), Some("circuit_open"));
857 assert_eq!(
858 wrapped.detail(),
859 Some("connector 'billing' breaker open since 12:04")
860 );
861 assert!(wrapped.retryable());
862
863 assert_eq!(service_error_code(&wrapped), "circuit_open");
866 }
867
868 #[test]
869 fn service_display_hides_the_detail_but_debug_shows_it() {
870 let e = DataflowError::service("circuit_open", "upstream unavailable")
871 .detail("SECRET-TOPOLOGY")
872 .build();
873
874 assert_eq!(e.to_string(), "upstream unavailable");
876 assert!(!e.to_string().contains("SECRET-TOPOLOGY"));
877 assert!(format!("{e:?}").contains("SECRET-TOPOLOGY"));
879 }
880
881 #[test]
882 fn service_retryability_is_independent_of_every_other_field() {
883 let yes = DataflowError::service("k", "m").retryable(true).build();
884 let no = DataflowError::service("k", "m").retryable(false).build();
885 assert!(yes.retryable());
886 assert!(!no.retryable());
887 assert!(!DataflowError::service("k", "m").build().retryable());
889 }
890
891 #[test]
892 fn kind_and_detail_are_none_for_every_engine_owned_variant() {
893 let variants = [
894 DataflowError::Validation("v".into()),
895 DataflowError::FunctionExecution {
896 context: "c".into(),
897 source: None,
898 },
899 DataflowError::LogicEvaluation("l".into()),
900 DataflowError::Deserialization("d".into()),
901 DataflowError::Workflow("w".into()),
902 DataflowError::Task("t".into()),
903 DataflowError::FunctionNotFound("f".into()),
904 DataflowError::Http {
905 status: 500,
906 message: "h".into(),
907 },
908 DataflowError::Timeout("to".into()),
909 DataflowError::Io("io".into()),
910 DataflowError::Unknown("u".into()),
911 ];
912 assert_eq!(variants.len(), 11, "one assertion per engine-owned variant");
913 for v in &variants {
914 assert_eq!(v.kind(), None, "kind() for {v:?}");
915 assert_eq!(v.detail(), None, "detail() for {v:?}");
916 }
917 }
918
919 #[test]
920 fn service_error_code_passes_kind_through_verbatim() {
921 let e = DataflowError::service("circuit_open", "m").build();
924 let info = ErrorInfo::new(None, None, e);
925 assert_eq!(info.code, "circuit_open");
926 }
927
928 #[test]
929 fn an_empty_kind_falls_back_rather_than_recording_an_empty_code() {
930 let e = DataflowError::service("", "m").build();
931 let info = ErrorInfo::new(None, None, e);
932 assert_eq!(info.code, "TASK_ERROR");
933 assert!(!info.code.is_empty());
934 }
935
936 #[test]
937 fn a_non_ascii_kind_is_neither_panicked_on_nor_mangled() {
938 let e = DataflowError::service("limite_dépassé", "m").build();
939 let info = ErrorInfo::new(None, None, e);
940 assert_eq!(info.code, "limite_dépassé");
942 }
943
944 #[test]
945 fn error_info_lifts_the_detail_and_omits_it_when_absent() {
946 let with = ErrorInfo::new(
947 None,
948 None,
949 DataflowError::service("k", "m").detail("op only").build(),
950 );
951 assert_eq!(with.detail.as_deref(), Some("op only"));
952 assert!(serde_json::to_string(&with).unwrap().contains("detail"));
953
954 let without = ErrorInfo::new(None, None, DataflowError::service("k", "m").build());
956 assert_eq!(without.detail, None);
957 assert!(!serde_json::to_string(&without).unwrap().contains("detail"));
958
959 let engine_owned = ErrorInfo::new(None, None, DataflowError::Task("t".into()));
960 assert_eq!(engine_owned.detail, None);
961 assert!(
962 !serde_json::to_string(&engine_owned)
963 .unwrap()
964 .contains("detail"),
965 "the JSON shape is unchanged for every pre-existing error"
966 );
967 }
968
969 #[test]
970 fn a_service_error_round_trips_through_serde_with_and_without_detail() {
971 for e in [
972 DataflowError::service("k", "m")
973 .detail("d")
974 .retryable(true)
975 .build(),
976 DataflowError::service("k", "m").build(),
977 ] {
978 let json = serde_json::to_string(&e).unwrap();
979 let back: DataflowError = serde_json::from_str(&json).unwrap();
980 assert_eq!(back.kind(), e.kind());
981 assert_eq!(back.detail(), e.detail());
982 assert_eq!(back.retryable(), e.retryable());
983 }
984
985 let bare = DataflowError::service("k", "m").build();
987 assert!(!serde_json::to_string(&bare).unwrap().contains("detail"));
988 }
989}