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