Skip to main content

spate_core/
error.rs

1//! Error taxonomy and per-stage error policies.
2//!
3//! A pipeline failure falls into one of three classes (ADR-0010):
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 limited to `Skip` and `Fail`; every
8//! 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 `spate_*_dropped_total{reason}`, and
15    /// continue. Default for deserializers.
16    Skip,
17    /// Fail the batch and stop the pipeline. An operator stage counts it in
18    /// `spate_operator_errors_total{error_type="fatal"}`. Default for
19    /// operators.
20    #[default]
21    Fail,
22}
23
24/// Broad classification used in metrics labels (`error_type`) and by the
25/// retry layer.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum ErrorClass {
29    /// Transient; retrying the same operation may succeed.
30    Retryable,
31    /// Specific to one record; retrying the same record cannot succeed.
32    RecordLevel,
33    /// The component or pipeline is broken; processing must stop.
34    Fatal,
35}
36
37/// An unrecoverable pipeline failure: an invariant was violated or a
38/// `Fail`-policy stage tripped. The pipeline transitions to `Failed`, the
39/// partition watermarks stop advancing, and the process exits non-zero.
40#[derive(Debug, thiserror::Error)]
41#[error("fatal error in {component}: {reason}")]
42pub struct FatalError {
43    /// Component id where the failure originated.
44    pub component: String,
45    /// Human-readable cause.
46    pub reason: String,
47}
48
49/// A payload could not be deserialized.
50#[derive(Debug, thiserror::Error)]
51#[non_exhaustive]
52pub enum DeserError {
53    /// The payload bytes do not match the expected format.
54    #[error("malformed payload: {reason}")]
55    Malformed {
56        /// Human-readable cause, for logs and dead-record metrics.
57        reason: String,
58    },
59    /// A schema required to decode the payload is not available.
60    #[error("schema unavailable: {reason}")]
61    SchemaUnavailable {
62        /// Human-readable cause.
63        reason: String,
64    },
65    /// The payload cannot be decoded *yet*. A required resource (typically
66    /// a schema fetched from a registry) is still being obtained, and the
67    /// deserializer has already triggered the asynchronous work that will
68    /// make it available. The chain reports the batch `Blocked` at this
69    /// payload and the driver's retry loop re-pushes it. The record is
70    /// neither dropped nor counted as an error, and the stage's
71    /// [`ErrorPolicy`] does not apply.
72    ///
73    /// Contract: a deserializer must return this **before emitting any
74    /// record** for the payload; records emitted ahead of a `NotReady`
75    /// would be duplicated when the payload is replayed.
76    #[error("not ready: {reason}")]
77    NotReady {
78        /// What is being waited for.
79        reason: String,
80    },
81    /// The deserializer can decode no further payload, for example because
82    /// its schema registry rejected the credentials. The chain stops the
83    /// pipeline whatever the stage's [`ErrorPolicy`], and counts the payload
84    /// as an error but not as dropped.
85    ///
86    /// Contract: as with `NotReady`, return this before emitting any record
87    /// for the payload.
88    #[error("deserializer failed: {reason}")]
89    Fatal {
90        /// Human-readable cause.
91        reason: String,
92    },
93}
94
95/// A source failed to poll, commit, or manage its assignment.
96#[derive(Debug, thiserror::Error)]
97#[non_exhaustive]
98pub enum SourceError {
99    /// Underlying client error.
100    #[error("source error ({class:?}): {reason}")]
101    Client {
102        /// Retryable vs fatal, as judged by the connector.
103        class: ErrorClass,
104        /// Human-readable cause.
105        reason: String,
106    },
107}
108
109/// A sink failed to write a batch.
110#[derive(Debug, thiserror::Error)]
111#[non_exhaustive]
112pub enum SinkError {
113    /// Underlying client error.
114    #[error("sink error ({class:?}): {reason}")]
115    Client {
116        /// Retryable (will be retried on another replica) vs fatal.
117        class: ErrorClass,
118        /// Human-readable cause.
119        reason: String,
120    },
121}
122
123/// The TLS alert descriptions (RFC 8446 numbering) with which a server
124/// rejects the client's certificate, credential or negotiated parameters.
125///
126/// A fatal alert from this list is a rejection that retrying cannot cure, so
127/// a connector classes it [`ErrorClass::Fatal`]. Any other alert, such as
128/// `decode_error` (50) or `internal_error` (80), stays retryable.
129pub const TLS_REJECTION_ALERTS: &[u8] = &[
130    40,  // handshake_failure
131    42,  // bad_certificate
132    43,  // unsupported_certificate
133    44,  // certificate_revoked
134    45,  // certificate_expired
135    46,  // certificate_unknown
136    48,  // unknown_ca
137    49,  // access_denied
138    51,  // decrypt_error
139    70,  // protocol_version
140    71,  // insufficient_security
141    109, // missing_extension
142    110, // unsupported_extension
143    112, // unrecognized_name
144    116, // certificate_required
145    120, // no_application_protocol
146];
147
148/// The first `T` in `err`'s source chain, `err` included.
149///
150/// At each [`std::io::Error`] it also follows the error the `io::Error`
151/// wraps, which `io::Error::source` skips.
152#[must_use]
153pub fn find_source<'a, T: std::error::Error + 'static>(
154    err: &'a (dyn std::error::Error + 'static),
155) -> Option<&'a T> {
156    let mut pending = vec![err];
157    while let Some(e) = pending.pop() {
158        if let Some(found) = e.downcast_ref() {
159            return Some(found);
160        }
161        if let Some(inner) = e
162            .downcast_ref::<std::io::Error>()
163            .and_then(|io| io.get_ref())
164        {
165            pending.push(inner);
166        }
167        pending.extend(e.source());
168    }
169    None
170}
171
172/// The TLS rejection in an error's source chain, as an
173/// `Option<&rustls::Error>`: a certificate the client failed to verify, an
174/// alert in [`TLS_REJECTION_ALERTS`](crate::error::TLS_REJECTION_ALERTS), or a
175/// peer that shares no protocol version, cipher suite or other handshake
176/// parameter with the client (`PeerIncompatible`).
177///
178/// The first argument is the path to the caller's `rustls` crate, such as
179/// `rustls` or `async_nats::rustls`, and the second an
180/// `&(dyn Error + 'static)`. It expands against the caller's rustls, so
181/// spate-core itself does not depend on rustls.
182///
183/// ```ignore
184/// let fatal = spate::tls_rejection!(rustls, &err).is_some();
185/// ```
186#[macro_export]
187macro_rules! tls_rejection {
188    ($($rustls:ident)::+, $err:expr) => {
189        $crate::error::find_source::<$($rustls)::+::Error>($err).filter(|tls| match tls {
190            $($rustls)::+::Error::InvalidCertificate(_)
191            | $($rustls)::+::Error::PeerIncompatible(_) => true,
192            $($rustls)::+::Error::AlertReceived(alert) => {
193                $crate::error::TLS_REJECTION_ALERTS.contains(&u8::from(*alert))
194            }
195            _ => false,
196        })
197    };
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// A certificate failure, a listed alert and an incompatible peer are
205    /// rejections behind an `io::Error`; an unlisted alert and other TLS
206    /// errors are not.
207    #[test]
208    fn tls_rejection_matches_the_rejection_classes() {
209        use rustls::{AlertDescription as A, CertificateError, Error as Tls};
210        for (tls, rejected) in [
211            (
212                Tls::InvalidCertificate(CertificateError::UnknownIssuer),
213                true,
214            ),
215            (
216                Tls::PeerIncompatible(rustls::PeerIncompatible::NoCipherSuitesInCommon),
217                true,
218            ),
219            (Tls::AlertReceived(A::HandshakeFailure), true),
220            (Tls::AlertReceived(A::CertificateRequired), true),
221            (Tls::AlertReceived(A::DecodeError), false),
222            (Tls::AlertReceived(A::InternalError), false),
223            (Tls::General("reset".into()), false),
224        ] {
225            let name = format!("{tls:?}");
226            let err = std::io::Error::new(std::io::ErrorKind::InvalidData, tls);
227            assert_eq!(
228                crate::tls_rejection!(rustls, &err).is_some(),
229                rejected,
230                "{name}"
231            );
232        }
233    }
234
235    #[test]
236    fn defaults_match_documented_policy() {
237        assert_eq!(ErrorPolicy::default(), ErrorPolicy::Fail);
238    }
239
240    #[test]
241    fn errors_render_reasons() {
242        let e = DeserError::Malformed {
243            reason: "truncated header".into(),
244        };
245        assert!(e.to_string().contains("truncated header"));
246    }
247
248    #[derive(Debug, thiserror::Error)]
249    #[error("target {0}")]
250    struct Target(u8);
251
252    #[derive(Debug, thiserror::Error)]
253    #[error("wrapper")]
254    struct Wrapper(#[source] std::io::Error);
255
256    /// A `T` inside an `io::Error` is found, though `io::Error::source` skips
257    /// it.
258    #[test]
259    fn find_source_reaches_the_error_an_io_error_wraps() {
260        let err = Wrapper(std::io::Error::other(std::io::Error::new(
261            std::io::ErrorKind::InvalidData,
262            Target(7),
263        )));
264        assert_eq!(find_source::<Target>(&err).map(|t| t.0), Some(7));
265        assert!(find_source::<FatalError>(&err).is_none());
266    }
267}