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}