Skip to main content

dataflow_rs/engine/
error.rs

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/// Main error type for the dataflow engine
8///
9/// `#[non_exhaustive]`: a downstream `match` needs a wildcard arm. Adding it here
10/// makes every *future* variant additive rather than a breaking change, which
11/// matters because this enum is the crate's error channel and will keep growing.
12#[derive(Debug, Error, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum DataflowError {
15    /// Validation errors occurring during rule evaluation
16    #[error("Validation error: {0}")]
17    Validation(String),
18
19    /// Errors during function execution
20    #[error("Function execution error: {context}")]
21    FunctionExecution {
22        context: String,
23        #[source]
24        #[serde(skip)]
25        source: Option<Box<Self>>,
26    },
27
28    /// Workflow-related errors
29    #[error("Workflow error: {0}")]
30    Workflow(String),
31
32    /// Task-related errors
33    #[error("Task error: {0}")]
34    Task(String),
35
36    /// Function not found errors
37    #[error("Function not found: {0}")]
38    FunctionNotFound(String),
39
40    /// JSON serialization/deserialization errors
41    #[error("Deserialization error: {0}")]
42    Deserialization(String),
43
44    /// I/O errors (file reading, etc.)
45    #[error("IO error: {0}")]
46    Io(String),
47
48    /// JSONLogic/DataLogic evaluation errors
49    #[error("Logic evaluation error: {0}")]
50    LogicEvaluation(String),
51
52    /// HTTP request errors
53    #[error("HTTP error: {status} - {message}")]
54    Http { status: u16, message: String },
55
56    /// Timeout errors
57    #[error("Timeout error: {0}")]
58    Timeout(String),
59
60    /// Any other errors
61    #[error("Unknown error: {0}")]
62    Unknown(String),
63
64    /// A failure the service layer classifies itself.
65    ///
66    /// The engine never interprets `kind`: it carries it to [`ErrorInfo::code`]
67    /// and otherwise treats this exactly like any other error —
68    /// `continue_on_error`, the audit-trail entry and the `Result::Err`
69    /// short-circuit are unchanged. No built-in returns this variant.
70    ///
71    /// `Display` renders `message` alone, so `to_string()` is always safe to hand
72    /// to an untrusted caller. `detail` is reachable only through `Debug`,
73    /// [`DataflowError::detail`] and [`ErrorInfo::detail`].
74    ///
75    /// Build it with [`DataflowError::service`].
76    #[error("{message}")]
77    Service {
78        /// Stable, service-owned classification, e.g. `"circuit_open"`.
79        kind: String,
80        /// Caller-safe text.
81        message: String,
82        /// Operator-only text: logged and kept on the trace, not intended for an
83        /// untrusted caller.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        detail: Option<String>,
86        /// Retryability, declared by the service rather than inferred from the
87        /// variant.
88        retryable: bool,
89    },
90}
91
92impl DataflowError {
93    /// Creates a new function execution error with context
94    pub fn function_execution<S: Into<String>>(context: S, source: Option<Self>) -> Self {
95        Self::FunctionExecution {
96            context: context.into(),
97            source: source.map(Box::new),
98        }
99    }
100
101    /// Creates a new HTTP error
102    pub fn http<S: Into<String>>(status: u16, message: S) -> Self {
103        Self::Http {
104            status,
105            message: message.into(),
106        }
107    }
108
109    /// Convert from std::io::Error
110    pub fn from_io(err: std::io::Error) -> Self {
111        Self::Io(err.to_string())
112    }
113
114    /// Convert from serde_json::Error
115    pub fn from_serde(err: serde_json::Error) -> Self {
116        Self::Deserialization(err.to_string())
117    }
118
119    /// Determines if this error is retryable (worth retrying)
120    ///
121    /// Retryable errors are typically transient infrastructure failures that might succeed on retry.
122    /// Non-retryable errors are typically data validation, logic, or configuration errors that
123    /// will consistently fail on retry.
124    pub fn retryable(&self) -> bool {
125        match self {
126            // Retryable errors - infrastructure/transient failures
127            Self::Http { status, .. } => {
128                // Retry on server errors (5xx) and specific client errors that might be transient
129                *status >= 500 || *status == 429 || *status == 408 || *status == 0
130                // 0 means connection error
131            }
132            Self::Timeout(_) => true,
133            Self::Io(_) => true,
134            Self::FunctionExecution { source, .. } => {
135                // Inherit retryability from the source error if present
136                source.as_ref().map(|e| e.retryable()).unwrap_or(false)
137            }
138
139            // Non-retryable errors - data/logic/configuration issues
140            Self::Validation(_) => false,
141            Self::LogicEvaluation(_) => false,
142            Self::Deserialization(_) => false,
143            Self::Workflow(_) => false,
144            Self::Task(_) => false,
145            Self::FunctionNotFound(_) => false,
146            Self::Unknown(_) => false,
147
148            // Declared by the service rather than inferred from the variant.
149            Self::Service { retryable, .. } => *retryable,
150        }
151    }
152
153    /// The service-owned classification, or `None` for every engine-owned
154    /// variant.
155    ///
156    /// This is the whole point of [`DataflowError::Service`]: a handler can
157    /// classify its own failures with a stable code the engine never interprets.
158    pub fn kind(&self) -> Option<&str> {
159        match self {
160            Self::Service { kind, .. } => Some(kind),
161            // Same inheritance as `retryable()`: a `Service` error wrapped for
162            // context via `FunctionExecution` must not lose its classification.
163            Self::FunctionExecution { source, .. } => source.as_deref().and_then(Self::kind),
164            _ => None,
165        }
166    }
167
168    /// Operator-only detail, if this is a [`DataflowError::Service`] carrying one.
169    ///
170    /// Never included in `Display`, so `to_string()` stays safe to hand to an
171    /// untrusted caller. Reachable through `Debug`, this method, and
172    /// [`ErrorInfo::detail`].
173    pub fn detail(&self) -> Option<&str> {
174        match self {
175            Self::Service { detail, .. } => detail.as_deref(),
176            Self::FunctionExecution { source, .. } => source.as_deref().and_then(Self::detail),
177            _ => None,
178        }
179    }
180
181    /// Start building a service-classified error.
182    ///
183    /// `kind` is the stable code the service will switch on; `message` is the
184    /// caller-safe text. Defaults: no detail, `retryable: false`.
185    ///
186    /// ```
187    /// use dataflow_rs::DataflowError;
188    ///
189    /// let e = DataflowError::service("circuit_open", "upstream unavailable")
190    ///     .detail("connector 'billing' breaker open")
191    ///     .retryable(true)
192    ///     .build();
193    ///
194    /// assert_eq!(e.kind(), Some("circuit_open"));
195    /// assert_eq!(e.detail(), Some("connector 'billing' breaker open"));
196    /// assert!(e.retryable());
197    /// // Display carries only the caller-safe text.
198    /// assert_eq!(e.to_string(), "upstream unavailable");
199    /// ```
200    pub fn service(kind: impl Into<String>, message: impl Into<String>) -> ServiceErrorBuilder {
201        ServiceErrorBuilder {
202            kind: kind.into(),
203            message: message.into(),
204            detail: None,
205            retryable: false,
206        }
207    }
208}
209
210/// Builder for [`DataflowError::Service`]. Mirrors [`ErrorInfoBuilder`].
211#[must_use = "ServiceErrorBuilder must be `.build()` to produce a DataflowError"]
212pub struct ServiceErrorBuilder {
213    kind: String,
214    message: String,
215    detail: Option<String>,
216    retryable: bool,
217}
218
219impl ServiceErrorBuilder {
220    /// Attach operator-only detail — logged and kept on the trace, not intended
221    /// for an untrusted caller.
222    pub fn detail(mut self, detail: impl Into<String>) -> Self {
223        self.detail = Some(detail.into());
224        self
225    }
226
227    /// Declare retryability. Defaults to `false`.
228    ///
229    /// Carriage for consumers: no engine code path acts on `retryable()`.
230    pub fn retryable(mut self, retryable: bool) -> Self {
231        self.retryable = retryable;
232        self
233    }
234
235    pub fn build(self) -> DataflowError {
236        DataflowError::Service {
237            kind: self.kind,
238            message: self.message,
239            detail: self.detail,
240            retryable: self.retryable,
241        }
242    }
243}
244
245/// Type alias for Result with DataflowError
246pub type Result<T> = std::result::Result<T, DataflowError>;
247
248/// Structured error information for error tracking in messages
249///
250/// `#[non_exhaustive]`: construct through [`ErrorInfo::builder`],
251/// [`ErrorInfo::new`], [`ErrorInfo::simple`] or [`ErrorInfo::simple_ref`], which
252/// are the documented paths. Field reads and `..` patterns are unaffected, and
253/// future field additions stay non-breaking.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[non_exhaustive]
256pub struct ErrorInfo {
257    /// Error code (e.g., "WORKFLOW_ERROR", "TASK_ERROR", "VALIDATION_ERROR")
258    pub code: String,
259
260    /// Human-readable error message
261    pub message: String,
262
263    /// Optional path to the error location (e.g., "workflow.id", "task.id", "data.field")
264    pub path: Option<String>,
265
266    /// ID of the workflow where the error occurred (if available)
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub workflow_id: Option<String>,
269
270    /// ID of the task where the error occurred (if available)
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub task_id: Option<String>,
273
274    /// Timestamp when the error occurred
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub timestamp: Option<String>,
277
278    /// Whether a retry was attempted
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub retry_attempted: Option<bool>,
281
282    /// Number of retries attempted
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub retry_count: Option<u32>,
285
286    /// Operator-only detail lifted from [`DataflowError::Service`].
287    ///
288    /// Present on the persisted trace; a service must **not** surface it to an
289    /// untrusted caller. `None` for every engine-owned error, and omitted from
290    /// the serialized form when absent, so the JSON shape is unchanged unless a
291    /// `Service` error carried one.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub detail: Option<String>,
294}
295
296/// Default cap on the number of records held at the error-context path.
297///
298/// Bounded so the option's memory cost does not scale with a looping workflow's
299/// iteration count — `Message.context` is deep-cloned into every trace snapshot.
300pub(crate) const DEFAULT_ERROR_CONTEXT_LIMIT: usize = 32;
301
302/// Where the engine mirrors per-task failure codes into the message context, so
303/// a downstream `condition` or `map` can branch on *why* a task failed.
304///
305/// Built and validated by `EngineBuilder::build`; never constructed on the hot
306/// path. Holds the dotted form (for `refresh_for_path`) and the pre-split walk
307/// order (for the write) so neither is recomputed per failure.
308#[derive(Debug, Clone)]
309pub(crate) struct ErrorContextConfig {
310    /// Dotted path, e.g. `"metadata.errors"`.
311    pub(crate) path: String,
312    /// `path` pre-split into the walk order used by the `*_parts` accessors.
313    pub(crate) path_parts: Arc<[Arc<str>]>,
314    /// Most-recent-N records retained; older ones are dropped from the front.
315    pub(crate) limit: usize,
316}
317
318impl ErrorContextConfig {
319    /// Validate `path` and pre-split it.
320    ///
321    /// The first segment must name one of the three context slots. Anything else
322    /// — `payload.errors` above all — would write somewhere the JSONLogic
323    /// evaluation context cannot see, and the workflow author would get a
324    /// silently-never-true condition rather than an error. `metadata.progress` is
325    /// rejected outright: the workflow executor owns that slot and cross-workflow
326    /// chaining depends on its shape.
327    pub(crate) fn new(path: String, limit: usize) -> Result<Self> {
328        let mut segments = path.split('.');
329        let first = segments.next().unwrap_or_default();
330        let root = strip_hash_prefix(first);
331        if !matches!(root, "data" | "metadata" | "temp_data") {
332            return Err(DataflowError::Workflow(format!(
333                "error context path must start with `data`, `metadata` or `temp_data`, got {path:?}"
334            )));
335        }
336        let rest: Vec<&str> = segments.collect();
337        if rest.is_empty() || rest.iter().any(|s| s.is_empty()) {
338            return Err(DataflowError::Workflow(format!(
339                "error context path must name a slot inside `{root}`, got {path:?}"
340            )));
341        }
342        if root == "metadata" && rest == ["progress"] {
343            return Err(DataflowError::Workflow(
344                "error context path may not be `metadata.progress` — the engine owns \
345                 that slot for cross-workflow chaining"
346                    .to_string(),
347            ));
348        }
349        if limit == 0 {
350            return Err(DataflowError::Workflow(
351                "error context limit must be at least 1".to_string(),
352            ));
353        }
354        let path_parts = compute_path_parts(first, &rest.join("."));
355        Ok(Self {
356            path,
357            path_parts,
358            limit,
359        })
360    }
361}
362
363/// The engine-owned code for a `DataflowError` variant.
364///
365/// The closed vocabulary every consumer switches on. `Service` is included for
366/// totality but is normally reached through [`service_error_code`], which
367/// prefers the service's own `kind`; an empty `kind` lands here and falls back
368/// to `TASK_ERROR` so no `ErrorInfo` ever carries an empty code.
369fn variant_code(error: &DataflowError) -> &'static str {
370    match error {
371        DataflowError::Validation(_) => "VALIDATION_ERROR",
372        DataflowError::Workflow(_) => "WORKFLOW_ERROR",
373        DataflowError::Task(_) => "TASK_ERROR",
374        DataflowError::FunctionNotFound(_) => "FUNCTION_NOT_FOUND",
375        DataflowError::FunctionExecution { .. } => "FUNCTION_ERROR",
376        DataflowError::LogicEvaluation(_) => "LOGIC_ERROR",
377        DataflowError::Http { .. } => "HTTP_ERROR",
378        DataflowError::Timeout(_) => "TIMEOUT_ERROR",
379        DataflowError::Io(_) => "IO_ERROR",
380        DataflowError::Deserialization(_) => "DESERIALIZATION_ERROR",
381        DataflowError::Unknown(_) => "UNKNOWN_ERROR",
382        DataflowError::Service { .. } => "TASK_ERROR",
383    }
384}
385
386/// The `ErrorInfo.code` recorded for `error` — the single classifier, used by
387/// both [`ErrorInfo::new`] and `workflow_executor`'s per-task error recording,
388/// so the two can never drift.
389///
390/// A [`DataflowError::Service`] contributes its own `kind` **verbatim** rather
391/// than upper-cased: the service owns it and will switch on the recorded `code`,
392/// so making the two different strings would force every consumer to know the
393/// transform. `kind()` recurses through `FunctionExecution.source`, so wrapping a
394/// service error for context keeps its classification.
395///
396/// Everything else takes its variant's code from [`variant_code`]. Note this is
397/// **not** a flat `TASK_ERROR` fallback: before 3.5.0 every engine-owned variant
398/// collapsed to `TASK_ERROR` on the live path, which made a timeout, a dropped
399/// connection and a rejected request indistinguishable to a workflow author.
400pub(crate) fn service_error_code(error: &DataflowError) -> String {
401    match error.kind() {
402        Some(kind) if !kind.is_empty() => kind.to_string(),
403        _ => variant_code(error).to_string(),
404    }
405}
406
407impl ErrorInfo {
408    /// Create a new error info entry with all fields
409    pub fn new(workflow_id: Option<String>, task_id: Option<String>, error: DataflowError) -> Self {
410        Self {
411            // The single classifier, shared with the live per-task recording
412            // path so the two can never drift.
413            code: service_error_code(&error),
414            detail: error.detail().map(str::to_string),
415            message: error.to_string(),
416            path: None,
417            workflow_id,
418            task_id,
419            timestamp: Some(Utc::now().to_rfc3339()),
420            retry_attempted: Some(false),
421            retry_count: Some(0),
422        }
423    }
424
425    /// Create a simple error info with just code, message, and optional path
426    pub fn simple(code: String, message: String, path: Option<String>) -> Self {
427        Self {
428            code,
429            message,
430            path,
431            workflow_id: None,
432            task_id: None,
433            timestamp: Some(Utc::now().to_rfc3339()),
434            retry_attempted: None,
435            retry_count: None,
436            detail: None,
437        }
438    }
439
440    /// Create a simple error info from references (avoids cloning when possible)
441    pub fn simple_ref(code: &str, message: &str, path: Option<&str>) -> Self {
442        Self {
443            code: code.to_string(),
444            message: message.to_string(),
445            path: path.map(|s| s.to_string()),
446            workflow_id: None,
447            task_id: None,
448            timestamp: Some(Utc::now().to_rfc3339()),
449            retry_attempted: None,
450            retry_count: None,
451            detail: None,
452        }
453    }
454
455    /// Mark that a retry was attempted
456    pub fn with_retry(mut self) -> Self {
457        self.retry_attempted = Some(true);
458        self.retry_count = Some(self.retry_count.unwrap_or(0) + 1);
459        self
460    }
461
462    /// Create a builder for ErrorInfo
463    pub fn builder(code: impl Into<String>, message: impl Into<String>) -> ErrorInfoBuilder {
464        ErrorInfoBuilder::new(code, message)
465    }
466}
467
468/// Builder for creating ErrorInfo instances with a fluent API
469#[must_use = "ErrorInfoBuilder must be `.build()` to produce an ErrorInfo"]
470pub struct ErrorInfoBuilder {
471    code: String,
472    message: String,
473    path: Option<String>,
474    workflow_id: Option<String>,
475    task_id: Option<String>,
476    timestamp: Option<String>,
477    retry_attempted: Option<bool>,
478    retry_count: Option<u32>,
479    detail: Option<String>,
480}
481
482impl ErrorInfoBuilder {
483    /// Create a new ErrorInfoBuilder with required fields
484    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
485        Self {
486            code: code.into(),
487            message: message.into(),
488            path: None,
489            workflow_id: None,
490            task_id: None,
491            timestamp: Some(Utc::now().to_rfc3339()),
492            retry_attempted: None,
493            retry_count: None,
494            detail: None,
495        }
496    }
497
498    /// Set the error path
499    pub fn path(mut self, path: impl Into<String>) -> Self {
500        self.path = Some(path.into());
501        self
502    }
503
504    /// Set the workflow ID
505    pub fn workflow_id(mut self, id: impl Into<String>) -> Self {
506        self.workflow_id = Some(id.into());
507        self
508    }
509
510    /// Set the task ID
511    pub fn task_id(mut self, id: impl Into<String>) -> Self {
512        self.task_id = Some(id.into());
513        self
514    }
515
516    /// Set custom timestamp (defaults to now if not set)
517    pub fn timestamp(mut self, timestamp: impl Into<String>) -> Self {
518        self.timestamp = Some(timestamp.into());
519        self
520    }
521
522    /// Mark as retry attempted
523    pub fn retry_attempted(mut self, attempted: bool) -> Self {
524        self.retry_attempted = Some(attempted);
525        self
526    }
527
528    /// Set retry count
529    pub fn retry_count(mut self, count: u32) -> Self {
530        self.retry_count = Some(count);
531        self
532    }
533
534    /// Attach operator-only detail. Not surfaced by `Display` anywhere; a service
535    /// must not pass it to an untrusted caller.
536    pub fn detail(mut self, detail: impl Into<String>) -> Self {
537        self.detail = Some(detail.into());
538        self
539    }
540
541    /// Build the ErrorInfo instance
542    pub fn build(self) -> ErrorInfo {
543        ErrorInfo {
544            code: self.code,
545            message: self.message,
546            path: self.path,
547            workflow_id: self.workflow_id,
548            task_id: self.task_id,
549            timestamp: self.timestamp,
550            retry_attempted: self.retry_attempted,
551            retry_count: self.retry_count,
552            detail: self.detail,
553        }
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_retryable_errors() {
563        // Test retryable errors
564        assert!(
565            DataflowError::Http {
566                status: 500,
567                message: "Internal Server Error".to_string()
568            }
569            .retryable()
570        );
571        assert!(
572            DataflowError::Http {
573                status: 502,
574                message: "Bad Gateway".to_string()
575            }
576            .retryable()
577        );
578        assert!(
579            DataflowError::Http {
580                status: 503,
581                message: "Service Unavailable".to_string()
582            }
583            .retryable()
584        );
585        assert!(
586            DataflowError::Http {
587                status: 429,
588                message: "Too Many Requests".to_string()
589            }
590            .retryable()
591        );
592        assert!(
593            DataflowError::Http {
594                status: 408,
595                message: "Request Timeout".to_string()
596            }
597            .retryable()
598        );
599        assert!(
600            DataflowError::Http {
601                status: 0,
602                message: "Connection Error".to_string()
603            }
604            .retryable()
605        );
606        assert!(DataflowError::Timeout("Connection timeout".to_string()).retryable());
607        assert!(DataflowError::Io("Network error".to_string()).retryable());
608    }
609
610    #[test]
611    fn test_non_retryable_errors() {
612        // Test non-retryable errors
613        assert!(
614            !DataflowError::Http {
615                status: 400,
616                message: "Bad Request".to_string()
617            }
618            .retryable()
619        );
620        assert!(
621            !DataflowError::Http {
622                status: 401,
623                message: "Unauthorized".to_string()
624            }
625            .retryable()
626        );
627        assert!(
628            !DataflowError::Http {
629                status: 403,
630                message: "Forbidden".to_string()
631            }
632            .retryable()
633        );
634        assert!(
635            !DataflowError::Http {
636                status: 404,
637                message: "Not Found".to_string()
638            }
639            .retryable()
640        );
641        assert!(!DataflowError::Validation("Invalid input".to_string()).retryable());
642        assert!(!DataflowError::LogicEvaluation("Invalid logic".to_string()).retryable());
643        assert!(!DataflowError::Deserialization("Invalid JSON".to_string()).retryable());
644        assert!(!DataflowError::Workflow("Invalid workflow".to_string()).retryable());
645        assert!(!DataflowError::Unknown("Unknown error".to_string()).retryable());
646    }
647
648    #[test]
649    fn test_function_execution_error_retryability() {
650        // Test that function execution errors inherit retryability from source
651        let retryable_source = DataflowError::Http {
652            status: 500,
653            message: "Server Error".to_string(),
654        };
655        let non_retryable_source = DataflowError::Validation("Invalid data".to_string());
656
657        let retryable_func_error =
658            DataflowError::function_execution("HTTP call failed", Some(retryable_source));
659        let non_retryable_func_error =
660            DataflowError::function_execution("Validation failed", Some(non_retryable_source));
661        let no_source_func_error = DataflowError::function_execution("Unknown failure", None);
662
663        assert!(retryable_func_error.retryable());
664        assert!(!non_retryable_func_error.retryable());
665        assert!(!no_source_func_error.retryable());
666    }
667
668    #[test]
669    fn test_error_info_builder() {
670        // Test basic builder
671        let error = ErrorInfo::builder("TEST_ERROR", "Test message").build();
672        assert_eq!(error.code, "TEST_ERROR");
673        assert_eq!(error.message, "Test message");
674        assert!(error.timestamp.is_some());
675        assert!(error.path.is_none());
676
677        // Test full builder
678        let error = ErrorInfo::builder("VALIDATION_ERROR", "Field validation failed")
679            .path("data.email")
680            .workflow_id("workflow_1")
681            .task_id("validate_email")
682            .retry_attempted(true)
683            .retry_count(2)
684            .build();
685
686        assert_eq!(error.code, "VALIDATION_ERROR");
687        assert_eq!(error.message, "Field validation failed");
688        assert_eq!(error.path, Some("data.email".to_string()));
689        assert_eq!(error.workflow_id, Some("workflow_1".to_string()));
690        assert_eq!(error.task_id, Some("validate_email".to_string()));
691        assert_eq!(error.retry_attempted, Some(true));
692        assert_eq!(error.retry_count, Some(2));
693    }
694
695    #[test]
696    fn test_error_info_new_from_dataflow_error() {
697        // Test ErrorInfo::new generates correct codes for each error type
698        let test_cases = vec![
699            (
700                DataflowError::Validation("test".to_string()),
701                "VALIDATION_ERROR",
702            ),
703            (
704                DataflowError::Workflow("test".to_string()),
705                "WORKFLOW_ERROR",
706            ),
707            (DataflowError::Task("test".to_string()), "TASK_ERROR"),
708            (
709                DataflowError::FunctionNotFound("test".to_string()),
710                "FUNCTION_NOT_FOUND",
711            ),
712            (
713                DataflowError::function_execution("test", None),
714                "FUNCTION_ERROR",
715            ),
716            (
717                DataflowError::LogicEvaluation("test".to_string()),
718                "LOGIC_ERROR",
719            ),
720            (DataflowError::http(404, "Not Found"), "HTTP_ERROR"),
721            (DataflowError::Timeout("test".to_string()), "TIMEOUT_ERROR"),
722            (DataflowError::Io("test".to_string()), "IO_ERROR"),
723            (
724                DataflowError::Deserialization("test".to_string()),
725                "DESERIALIZATION_ERROR",
726            ),
727            (DataflowError::Unknown("test".to_string()), "UNKNOWN_ERROR"),
728        ];
729
730        for (error, expected_code) in test_cases {
731            let info = ErrorInfo::new(
732                Some("workflow_1".to_string()),
733                Some("task_1".to_string()),
734                error,
735            );
736            assert_eq!(info.code, expected_code);
737            assert_eq!(info.workflow_id, Some("workflow_1".to_string()));
738            assert_eq!(info.task_id, Some("task_1".to_string()));
739            assert!(info.timestamp.is_some());
740            assert_eq!(info.retry_attempted, Some(false));
741            assert_eq!(info.retry_count, Some(0));
742        }
743    }
744
745    #[test]
746    fn test_error_info_simple_constructors() {
747        // Test simple constructor
748        let error = ErrorInfo::simple(
749            "CUSTOM_ERROR".to_string(),
750            "Custom message".to_string(),
751            Some("data.field".to_string()),
752        );
753        assert_eq!(error.code, "CUSTOM_ERROR");
754        assert_eq!(error.message, "Custom message");
755        assert_eq!(error.path, Some("data.field".to_string()));
756        assert!(error.workflow_id.is_none());
757        assert!(error.task_id.is_none());
758        assert!(error.timestamp.is_some());
759
760        // Test simple_ref constructor
761        let error = ErrorInfo::simple_ref("REF_ERROR", "Ref message", Some("data.path"));
762        assert_eq!(error.code, "REF_ERROR");
763        assert_eq!(error.message, "Ref message");
764        assert_eq!(error.path, Some("data.path".to_string()));
765
766        // Test simple_ref with None path
767        let error = ErrorInfo::simple_ref("NO_PATH", "No path message", None);
768        assert!(error.path.is_none());
769    }
770
771    #[test]
772    fn test_error_info_with_retry() {
773        let error = ErrorInfo::simple_ref("TEST", "Test", None);
774        assert!(error.retry_attempted.is_none());
775        assert!(error.retry_count.is_none());
776
777        let error = error.with_retry();
778        assert_eq!(error.retry_attempted, Some(true));
779        assert_eq!(error.retry_count, Some(1));
780
781        let error = error.with_retry();
782        assert_eq!(error.retry_attempted, Some(true));
783        assert_eq!(error.retry_count, Some(2));
784    }
785
786    #[test]
787    fn test_error_display_messages() {
788        // Test that error display messages are correct
789        assert_eq!(
790            DataflowError::Validation("test".to_string()).to_string(),
791            "Validation error: test"
792        );
793        assert_eq!(
794            DataflowError::Workflow("test".to_string()).to_string(),
795            "Workflow error: test"
796        );
797        assert_eq!(
798            DataflowError::Task("test".to_string()).to_string(),
799            "Task error: test"
800        );
801        assert_eq!(
802            DataflowError::FunctionNotFound("test".to_string()).to_string(),
803            "Function not found: test"
804        );
805        assert_eq!(
806            DataflowError::http(404, "Not Found").to_string(),
807            "HTTP error: 404 - Not Found"
808        );
809        assert_eq!(
810            DataflowError::Timeout("test".to_string()).to_string(),
811            "Timeout error: test"
812        );
813    }
814
815    #[test]
816    fn test_error_conversions() {
817        // Test from_serde (we can't easily create a real serde error, but we can test the conversion works)
818        let json_str = "invalid json";
819        let serde_result: std::result::Result<serde_json::Value, _> =
820            serde_json::from_str(json_str);
821        if let Err(e) = serde_result {
822            let dataflow_err = DataflowError::from_serde(e);
823            assert!(matches!(dataflow_err, DataflowError::Deserialization(_)));
824        }
825    }
826
827    #[test]
828    fn service_error_carries_kind_detail_and_declared_retryability() {
829        let e = DataflowError::service("circuit_open", "upstream unavailable")
830            .detail("connector 'billing' breaker open")
831            .retryable(true)
832            .build();
833
834        assert_eq!(e.kind(), Some("circuit_open"));
835        assert_eq!(e.detail(), Some("connector 'billing' breaker open"));
836        assert!(e.retryable());
837    }
838
839    #[test]
840    fn function_execution_inherits_kind_detail_and_retryable_from_a_wrapped_service_source() {
841        // `DataflowError::function_execution` is the documented way to add
842        // context to a downstream error. `retryable()` already recursed into
843        // `source`; `kind()`/`detail()` must do the same, or wrapping a
844        // `Service` error for context silently downgrades it to a generic
845        // `TASK_ERROR` with no detail.
846        let inner = DataflowError::service("circuit_open", "upstream unavailable")
847            .detail("connector 'billing' breaker open since 12:04")
848            .retryable(true)
849            .build();
850        let wrapped = DataflowError::function_execution("calling billing connector", Some(inner));
851
852        assert_eq!(wrapped.kind(), Some("circuit_open"));
853        assert_eq!(
854            wrapped.detail(),
855            Some("connector 'billing' breaker open since 12:04")
856        );
857        assert!(wrapped.retryable());
858
859        // `ErrorInfo::new`'s Service arm goes through the same `kind()` call,
860        // so the wrapped error's code makes it through end to end too.
861        assert_eq!(service_error_code(&wrapped), "circuit_open");
862    }
863
864    #[test]
865    fn service_display_hides_the_detail_but_debug_shows_it() {
866        let e = DataflowError::service("circuit_open", "upstream unavailable")
867            .detail("SECRET-TOPOLOGY")
868            .build();
869
870        // `to_string()` is always safe to hand to an untrusted caller.
871        assert_eq!(e.to_string(), "upstream unavailable");
872        assert!(!e.to_string().contains("SECRET-TOPOLOGY"));
873        // The operator channel is reachable through Debug.
874        assert!(format!("{e:?}").contains("SECRET-TOPOLOGY"));
875    }
876
877    #[test]
878    fn service_retryability_is_independent_of_every_other_field() {
879        let yes = DataflowError::service("k", "m").retryable(true).build();
880        let no = DataflowError::service("k", "m").retryable(false).build();
881        assert!(yes.retryable());
882        assert!(!no.retryable());
883        // Defaults to false.
884        assert!(!DataflowError::service("k", "m").build().retryable());
885    }
886
887    #[test]
888    fn kind_and_detail_are_none_for_every_engine_owned_variant() {
889        let variants = [
890            DataflowError::Validation("v".into()),
891            DataflowError::FunctionExecution {
892                context: "c".into(),
893                source: None,
894            },
895            DataflowError::LogicEvaluation("l".into()),
896            DataflowError::Deserialization("d".into()),
897            DataflowError::Workflow("w".into()),
898            DataflowError::Task("t".into()),
899            DataflowError::FunctionNotFound("f".into()),
900            DataflowError::Http {
901                status: 500,
902                message: "h".into(),
903            },
904            DataflowError::Timeout("to".into()),
905            DataflowError::Io("io".into()),
906            DataflowError::Unknown("u".into()),
907        ];
908        assert_eq!(variants.len(), 11, "one assertion per engine-owned variant");
909        for v in &variants {
910            assert_eq!(v.kind(), None, "kind() for {v:?}");
911            assert_eq!(v.detail(), None, "detail() for {v:?}");
912        }
913    }
914
915    #[test]
916    fn service_error_code_passes_kind_through_verbatim() {
917        // The recorded decision: verbatim, not upper-cased. A service switching
918        // on the recorded `code` should not have to know a transform.
919        let e = DataflowError::service("circuit_open", "m").build();
920        let info = ErrorInfo::new(None, None, e);
921        assert_eq!(info.code, "circuit_open");
922    }
923
924    #[test]
925    fn an_empty_kind_falls_back_rather_than_recording_an_empty_code() {
926        let e = DataflowError::service("", "m").build();
927        let info = ErrorInfo::new(None, None, e);
928        assert_eq!(info.code, "TASK_ERROR");
929        assert!(!info.code.is_empty());
930    }
931
932    #[test]
933    fn a_non_ascii_kind_is_neither_panicked_on_nor_mangled() {
934        let e = DataflowError::service("limite_dépassé", "m").build();
935        let info = ErrorInfo::new(None, None, e);
936        // Verbatim, so the exact string round-trips including the accent.
937        assert_eq!(info.code, "limite_dépassé");
938    }
939
940    #[test]
941    fn error_info_lifts_the_detail_and_omits_it_when_absent() {
942        let with = ErrorInfo::new(
943            None,
944            None,
945            DataflowError::service("k", "m").detail("op only").build(),
946        );
947        assert_eq!(with.detail.as_deref(), Some("op only"));
948        assert!(serde_json::to_string(&with).unwrap().contains("detail"));
949
950        // Absent on a Service without one, and on every engine-owned variant.
951        let without = ErrorInfo::new(None, None, DataflowError::service("k", "m").build());
952        assert_eq!(without.detail, None);
953        assert!(!serde_json::to_string(&without).unwrap().contains("detail"));
954
955        let engine_owned = ErrorInfo::new(None, None, DataflowError::Task("t".into()));
956        assert_eq!(engine_owned.detail, None);
957        assert!(
958            !serde_json::to_string(&engine_owned)
959                .unwrap()
960                .contains("detail"),
961            "the JSON shape is unchanged for every pre-existing error"
962        );
963    }
964
965    #[test]
966    fn a_service_error_round_trips_through_serde_with_and_without_detail() {
967        for e in [
968            DataflowError::service("k", "m")
969                .detail("d")
970                .retryable(true)
971                .build(),
972            DataflowError::service("k", "m").build(),
973        ] {
974            let json = serde_json::to_string(&e).unwrap();
975            let back: DataflowError = serde_json::from_str(&json).unwrap();
976            assert_eq!(back.kind(), e.kind());
977            assert_eq!(back.detail(), e.detail());
978            assert_eq!(back.retryable(), e.retryable());
979        }
980
981        // `detail: None` emits no key.
982        let bare = DataflowError::service("k", "m").build();
983        assert!(!serde_json::to_string(&bare).unwrap().contains("detail"));
984    }
985}