Skip to main content

etl_core/
error.rs

1//! Error taxonomy and per-stage error policies.
2//!
3//! Three classes of failure exist in a pipeline (see `docs/DESIGN.md`):
4//! *retryable* (transient I/O — handled by the sink retry layer),
5//! *record-level* (a bad payload or failed user code — subject to
6//! [`ErrorPolicy`]), and *fatal* (invariant violations — the pipeline
7//! stops). Record-level policies are deliberately limited to `Skip` and
8//! `Fail`; every skip is surfaced through metrics.
9
10/// What to do when a record fails in a stage.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum ErrorPolicy {
14    /// Drop the record, count it in `etl_*_dropped_total{reason}`, and
15    /// continue. Default for deserializers.
16    Skip,
17    /// Fail the batch and stop the pipeline. Default for operators.
18    #[default]
19    Fail,
20}
21
22/// Broad classification used in metrics labels (`error_type`) and by the
23/// retry layer.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ErrorClass {
27    /// Transient; retrying the same operation may succeed.
28    Retryable,
29    /// Specific to one record; retrying the same record cannot succeed.
30    RecordLevel,
31    /// The component or pipeline is broken; processing must stop.
32    Fatal,
33}
34
35/// An unrecoverable pipeline failure: an invariant was violated or a
36/// `Fail`-policy stage tripped. The pipeline transitions to `Failed`, the
37/// partition watermarks stop advancing, and the process exits non-zero.
38#[derive(Debug, thiserror::Error)]
39#[error("fatal error in {component}: {reason}")]
40pub struct FatalError {
41    /// Component id where the failure originated.
42    pub component: String,
43    /// Human-readable cause.
44    pub reason: String,
45}
46
47/// A payload could not be deserialized.
48#[derive(Debug, thiserror::Error)]
49#[non_exhaustive]
50pub enum DeserError {
51    /// The payload bytes do not match the expected format.
52    #[error("malformed payload: {reason}")]
53    Malformed {
54        /// Human-readable cause, for logs and dead-record metrics.
55        reason: String,
56    },
57    /// A schema required to decode the payload is not available.
58    #[error("schema unavailable: {reason}")]
59    SchemaUnavailable {
60        /// Human-readable cause.
61        reason: String,
62    },
63    /// The payload cannot be decoded *yet*: a required resource (typically
64    /// a schema fetched from a registry) is still being obtained, and the
65    /// deserializer has already triggered the asynchronous work that will
66    /// make it available. The chain reports the batch `Blocked` at this
67    /// payload and the driver's retry loop re-pushes it — the record is
68    /// neither dropped nor counted as an error, and the stage's
69    /// [`ErrorPolicy`] does not apply.
70    ///
71    /// Contract: a deserializer must return this **before emitting any
72    /// record** for the payload; records emitted ahead of a `NotReady`
73    /// would be duplicated when the payload is replayed.
74    #[error("not ready: {reason}")]
75    NotReady {
76        /// What is being waited for.
77        reason: String,
78    },
79}
80
81/// A source failed to poll, commit, or manage its assignment.
82#[derive(Debug, thiserror::Error)]
83#[non_exhaustive]
84pub enum SourceError {
85    /// Underlying client error.
86    #[error("source error ({class:?}): {reason}")]
87    Client {
88        /// Retryable vs fatal, as judged by the connector.
89        class: ErrorClass,
90        /// Human-readable cause.
91        reason: String,
92    },
93}
94
95/// A sink failed to write a batch.
96#[derive(Debug, thiserror::Error)]
97#[non_exhaustive]
98pub enum SinkError {
99    /// Underlying client error.
100    #[error("sink error ({class:?}): {reason}")]
101    Client {
102        /// Retryable (will be retried on another replica) vs fatal.
103        class: ErrorClass,
104        /// Human-readable cause.
105        reason: String,
106    },
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn defaults_match_documented_policy() {
115        assert_eq!(ErrorPolicy::default(), ErrorPolicy::Fail);
116    }
117
118    #[test]
119    fn errors_render_reasons() {
120        let e = DeserError::Malformed {
121            reason: "truncated header".into(),
122        };
123        assert!(e.to_string().contains("truncated header"));
124    }
125}