Skip to main content

laminar_connectors/error/
mod.rs

1use thiserror::Error;
2
3/// Errors that can occur during connector operations.
4///
5/// Callers that need to distinguish "retry may work" from "propagate"
6/// should use [`ConnectorError::is_transient`] rather than matching
7/// variants directly — the variant set has changed in the past.
8#[derive(Debug, Error)]
9pub enum ConnectorError {
10    /// A connector factory was registered more than once in the same registry category.
11    #[error("{kind} connector factory '{name}' is already registered")]
12    FactoryAlreadyRegistered {
13        /// Registry category (`source`, `sink`, `table source`, or `lookup source`).
14        kind: &'static str,
15        /// Duplicate connector type name.
16        name: String,
17    },
18
19    /// Connector factory registration was attempted after construction completed.
20    #[error("connector registry is frozen; cannot register {kind} factory '{name}'")]
21    RegistryFrozen {
22        /// Registry category (`source`, `sink`, `table source`, or `lookup source`).
23        kind: &'static str,
24        /// Connector type name that was rejected.
25        name: String,
26    },
27
28    /// Failed to connect to the external system (network error, DNS
29    /// failure, TLS negotiation failure, auth rejection).
30    #[error("connection failed: {0}")]
31    ConnectionFailed(String),
32
33    /// Invalid, missing, or contradictory connector configuration.
34    #[error("configuration error: {0}")]
35    ConfigurationError(String),
36
37    /// A requested connector capability is intentionally unavailable.
38    #[error("feature unsupported: {0}")]
39    FeatureUnsupported(String),
40
41    /// Error reading data from a source.
42    #[error("read error: {0}")]
43    ReadError(String),
44
45    /// Error writing data to a sink.
46    #[error("write error: {0}")]
47    WriteError(String),
48
49    /// A dispatched operation failed without proving whether its external
50    /// side effects were applied.
51    ///
52    /// Recovery may replay the operation, but the connector generation that
53    /// observed this error must not process later work because its external
54    /// position is no longer known.
55    #[error("operation outcome unknown: {message}")]
56    OutcomeUnknown {
57        /// Failure detail.
58        message: String,
59        /// Whether a fresh connector generation may make progress after reconciliation.
60        retryable: bool,
61    },
62
63    /// Serialization or deserialization error.
64    #[error("serde error: {0}")]
65    Serde(#[from] SerdeError),
66
67    /// Transaction error (begin/commit/rollback).
68    ///
69    /// Kept separate from [`Self::WriteError`] because transactional
70    /// failures are classified as **non-transient** by default; a write
71    /// error is transient. Per-connector retry policy can override, but
72    /// the default must not loop forever on bad transactional state.
73    #[error("transaction error: {0}")]
74    TransactionError(String),
75
76    /// The connector is not in the expected state.
77    #[error("invalid state: expected {expected}, got {actual}")]
78    InvalidState {
79        /// The expected state.
80        expected: String,
81        /// The actual state.
82        actual: String,
83    },
84
85    /// Schema mismatch between expected and actual data.
86    #[error("schema mismatch: {0}")]
87    SchemaMismatch(String),
88
89    /// Operation timed out.
90    #[error("timeout after {0}ms")]
91    Timeout(u64),
92
93    /// The connector has been closed.
94    #[error("connector closed")]
95    Closed,
96
97    /// An internal error that doesn't fit other categories.
98    #[error("internal error: {0}")]
99    Internal(String),
100
101    /// An I/O error from the underlying system.
102    #[error("I/O error: {0}")]
103    Io(#[from] std::io::Error),
104}
105
106impl From<laminar_core::lookup::source::LookupError> for ConnectorError {
107    fn from(err: laminar_core::lookup::source::LookupError) -> Self {
108        use laminar_core::lookup::source::LookupError;
109        match err {
110            LookupError::Connection(m) => Self::ConnectionFailed(m),
111            LookupError::Query(m) => Self::ReadError(m),
112            LookupError::Timeout(d) =>
113            {
114                #[allow(clippy::cast_possible_truncation)]
115                Self::Timeout(d.as_millis() as u64)
116            }
117            LookupError::NotAvailable(m) => Self::InvalidState {
118                expected: "lookup source available".into(),
119                actual: m,
120            },
121            LookupError::Internal(m) => Self::Internal(m),
122        }
123    }
124}
125
126impl ConnectorError {
127    /// Construct an error for a dispatched operation whose external outcome is unknown.
128    #[must_use]
129    pub fn outcome_unknown(message: impl Into<String>, retryable: bool) -> Self {
130        Self::OutcomeUnknown {
131            message: message.into(),
132            retryable,
133        }
134    }
135
136    /// Construct a "missing required config" error. Thin helper around
137    /// [`Self::ConfigurationError`] so every "missing required config:
138    /// {key}" message is shaped the same way.
139    #[must_use]
140    pub fn missing_config(key: impl Into<String>) -> Self {
141        Self::ConfigurationError(format!("missing required config: {}", key.into()))
142    }
143
144    /// Returns `true` if this error is likely transient and recovery or a
145    /// retry may make progress (e.g., network timeout, throttled request).
146    /// An outcome-unknown error still requires connector retirement before
147    /// replay; inspect [`Self::is_outcome_unknown`] when that distinction
148    /// matters.
149    /// Returns `false` for configuration, schema, and state errors that
150    /// will not resolve without user intervention.
151    #[must_use]
152    pub fn is_transient(&self) -> bool {
153        match self {
154            Self::OutcomeUnknown { retryable, .. } => *retryable,
155            Self::ReadError(_)
156            | Self::WriteError(_)
157            | Self::Timeout(_)
158            | Self::Io(_)
159            | Self::ConnectionFailed(_) => true,
160
161            Self::ConfigurationError(_)
162            | Self::FeatureUnsupported(_)
163            | Self::FactoryAlreadyRegistered { .. }
164            | Self::RegistryFrozen { .. }
165            | Self::SchemaMismatch(_)
166            | Self::InvalidState { .. }
167            | Self::TransactionError(_)
168            | Self::Serde(_)
169            | Self::Closed
170            | Self::Internal(_) => false,
171        }
172    }
173
174    /// Returns `true` when an external side effect may have completed even
175    /// though the operation returned an error.
176    ///
177    /// This is stronger than [`Self::is_transient`]: callers may recover and
178    /// replay, but must retire the connector generation before doing so.
179    #[must_use]
180    pub fn is_outcome_unknown(&self) -> bool {
181        matches!(self, Self::OutcomeUnknown { .. })
182    }
183}
184
185/// Errors that occur during record serialization or deserialization.
186#[derive(Debug, Error)]
187pub enum SerdeError {
188    /// JSON parsing or encoding error.
189    #[error("JSON error: {0}")]
190    Json(String),
191
192    /// CSV parsing or encoding error.
193    #[error("CSV error: {0}")]
194    Csv(String),
195
196    /// The data format is not supported.
197    #[error("unsupported format: {0}")]
198    UnsupportedFormat(String),
199
200    /// A required field is missing from the input.
201    #[error("missing field: {0}")]
202    MissingField(String),
203
204    /// A field value could not be converted to the target Arrow type.
205    #[error("type conversion error: field '{field}', expected {expected}: {message}")]
206    TypeConversion {
207        /// The field name.
208        field: String,
209        /// The expected Arrow data type.
210        expected: String,
211        /// Details about the conversion failure.
212        message: String,
213    },
214
215    /// The input data is malformed.
216    #[error("malformed input: {0}")]
217    MalformedInput(String),
218
219    /// Schema ID not found in registry.
220    #[error("schema not found: schema ID {schema_id}")]
221    SchemaNotFound {
222        /// The schema ID that was not found.
223        schema_id: i32,
224    },
225
226    /// Confluent wire format magic byte mismatch.
227    #[error("invalid Confluent header: expected 0x{expected:02x}, got 0x{got:02x}")]
228    InvalidConfluentHeader {
229        /// Expected magic byte (0x00).
230        expected: u8,
231        /// Actual byte found.
232        got: u8,
233    },
234
235    /// Schema incompatible with existing version in the registry.
236    #[error("schema incompatible: subject '{subject}': {message}")]
237    SchemaIncompatible {
238        /// The Schema Registry subject name.
239        subject: String,
240        /// Incompatibility details.
241        message: String,
242    },
243
244    /// Avro decode failure for a specific column.
245    #[error("Avro decode error: column '{column}' (avro type '{avro_type}'): {message}")]
246    AvroDecodeError {
247        /// The column that failed to decode.
248        column: String,
249        /// The Avro type being decoded.
250        avro_type: String,
251        /// The decode failure details.
252        message: String,
253    },
254
255    /// Record count mismatch after serialization.
256    #[error("record count mismatch: expected {expected}, got {got}")]
257    RecordCountMismatch {
258        /// Expected number of records.
259        expected: usize,
260        /// Actual number of records produced.
261        got: usize,
262    },
263}
264
265impl From<serde_json::Error> for SerdeError {
266    fn from(e: serde_json::Error) -> Self {
267        SerdeError::Json(e.to_string())
268    }
269}
270
271#[cfg(test)]
272mod tests;