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
321/// Default cap on the number of records held at the error-context path.
322///
323/// Bounded so the option's memory cost does not scale with a looping workflow's
324/// iteration count — `Message.context` is deep-cloned into every trace snapshot.
325pub(crate) const DEFAULT_ERROR_CONTEXT_LIMIT: usize = 32;
326
327/// Where the engine mirrors per-task failure codes into the message context, so
328/// a downstream `condition` or `map` can branch on *why* a task failed.
329///
330/// Built and validated by `EngineBuilder::build`; never constructed on the hot
331/// path. Holds the dotted form (for `refresh_for_path`) and the pre-split walk
332/// order (for the write) so neither is recomputed per failure.
333#[derive(Debug, Clone)]
334pub(crate) struct ErrorContextConfig {
335    /// Dotted path, e.g. `"metadata.errors"`.
336    pub(crate) path: String,
337    /// `path` pre-split into the walk order used by the `*_parts` accessors.
338    pub(crate) path_parts: Arc<[Arc<str>]>,
339    /// Most-recent-N records retained; older ones are dropped from the front.
340    pub(crate) limit: usize,
341}
342
343impl ErrorContextConfig {
344    /// Validate `path` and pre-split it.
345    ///
346    /// The first segment must name one of the three context slots. Anything else
347    /// — `payload.errors` above all — would write somewhere the JSONLogic
348    /// evaluation context cannot see, and the workflow author would get a
349    /// silently-never-true condition rather than an error. `metadata.progress` is
350    /// rejected outright: the workflow executor owns that slot and cross-workflow
351    /// chaining depends on its shape.
352    pub(crate) fn new(path: String, limit: usize) -> Result<Self> {
353        let mut segments = path.split('.');
354        let first = segments.next().unwrap_or_default();
355        let root = strip_hash_prefix(first);
356        if !matches!(root, "data" | "metadata" | "temp_data") {
357            return Err(DataflowError::Workflow(format!(
358                "error context path must start with `data`, `metadata` or `temp_data`, got {path:?}"
359            )));
360        }
361        let rest: Vec<&str> = segments.collect();
362        if rest.is_empty() || rest.iter().any(|s| s.is_empty()) {
363            return Err(DataflowError::Workflow(format!(
364                "error context path must name a slot inside `{root}`, got {path:?}"
365            )));
366        }
367        if root == "metadata" && rest == ["progress"] {
368            return Err(DataflowError::Workflow(
369                "error context path may not be `metadata.progress` — the engine owns \
370                 that slot for cross-workflow chaining"
371                    .to_string(),
372            ));
373        }
374        if limit == 0 {
375            return Err(DataflowError::Workflow(
376                "error context limit must be at least 1".to_string(),
377            ));
378        }
379        let path_parts = compute_path_parts(first, &rest.join("."));
380        Ok(Self {
381            path,
382            path_parts,
383            limit,
384        })
385    }
386}
387
388/// The engine-owned code for a `DataflowError` variant.
389///
390/// The closed vocabulary every consumer switches on. `Service` is included for
391/// totality but is normally reached through [`service_error_code`], which
392/// prefers the service's own `kind`; an empty `kind` lands here and falls back
393/// to `TASK_ERROR` so no `ErrorInfo` ever carries an empty code.
394fn variant_code(error: &DataflowError) -> &'static str {
395    match error {
396        DataflowError::Validation(_) => "VALIDATION_ERROR",
397        DataflowError::Workflow(_) => "WORKFLOW_ERROR",
398        DataflowError::Task(_) => "TASK_ERROR",
399        DataflowError::FunctionNotFound(_) => "FUNCTION_NOT_FOUND",
400        DataflowError::FunctionExecution { .. } => "FUNCTION_ERROR",
401        DataflowError::LogicEvaluation(_) => "LOGIC_ERROR",
402        DataflowError::BudgetExceeded(_) => "BUDGET_EXCEEDED",
403        DataflowError::Http { .. } => "HTTP_ERROR",
404        DataflowError::Timeout(_) => "TIMEOUT_ERROR",
405        DataflowError::Io(_) => "IO_ERROR",
406        DataflowError::Deserialization(_) => "DESERIALIZATION_ERROR",
407        DataflowError::Unknown(_) => "UNKNOWN_ERROR",
408        DataflowError::Service { .. } => "TASK_ERROR",
409    }
410}
411
412/// The `ErrorInfo.code` recorded for `error` — the single classifier, used by
413/// both [`ErrorInfo::new`] and `workflow_executor`'s per-task error recording,
414/// so the two can never drift.
415///
416/// A [`DataflowError::Service`] contributes its own `kind` **verbatim** rather
417/// than upper-cased: the service owns it and will switch on the recorded `code`,
418/// so making the two different strings would force every consumer to know the
419/// transform. `kind()` recurses through `FunctionExecution.source`, so wrapping a
420/// service error for context keeps its classification.
421///
422/// Everything else takes its variant's code from [`variant_code`]. Note this is
423/// **not** a flat `TASK_ERROR` fallback: before 3.5.0 every engine-owned variant
424/// collapsed to `TASK_ERROR` on the live path, which made a timeout, a dropped
425/// connection and a rejected request indistinguishable to a workflow author.
426pub(crate) fn service_error_code(error: &DataflowError) -> String {
427    match error.kind() {
428        Some(kind) if !kind.is_empty() => kind.to_string(),
429        _ => variant_code(error).to_string(),
430    }
431}
432
433/// The single conversion from a `datalogic-rs` evaluation error into this
434/// crate's error channel.
435///
436/// Every *evaluation* failure that surfaces as a [`DataflowError`] goes through
437/// here, so the budget/ordinary split is decided in one place rather than at
438/// each `map_err`. Classification reads [`datalogic_rs::Error::tag`] rather
439/// than matching `ErrorKind`, because the `BudgetExceeded` variant is itself
440/// feature-gated in datalogic — the tag is the same string either way and
441/// needs no `cfg` here.
442///
443/// *Compile*-time failures deliberately do not use this: nothing is charged
444/// for compiling or for folding a constant subtree, so a budget error cannot
445/// arise there and those sites stay plain [`DataflowError::LogicEvaluation`].
446pub(crate) fn from_datalogic_eval(error: &datalogic_rs::Error) -> DataflowError {
447    if error.tag() == "BudgetExceeded" {
448        return DataflowError::BudgetExceeded(error.to_string());
449    }
450    DataflowError::LogicEvaluation(error.to_string())
451}
452
453impl ErrorInfo {
454    /// Create a new error info entry with all fields
455    pub fn new(workflow_id: Option<String>, task_id: Option<String>, error: DataflowError) -> Self {
456        Self {
457            // The single classifier, shared with the live per-task recording
458            // path so the two can never drift.
459            code: service_error_code(&error),
460            detail: error.detail().map(str::to_string),
461            message: error.to_string(),
462            path: None,
463            workflow_id,
464            task_id,
465            timestamp: Some(Utc::now().to_rfc3339()),
466            retry_attempted: Some(false),
467            retry_count: Some(0),
468        }
469    }
470
471    /// Create a simple error info with just code, message, and optional path
472    pub fn simple(code: String, message: String, path: Option<String>) -> Self {
473        Self {
474            code,
475            message,
476            path,
477            workflow_id: None,
478            task_id: None,
479            timestamp: Some(Utc::now().to_rfc3339()),
480            retry_attempted: None,
481            retry_count: None,
482            detail: None,
483        }
484    }
485
486    /// Create a simple error info from references (avoids cloning when possible)
487    pub fn simple_ref(code: &str, message: &str, path: Option<&str>) -> Self {
488        Self {
489            code: code.to_string(),
490            message: message.to_string(),
491            path: path.map(|s| s.to_string()),
492            workflow_id: None,
493            task_id: None,
494            timestamp: Some(Utc::now().to_rfc3339()),
495            retry_attempted: None,
496            retry_count: None,
497            detail: None,
498        }
499    }
500
501    /// Mark that a retry was attempted
502    pub fn with_retry(mut self) -> Self {
503        self.retry_attempted = Some(true);
504        self.retry_count = Some(self.retry_count.unwrap_or(0) + 1);
505        self
506    }
507
508    /// Create a builder for ErrorInfo
509    pub fn builder(code: impl Into<String>, message: impl Into<String>) -> ErrorInfoBuilder {
510        ErrorInfoBuilder::new(code, message)
511    }
512}
513
514/// Builder for creating ErrorInfo instances with a fluent API
515#[must_use = "ErrorInfoBuilder must be `.build()` to produce an ErrorInfo"]
516pub struct ErrorInfoBuilder {
517    code: String,
518    message: String,
519    path: Option<String>,
520    workflow_id: Option<String>,
521    task_id: Option<String>,
522    timestamp: Option<String>,
523    retry_attempted: Option<bool>,
524    retry_count: Option<u32>,
525    detail: Option<String>,
526}
527
528impl ErrorInfoBuilder {
529    /// Create a new ErrorInfoBuilder with required fields
530    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
531        Self {
532            code: code.into(),
533            message: message.into(),
534            path: None,
535            workflow_id: None,
536            task_id: None,
537            timestamp: Some(Utc::now().to_rfc3339()),
538            retry_attempted: None,
539            retry_count: None,
540            detail: None,
541        }
542    }
543
544    /// Set the error path
545    pub fn path(mut self, path: impl Into<String>) -> Self {
546        self.path = Some(path.into());
547        self
548    }
549
550    /// Set the workflow ID
551    pub fn workflow_id(mut self, id: impl Into<String>) -> Self {
552        self.workflow_id = Some(id.into());
553        self
554    }
555
556    /// Set the task ID
557    pub fn task_id(mut self, id: impl Into<String>) -> Self {
558        self.task_id = Some(id.into());
559        self
560    }
561
562    /// Set custom timestamp (defaults to now if not set)
563    pub fn timestamp(mut self, timestamp: impl Into<String>) -> Self {
564        self.timestamp = Some(timestamp.into());
565        self
566    }
567
568    /// Mark as retry attempted
569    pub fn retry_attempted(mut self, attempted: bool) -> Self {
570        self.retry_attempted = Some(attempted);
571        self
572    }
573
574    /// Set retry count
575    pub fn retry_count(mut self, count: u32) -> Self {
576        self.retry_count = Some(count);
577        self
578    }
579
580    /// Attach operator-only detail. Not surfaced by `Display` anywhere; a service
581    /// must not pass it to an untrusted caller.
582    pub fn detail(mut self, detail: impl Into<String>) -> Self {
583        self.detail = Some(detail.into());
584        self
585    }
586
587    /// Build the ErrorInfo instance
588    pub fn build(self) -> ErrorInfo {
589        ErrorInfo {
590            code: self.code,
591            message: self.message,
592            path: self.path,
593            workflow_id: self.workflow_id,
594            task_id: self.task_id,
595            timestamp: self.timestamp,
596            retry_attempted: self.retry_attempted,
597            retry_count: self.retry_count,
598            detail: self.detail,
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_retryable_errors() {
609        // Test retryable errors
610        assert!(
611            DataflowError::Http {
612                status: 500,
613                message: "Internal Server Error".to_string()
614            }
615            .retryable()
616        );
617        assert!(
618            DataflowError::Http {
619                status: 502,
620                message: "Bad Gateway".to_string()
621            }
622            .retryable()
623        );
624        assert!(
625            DataflowError::Http {
626                status: 503,
627                message: "Service Unavailable".to_string()
628            }
629            .retryable()
630        );
631        assert!(
632            DataflowError::Http {
633                status: 429,
634                message: "Too Many Requests".to_string()
635            }
636            .retryable()
637        );
638        assert!(
639            DataflowError::Http {
640                status: 408,
641                message: "Request Timeout".to_string()
642            }
643            .retryable()
644        );
645        assert!(
646            DataflowError::Http {
647                status: 0,
648                message: "Connection Error".to_string()
649            }
650            .retryable()
651        );
652        assert!(DataflowError::Timeout("Connection timeout".to_string()).retryable());
653        assert!(DataflowError::Io("Network error".to_string()).retryable());
654    }
655
656    #[test]
657    fn test_non_retryable_errors() {
658        // Test non-retryable errors
659        assert!(
660            !DataflowError::Http {
661                status: 400,
662                message: "Bad Request".to_string()
663            }
664            .retryable()
665        );
666        assert!(
667            !DataflowError::Http {
668                status: 401,
669                message: "Unauthorized".to_string()
670            }
671            .retryable()
672        );
673        assert!(
674            !DataflowError::Http {
675                status: 403,
676                message: "Forbidden".to_string()
677            }
678            .retryable()
679        );
680        assert!(
681            !DataflowError::Http {
682                status: 404,
683                message: "Not Found".to_string()
684            }
685            .retryable()
686        );
687        assert!(!DataflowError::Validation("Invalid input".to_string()).retryable());
688        assert!(!DataflowError::LogicEvaluation("Invalid logic".to_string()).retryable());
689        assert!(!DataflowError::Deserialization("Invalid JSON".to_string()).retryable());
690        assert!(!DataflowError::Workflow("Invalid workflow".to_string()).retryable());
691        assert!(!DataflowError::Unknown("Unknown error".to_string()).retryable());
692    }
693
694    #[test]
695    fn test_function_execution_error_retryability() {
696        // Test that function execution errors inherit retryability from source
697        let retryable_source = DataflowError::Http {
698            status: 500,
699            message: "Server Error".to_string(),
700        };
701        let non_retryable_source = DataflowError::Validation("Invalid data".to_string());
702
703        let retryable_func_error =
704            DataflowError::function_execution("HTTP call failed", Some(retryable_source));
705        let non_retryable_func_error =
706            DataflowError::function_execution("Validation failed", Some(non_retryable_source));
707        let no_source_func_error = DataflowError::function_execution("Unknown failure", None);
708
709        assert!(retryable_func_error.retryable());
710        assert!(!non_retryable_func_error.retryable());
711        assert!(!no_source_func_error.retryable());
712    }
713
714    #[test]
715    fn test_error_info_builder() {
716        // Test basic builder
717        let error = ErrorInfo::builder("TEST_ERROR", "Test message").build();
718        assert_eq!(error.code, "TEST_ERROR");
719        assert_eq!(error.message, "Test message");
720        assert!(error.timestamp.is_some());
721        assert!(error.path.is_none());
722
723        // Test full builder
724        let error = ErrorInfo::builder("VALIDATION_ERROR", "Field validation failed")
725            .path("data.email")
726            .workflow_id("workflow_1")
727            .task_id("validate_email")
728            .retry_attempted(true)
729            .retry_count(2)
730            .build();
731
732        assert_eq!(error.code, "VALIDATION_ERROR");
733        assert_eq!(error.message, "Field validation failed");
734        assert_eq!(error.path, Some("data.email".to_string()));
735        assert_eq!(error.workflow_id, Some("workflow_1".to_string()));
736        assert_eq!(error.task_id, Some("validate_email".to_string()));
737        assert_eq!(error.retry_attempted, Some(true));
738        assert_eq!(error.retry_count, Some(2));
739    }
740
741    #[test]
742    fn test_error_info_new_from_dataflow_error() {
743        // Test ErrorInfo::new generates correct codes for each error type
744        let test_cases = vec![
745            (
746                DataflowError::Validation("test".to_string()),
747                "VALIDATION_ERROR",
748            ),
749            (
750                DataflowError::Workflow("test".to_string()),
751                "WORKFLOW_ERROR",
752            ),
753            (DataflowError::Task("test".to_string()), "TASK_ERROR"),
754            (
755                DataflowError::FunctionNotFound("test".to_string()),
756                "FUNCTION_NOT_FOUND",
757            ),
758            (
759                DataflowError::function_execution("test", None),
760                "FUNCTION_ERROR",
761            ),
762            (
763                DataflowError::LogicEvaluation("test".to_string()),
764                "LOGIC_ERROR",
765            ),
766            (DataflowError::http(404, "Not Found"), "HTTP_ERROR"),
767            (DataflowError::Timeout("test".to_string()), "TIMEOUT_ERROR"),
768            (DataflowError::Io("test".to_string()), "IO_ERROR"),
769            (
770                DataflowError::Deserialization("test".to_string()),
771                "DESERIALIZATION_ERROR",
772            ),
773            (DataflowError::Unknown("test".to_string()), "UNKNOWN_ERROR"),
774        ];
775
776        for (error, expected_code) in test_cases {
777            let info = ErrorInfo::new(
778                Some("workflow_1".to_string()),
779                Some("task_1".to_string()),
780                error,
781            );
782            assert_eq!(info.code, expected_code);
783            assert_eq!(info.workflow_id, Some("workflow_1".to_string()));
784            assert_eq!(info.task_id, Some("task_1".to_string()));
785            assert!(info.timestamp.is_some());
786            assert_eq!(info.retry_attempted, Some(false));
787            assert_eq!(info.retry_count, Some(0));
788        }
789    }
790
791    #[test]
792    fn test_error_info_simple_constructors() {
793        // Test simple constructor
794        let error = ErrorInfo::simple(
795            "CUSTOM_ERROR".to_string(),
796            "Custom message".to_string(),
797            Some("data.field".to_string()),
798        );
799        assert_eq!(error.code, "CUSTOM_ERROR");
800        assert_eq!(error.message, "Custom message");
801        assert_eq!(error.path, Some("data.field".to_string()));
802        assert!(error.workflow_id.is_none());
803        assert!(error.task_id.is_none());
804        assert!(error.timestamp.is_some());
805
806        // Test simple_ref constructor
807        let error = ErrorInfo::simple_ref("REF_ERROR", "Ref message", Some("data.path"));
808        assert_eq!(error.code, "REF_ERROR");
809        assert_eq!(error.message, "Ref message");
810        assert_eq!(error.path, Some("data.path".to_string()));
811
812        // Test simple_ref with None path
813        let error = ErrorInfo::simple_ref("NO_PATH", "No path message", None);
814        assert!(error.path.is_none());
815    }
816
817    #[test]
818    fn test_error_info_with_retry() {
819        let error = ErrorInfo::simple_ref("TEST", "Test", None);
820        assert!(error.retry_attempted.is_none());
821        assert!(error.retry_count.is_none());
822
823        let error = error.with_retry();
824        assert_eq!(error.retry_attempted, Some(true));
825        assert_eq!(error.retry_count, Some(1));
826
827        let error = error.with_retry();
828        assert_eq!(error.retry_attempted, Some(true));
829        assert_eq!(error.retry_count, Some(2));
830    }
831
832    #[test]
833    fn test_error_display_messages() {
834        // Test that error display messages are correct
835        assert_eq!(
836            DataflowError::Validation("test".to_string()).to_string(),
837            "Validation error: test"
838        );
839        assert_eq!(
840            DataflowError::Workflow("test".to_string()).to_string(),
841            "Workflow error: test"
842        );
843        assert_eq!(
844            DataflowError::Task("test".to_string()).to_string(),
845            "Task error: test"
846        );
847        assert_eq!(
848            DataflowError::FunctionNotFound("test".to_string()).to_string(),
849            "Function not found: test"
850        );
851        assert_eq!(
852            DataflowError::http(404, "Not Found").to_string(),
853            "HTTP error: 404 - Not Found"
854        );
855        assert_eq!(
856            DataflowError::Timeout("test".to_string()).to_string(),
857            "Timeout error: test"
858        );
859    }
860
861    #[test]
862    fn test_error_conversions() {
863        // Test from_serde (we can't easily create a real serde error, but we can test the conversion works)
864        let json_str = "invalid json";
865        let serde_result: std::result::Result<serde_json::Value, _> =
866            serde_json::from_str(json_str);
867        if let Err(e) = serde_result {
868            let dataflow_err = DataflowError::from_serde(e);
869            assert!(matches!(dataflow_err, DataflowError::Deserialization(_)));
870        }
871    }
872
873    #[test]
874    fn service_error_carries_kind_detail_and_declared_retryability() {
875        let e = DataflowError::service("circuit_open", "upstream unavailable")
876            .detail("connector 'billing' breaker open")
877            .retryable(true)
878            .build();
879
880        assert_eq!(e.kind(), Some("circuit_open"));
881        assert_eq!(e.detail(), Some("connector 'billing' breaker open"));
882        assert!(e.retryable());
883    }
884
885    #[test]
886    fn function_execution_inherits_kind_detail_and_retryable_from_a_wrapped_service_source() {
887        // `DataflowError::function_execution` is the documented way to add
888        // context to a downstream error. `retryable()` already recursed into
889        // `source`; `kind()`/`detail()` must do the same, or wrapping a
890        // `Service` error for context silently downgrades it to a generic
891        // `TASK_ERROR` with no detail.
892        let inner = DataflowError::service("circuit_open", "upstream unavailable")
893            .detail("connector 'billing' breaker open since 12:04")
894            .retryable(true)
895            .build();
896        let wrapped = DataflowError::function_execution("calling billing connector", Some(inner));
897
898        assert_eq!(wrapped.kind(), Some("circuit_open"));
899        assert_eq!(
900            wrapped.detail(),
901            Some("connector 'billing' breaker open since 12:04")
902        );
903        assert!(wrapped.retryable());
904
905        // `ErrorInfo::new`'s Service arm goes through the same `kind()` call,
906        // so the wrapped error's code makes it through end to end too.
907        assert_eq!(service_error_code(&wrapped), "circuit_open");
908    }
909
910    #[test]
911    fn service_display_hides_the_detail_but_debug_shows_it() {
912        let e = DataflowError::service("circuit_open", "upstream unavailable")
913            .detail("SECRET-TOPOLOGY")
914            .build();
915
916        // `to_string()` is always safe to hand to an untrusted caller.
917        assert_eq!(e.to_string(), "upstream unavailable");
918        assert!(!e.to_string().contains("SECRET-TOPOLOGY"));
919        // The operator channel is reachable through Debug.
920        assert!(format!("{e:?}").contains("SECRET-TOPOLOGY"));
921    }
922
923    #[test]
924    fn service_retryability_is_independent_of_every_other_field() {
925        let yes = DataflowError::service("k", "m").retryable(true).build();
926        let no = DataflowError::service("k", "m").retryable(false).build();
927        assert!(yes.retryable());
928        assert!(!no.retryable());
929        // Defaults to false.
930        assert!(!DataflowError::service("k", "m").build().retryable());
931    }
932
933    #[test]
934    fn kind_and_detail_are_none_for_every_engine_owned_variant() {
935        let variants = [
936            DataflowError::Validation("v".into()),
937            DataflowError::FunctionExecution {
938                context: "c".into(),
939                source: None,
940            },
941            DataflowError::LogicEvaluation("l".into()),
942            DataflowError::Deserialization("d".into()),
943            DataflowError::Workflow("w".into()),
944            DataflowError::Task("t".into()),
945            DataflowError::FunctionNotFound("f".into()),
946            DataflowError::Http {
947                status: 500,
948                message: "h".into(),
949            },
950            DataflowError::Timeout("to".into()),
951            DataflowError::Io("io".into()),
952            DataflowError::Unknown("u".into()),
953        ];
954        assert_eq!(variants.len(), 11, "one assertion per engine-owned variant");
955        for v in &variants {
956            assert_eq!(v.kind(), None, "kind() for {v:?}");
957            assert_eq!(v.detail(), None, "detail() for {v:?}");
958        }
959    }
960
961    #[test]
962    fn service_error_code_passes_kind_through_verbatim() {
963        // The recorded decision: verbatim, not upper-cased. A service switching
964        // on the recorded `code` should not have to know a transform.
965        let e = DataflowError::service("circuit_open", "m").build();
966        let info = ErrorInfo::new(None, None, e);
967        assert_eq!(info.code, "circuit_open");
968    }
969
970    #[test]
971    fn an_empty_kind_falls_back_rather_than_recording_an_empty_code() {
972        let e = DataflowError::service("", "m").build();
973        let info = ErrorInfo::new(None, None, e);
974        assert_eq!(info.code, "TASK_ERROR");
975        assert!(!info.code.is_empty());
976    }
977
978    #[test]
979    fn a_non_ascii_kind_is_neither_panicked_on_nor_mangled() {
980        let e = DataflowError::service("limite_dépassé", "m").build();
981        let info = ErrorInfo::new(None, None, e);
982        // Verbatim, so the exact string round-trips including the accent.
983        assert_eq!(info.code, "limite_dépassé");
984    }
985
986    #[test]
987    fn error_info_lifts_the_detail_and_omits_it_when_absent() {
988        let with = ErrorInfo::new(
989            None,
990            None,
991            DataflowError::service("k", "m").detail("op only").build(),
992        );
993        assert_eq!(with.detail.as_deref(), Some("op only"));
994        assert!(serde_json::to_string(&with).unwrap().contains("detail"));
995
996        // Absent on a Service without one, and on every engine-owned variant.
997        let without = ErrorInfo::new(None, None, DataflowError::service("k", "m").build());
998        assert_eq!(without.detail, None);
999        assert!(!serde_json::to_string(&without).unwrap().contains("detail"));
1000
1001        let engine_owned = ErrorInfo::new(None, None, DataflowError::Task("t".into()));
1002        assert_eq!(engine_owned.detail, None);
1003        assert!(
1004            !serde_json::to_string(&engine_owned)
1005                .unwrap()
1006                .contains("detail"),
1007            "the JSON shape is unchanged for every pre-existing error"
1008        );
1009    }
1010
1011    #[test]
1012    fn a_service_error_round_trips_through_serde_with_and_without_detail() {
1013        for e in [
1014            DataflowError::service("k", "m")
1015                .detail("d")
1016                .retryable(true)
1017                .build(),
1018            DataflowError::service("k", "m").build(),
1019        ] {
1020            let json = serde_json::to_string(&e).unwrap();
1021            let back: DataflowError = serde_json::from_str(&json).unwrap();
1022            assert_eq!(back.kind(), e.kind());
1023            assert_eq!(back.detail(), e.detail());
1024            assert_eq!(back.retryable(), e.retryable());
1025        }
1026
1027        // `detail: None` emits no key.
1028        let bare = DataflowError::service("k", "m").build();
1029        assert!(!serde_json::to_string(&bare).unwrap().contains("detail"));
1030    }
1031}