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