Skip to main content

arrow_tiberius/
error.rs

1//! Error types for `arrow-tiberius`.
2
3use std::fmt::{self, Write as _};
4
5use snafu::Snafu;
6
7use crate::{DiagnosticCode, DiagnosticSet, MssqlVersion, WriteBackend};
8
9/// Crate-local result type.
10pub type Result<T> = std::result::Result<T, Error>;
11
12/// Stable write-path phase vocabulary for classifying writer failures.
13///
14/// Phase names match the `phase` field emitted by crate-owned tracing
15/// instrumentation. Diagnostics describe what failed; phases describe where the
16/// failure happened.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum WritePhase {
20    /// Arrow-to-SQL Server schema planning.
21    SchemaPlanning,
22    /// Bulk writer initialization.
23    WriterInitialization,
24    /// SQL Server target metadata validation before writer startup completes.
25    TargetMetadataValidation,
26    /// Bulk writer batch write entry point.
27    BatchWrite,
28    /// Runtime record batch schema validation before value conversion.
29    BatchSchemaValidation,
30    /// Runtime Arrow value conversion to SQL Server cells.
31    ValueConversion,
32    /// Direct raw TDS encoding.
33    DirectEncoding,
34    /// Packet or row write to the Tiberius bulk load sink.
35    PacketWrite,
36    /// Bulk writer finish entry point.
37    Finish,
38    /// Tiberius bulk load finalization.
39    Finalize,
40}
41
42impl WritePhase {
43    /// Returns the stable tracing-compatible phase name.
44    pub const fn as_str(self) -> &'static str {
45        match self {
46            Self::SchemaPlanning => "schema_planning",
47            Self::WriterInitialization => "writer_initialization",
48            Self::TargetMetadataValidation => "target_metadata_validation",
49            Self::BatchWrite => "batch_write",
50            Self::BatchSchemaValidation => "batch_schema_validation",
51            Self::ValueConversion => "value_conversion",
52            Self::DirectEncoding => "direct_encoding",
53            Self::PacketWrite => "packet_write",
54            Self::Finish => "finish",
55            Self::Finalize => "finalize",
56        }
57    }
58}
59
60impl fmt::Display for WritePhase {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(self.as_str())
63    }
64}
65
66/// Safe, structured information for user-facing error reports.
67///
68/// This view keeps dependency source text out of default reports. Use
69/// [`std::error::Error::source`] only for trusted debug paths that apply their
70/// own redaction policy.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct ErrorInfo<'a> {
73    phase: Option<WritePhase>,
74    kind: &'static str,
75    summary: &'static str,
76    diagnostics: Option<&'a DiagnosticSet>,
77}
78
79impl<'a> ErrorInfo<'a> {
80    /// Returns the classified write phase when one is known.
81    pub const fn phase(&self) -> Option<WritePhase> {
82        self.phase
83    }
84
85    /// Returns the inner error kind after write phase context is removed.
86    pub const fn kind(&self) -> &'static str {
87        self.kind
88    }
89
90    /// Returns a sanitized, user-facing summary.
91    pub const fn summary(&self) -> &'static str {
92        self.summary
93    }
94
95    /// Returns structured diagnostics carried by this error, when present.
96    pub const fn diagnostics(&self) -> Option<&'a DiagnosticSet> {
97        self.diagnostics
98    }
99
100    /// Returns comma-separated diagnostic code names for compact reports.
101    pub fn diagnostic_codes(&self) -> String {
102        if let Some(diagnostics) = self.diagnostics {
103            diagnostic_codes(diagnostics)
104        } else if self.kind == "BackendUnavailable" {
105            format!("{:?}", DiagnosticCode::BackendUnavailable)
106        } else {
107            String::new()
108        }
109    }
110}
111
112/// Error type for `arrow-tiberius` operations.
113#[derive(Debug, Snafu)]
114#[non_exhaustive]
115pub enum Error {
116    /// A write-path operation failed in a known writer phase.
117    #[snafu(display("write phase {phase} failed"))]
118    WritePhaseContext {
119        /// Stable writer phase where the failure was observed.
120        phase: WritePhase,
121        /// Original error raised by the failing operation.
122        source: Box<Error>,
123    },
124
125    /// A SQL Server database compatibility level is not supported.
126    #[snafu(display("invalid compatibility level {level}"))]
127    InvalidCompatibilityLevel {
128        /// The unsupported compatibility level value.
129        level: u16,
130    },
131
132    /// A SQL Server database compatibility level is not supported by the
133    /// selected SQL Server engine version.
134    #[snafu(display(
135        "{version:?} does not support database compatibility level {compatibility_level}"
136    ))]
137    UnsupportedCompatibilityLevel {
138        /// SQL Server engine version.
139        version: MssqlVersion,
140        /// Unsupported compatibility level value for the selected version.
141        compatibility_level: u16,
142    },
143
144    /// A SQL Server identifier is invalid for the selected identifier policy.
145    #[snafu(display("invalid identifier: {reason}"))]
146    InvalidIdentifier {
147        /// Human-readable reason the identifier is invalid.
148        reason: String,
149    },
150
151    /// Arrow schema planning failed.
152    #[snafu(display("write planning failed with {} diagnostic(s)", diagnostics.len()))]
153    Planning {
154        /// Structured planning diagnostics.
155        diagnostics: DiagnosticSet,
156    },
157
158    /// Runtime value conversion failed.
159    #[snafu(display(
160        "value conversion failed with {} diagnostic(s)",
161        diagnostics.len()
162    ))]
163    ValueConversion {
164        /// Structured value conversion diagnostics.
165        diagnostics: DiagnosticSet,
166    },
167
168    /// Direct raw TDS encoding failed.
169    #[snafu(display(
170        "direct encoding failed with {} diagnostic(s)",
171        diagnostics.len()
172    ))]
173    DirectEncoding {
174        /// Structured direct encoding diagnostics.
175        diagnostics: DiagnosticSet,
176    },
177
178    /// A requested write backend is not available.
179    #[snafu(display("write backend {backend:?} is unavailable: {reason}"))]
180    BackendUnavailable {
181        /// Requested write backend.
182        backend: WriteBackend,
183        /// Human-readable reason the backend is unavailable.
184        reason: String,
185    },
186
187    /// A SQL Server ADO-style connection string is invalid.
188    #[snafu(display("SQL Server connection string is invalid"))]
189    InvalidConnectionString,
190
191    /// Opening the TCP connection to SQL Server failed.
192    #[snafu(display("TCP connection to SQL Server failed: {source}"))]
193    ConnectionTcpConnect {
194        /// Source I/O error returned by the TCP transport.
195        source: std::io::Error,
196    },
197
198    /// Tiberius failed while establishing the SQL Server client session.
199    #[snafu(display("SQL Server client setup failed: {source}"))]
200    ConnectionClientSetup {
201        /// Source error returned by Tiberius.
202        source: tiberius::error::Error,
203    },
204
205    /// SQL Server table-existence metadata query failed.
206    #[snafu(display("SQL Server table existence query failed: {source}"))]
207    TableExistsQuery {
208        /// Source error returned by Tiberius.
209        source: tiberius::error::Error,
210    },
211
212    /// SQL Server table-existence metadata query returned an unexpected shape.
213    #[snafu(display("SQL Server table existence query returned an unexpected result: {reason}"))]
214    TableExistsUnexpectedResult {
215        /// Human-readable reason the metadata result could not be interpreted.
216        reason: String,
217    },
218
219    /// SQL Server target row-count query failed.
220    #[snafu(display("SQL Server target row count query failed: {source}"))]
221    TargetRowCountQuery {
222        /// Source error returned by Tiberius.
223        source: tiberius::error::Error,
224    },
225
226    /// SQL Server target row-count query returned an unexpected shape.
227    #[snafu(display("SQL Server target row count query returned an unexpected result: {reason}"))]
228    TargetRowCountUnexpectedResult {
229        /// Human-readable reason the target row-count result could not be interpreted.
230        reason: String,
231    },
232
233    /// SQL Server lifecycle statement execution failed.
234    #[snafu(display("SQL Server statement execution failed: {source}"))]
235    SqlExecution {
236        /// Source error returned by Tiberius.
237        source: tiberius::error::Error,
238    },
239
240    /// Tiberius returned an error while executing a SQL Server operation.
241    #[snafu(display("tiberius operation failed: {source}"))]
242    Tiberius {
243        /// Source error returned by Tiberius.
244        source: tiberius::error::Error,
245    },
246}
247
248impl Error {
249    /// Attaches stable writer phase context to an error.
250    #[must_use]
251    pub fn with_write_phase(self, phase: WritePhase) -> Self {
252        Self::WritePhaseContext {
253            phase,
254            source: Box::new(self),
255        }
256    }
257
258    /// Returns the stable writer phase when the error can be classified.
259    pub const fn write_phase(&self) -> Option<WritePhase> {
260        match self {
261            Self::WritePhaseContext { phase, .. } => Some(*phase),
262            Self::Planning { .. } => Some(WritePhase::SchemaPlanning),
263            Self::ValueConversion { .. } => Some(WritePhase::ValueConversion),
264            Self::DirectEncoding { .. } => Some(WritePhase::DirectEncoding),
265            _ => None,
266        }
267    }
268
269    /// Returns the inner error without writer phase context.
270    pub fn without_write_phase(&self) -> &Self {
271        match self {
272            Self::WritePhaseContext { source, .. } => source.without_write_phase(),
273            _ => self,
274        }
275    }
276
277    /// Returns safe, structured information for user-facing reports.
278    pub fn safe_error_info(&self) -> ErrorInfo<'_> {
279        let inner = self.without_write_phase();
280        ErrorInfo {
281            phase: self.write_phase(),
282            kind: inner.kind(),
283            summary: inner.safe_summary(),
284            diagnostics: inner.diagnostics(),
285        }
286    }
287
288    fn kind(&self) -> &'static str {
289        match self {
290            Self::WritePhaseContext { source, .. } => source.kind(),
291            Self::InvalidCompatibilityLevel { .. } => "InvalidCompatibilityLevel",
292            Self::UnsupportedCompatibilityLevel { .. } => "UnsupportedCompatibilityLevel",
293            Self::InvalidIdentifier { .. } => "InvalidIdentifier",
294            Self::Planning { .. } => "Planning",
295            Self::ValueConversion { .. } => "ValueConversion",
296            Self::DirectEncoding { .. } => "DirectEncoding",
297            Self::BackendUnavailable { .. } => "BackendUnavailable",
298            Self::InvalidConnectionString => "InvalidConnectionString",
299            Self::ConnectionTcpConnect { .. } => "ConnectionTcpConnect",
300            Self::ConnectionClientSetup { .. } => "ConnectionClientSetup",
301            Self::TableExistsQuery { .. } => "TableExistsQuery",
302            Self::TableExistsUnexpectedResult { .. } => "TableExistsUnexpectedResult",
303            Self::TargetRowCountQuery { .. } => "TargetRowCountQuery",
304            Self::TargetRowCountUnexpectedResult { .. } => "TargetRowCountUnexpectedResult",
305            Self::SqlExecution { .. } => "SqlExecution",
306            Self::Tiberius { .. } => "Tiberius",
307        }
308    }
309
310    fn safe_summary(&self) -> &'static str {
311        match self {
312            Self::WritePhaseContext { source, .. } => source.safe_summary(),
313            Self::InvalidCompatibilityLevel { .. } => "invalid compatibility level",
314            Self::UnsupportedCompatibilityLevel { .. } => {
315                "compatibility level is not supported by SQL Server version"
316            }
317            Self::InvalidIdentifier { .. } => "invalid identifier",
318            Self::Planning { .. } => "planning failed with diagnostics",
319            Self::ValueConversion { .. } => "value conversion failed with diagnostics",
320            Self::DirectEncoding { .. } => "direct encoding failed with diagnostics",
321            Self::BackendUnavailable { .. } => "write backend unavailable",
322            Self::InvalidConnectionString => "invalid connection string",
323            Self::ConnectionTcpConnect { .. } => "TCP connection failed",
324            Self::ConnectionClientSetup { .. } => "SQL Server client setup failed",
325            Self::TableExistsQuery { .. } => "table existence query failed",
326            Self::TableExistsUnexpectedResult { .. } => {
327                "table existence query returned unexpected result"
328            }
329            Self::TargetRowCountQuery { .. } => "target row count query failed",
330            Self::TargetRowCountUnexpectedResult { .. } => {
331                "target row count query returned unexpected result"
332            }
333            Self::SqlExecution { .. } => "SQL statement execution failed",
334            Self::Tiberius { .. } => "tiberius operation failed",
335        }
336    }
337
338    fn diagnostics(&self) -> Option<&DiagnosticSet> {
339        match self {
340            Self::WritePhaseContext { source, .. } => source.diagnostics(),
341            Self::Planning { diagnostics }
342            | Self::ValueConversion { diagnostics }
343            | Self::DirectEncoding { diagnostics } => Some(diagnostics),
344            _ => None,
345        }
346    }
347}
348
349fn diagnostic_codes(diagnostics: &DiagnosticSet) -> String {
350    let mut codes = String::new();
351    for diagnostic in diagnostics.all() {
352        if !codes.is_empty() {
353            codes.push(',');
354        }
355        let _ = write!(codes, "{:?}", diagnostic.code());
356    }
357    codes
358}
359
360#[cfg(test)]
361mod tests {
362    use std::{borrow::Cow, error::Error as StdError};
363
364    use crate::{
365        Diagnostic, DiagnosticCode, DiagnosticSet, Error, WritePhase,
366        observability::{
367            BATCH_SCHEMA_VALIDATION_PHASE, BATCH_WRITE_PHASE, DIRECT_ENCODING_PHASE,
368            FINALIZE_PHASE, FINISH_PHASE, PACKET_WRITE_PHASE, SCHEMA_PLANNING_PHASE,
369            TARGET_METADATA_VALIDATION_PHASE, VALUE_CONVERSION_PHASE, WRITER_INITIALIZATION_PHASE,
370        },
371    };
372
373    #[test]
374    fn planning_error_display_includes_diagnostic_count() {
375        let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
376            DiagnosticCode::UnsupportedArrowType,
377            "unsupported",
378        )]);
379        let err = Error::Planning { diagnostics };
380
381        assert_eq!(
382            err.to_string(),
383            "write planning failed with 1 diagnostic(s)"
384        );
385    }
386
387    #[test]
388    fn value_conversion_error_display_includes_diagnostic_count() {
389        let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
390            DiagnosticCode::ValueConversionUnsupported,
391            "unsupported conversion",
392        )]);
393        let err = Error::ValueConversion { diagnostics };
394
395        assert_eq!(
396            err.to_string(),
397            "value conversion failed with 1 diagnostic(s)"
398        );
399    }
400
401    #[test]
402    fn direct_encoding_error_display_includes_diagnostic_count() {
403        let diagnostics = DiagnosticSet::from(vec![Diagnostic::error(
404            DiagnosticCode::DirectEncodingInvalidPayload,
405            "invalid payload",
406        )]);
407        let err = Error::DirectEncoding { diagnostics };
408
409        assert_eq!(
410            err.to_string(),
411            "direct encoding failed with 1 diagnostic(s)"
412        );
413    }
414
415    #[test]
416    fn write_phase_names_match_tracing_vocabulary() {
417        assert_eq!(WritePhase::SchemaPlanning.as_str(), SCHEMA_PLANNING_PHASE);
418        assert_eq!(
419            WritePhase::WriterInitialization.as_str(),
420            WRITER_INITIALIZATION_PHASE
421        );
422        assert_eq!(
423            WritePhase::TargetMetadataValidation.as_str(),
424            TARGET_METADATA_VALIDATION_PHASE
425        );
426        assert_eq!(WritePhase::BatchWrite.as_str(), BATCH_WRITE_PHASE);
427        assert_eq!(
428            WritePhase::BatchSchemaValidation.as_str(),
429            BATCH_SCHEMA_VALIDATION_PHASE
430        );
431        assert_eq!(WritePhase::ValueConversion.as_str(), VALUE_CONVERSION_PHASE);
432        assert_eq!(WritePhase::DirectEncoding.as_str(), DIRECT_ENCODING_PHASE);
433        assert_eq!(WritePhase::PacketWrite.as_str(), PACKET_WRITE_PHASE);
434        assert_eq!(WritePhase::Finish.as_str(), FINISH_PHASE);
435        assert_eq!(WritePhase::Finalize.as_str(), FINALIZE_PHASE);
436    }
437
438    #[test]
439    fn write_phase_context_classifies_without_display_parsing() {
440        let err = Error::Tiberius {
441            source: tiberius::error::Error::BulkInput(Cow::Borrowed("packet failed")),
442        }
443        .with_write_phase(WritePhase::PacketWrite);
444
445        assert_eq!(err.write_phase(), Some(WritePhase::PacketWrite));
446        assert_eq!(err.to_string(), "write phase packet_write failed");
447        assert!(matches!(err.without_write_phase(), Error::Tiberius { .. }));
448    }
449
450    #[test]
451    fn write_phase_context_display_does_not_include_source_text() {
452        let err = Error::Tiberius {
453            source: tiberius::error::Error::BulkInput(Cow::Borrowed(
454                "server=tcp:sql.example.com;User ID=sa;password=secret",
455            )),
456        }
457        .with_write_phase(WritePhase::Finalize);
458
459        assert_eq!(err.to_string(), "write phase finalize failed");
460        assert!(!err.to_string().contains("password=secret"));
461        assert!(!err.to_string().contains("User ID=sa"));
462        assert!(!err.to_string().contains("sql.example.com"));
463    }
464
465    #[test]
466    fn safe_error_info_exposes_finalize_tiberius_cause_without_source_text() {
467        let err = Error::Tiberius {
468            source: tiberius::error::Error::BulkInput(Cow::Borrowed(
469                "server=tcp:sql.example.com;User ID=sa;password=secret",
470            )),
471        }
472        .with_write_phase(WritePhase::Finalize);
473
474        let info = err.safe_error_info();
475
476        assert_eq!(info.phase(), Some(WritePhase::Finalize));
477        assert_eq!(info.kind(), "Tiberius");
478        assert_eq!(info.summary(), "tiberius operation failed");
479        assert!(info.diagnostics().is_none());
480        assert_eq!(info.diagnostic_codes(), "");
481        assert!(!info.summary().contains("password=secret"));
482        assert!(!info.summary().contains("User ID=sa"));
483        assert!(!info.summary().contains("sql.example.com"));
484    }
485
486    #[test]
487    fn safe_error_info_exposes_nested_diagnostics() {
488        let err = Error::DirectEncoding {
489            diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
490                DiagnosticCode::DirectEncodingInvalidPayload,
491                "invalid payload",
492            )]),
493        }
494        .with_write_phase(WritePhase::WriterInitialization);
495
496        let info = err.safe_error_info();
497
498        assert_eq!(info.phase(), Some(WritePhase::WriterInitialization));
499        assert_eq!(info.kind(), "DirectEncoding");
500        assert_eq!(info.summary(), "direct encoding failed with diagnostics");
501        assert_eq!(info.diagnostic_codes(), "DirectEncodingInvalidPayload");
502        assert_eq!(
503            info.diagnostics()
504                .and_then(|diagnostics| diagnostics.all().first())
505                .map(Diagnostic::code),
506            Some(DiagnosticCode::DirectEncodingInvalidPayload)
507        );
508    }
509
510    #[test]
511    fn unwrapped_diagnostic_errors_keep_default_phase_classification() {
512        let planning = Error::Planning {
513            diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
514                DiagnosticCode::UnsupportedArrowType,
515                "unsupported",
516            )]),
517        };
518        let value_conversion = Error::ValueConversion {
519            diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
520                DiagnosticCode::NonFiniteFloat,
521                "not finite",
522            )]),
523        };
524        let direct_encoding = Error::DirectEncoding {
525            diagnostics: DiagnosticSet::from(vec![Diagnostic::error(
526                DiagnosticCode::DirectEncodingInvalidPayload,
527                "invalid",
528            )]),
529        };
530
531        assert_eq!(planning.write_phase(), Some(WritePhase::SchemaPlanning));
532        assert_eq!(
533            value_conversion.write_phase(),
534            Some(WritePhase::ValueConversion)
535        );
536        assert_eq!(
537            direct_encoding.write_phase(),
538            Some(WritePhase::DirectEncoding)
539        );
540    }
541
542    #[test]
543    fn backend_unavailable_error_display_includes_backend() {
544        let err = Error::BackendUnavailable {
545            backend: crate::WriteBackend::DirectRawBulk,
546            reason: "not implemented".to_owned(),
547        };
548
549        assert_eq!(
550            err.to_string(),
551            "write backend DirectRawBulk is unavailable: not implemented"
552        );
553    }
554
555    #[test]
556    fn invalid_connection_string_error_display_is_redacted() {
557        let err = Error::InvalidConnectionString;
558
559        assert_eq!(err.to_string(), "SQL Server connection string is invalid");
560    }
561
562    #[test]
563    fn tiberius_error_display_includes_source_error() {
564        let err = Error::Tiberius {
565            source: tiberius::error::Error::Protocol(Cow::Borrowed("invalid token")),
566        };
567
568        assert_eq!(
569            err.to_string(),
570            "tiberius operation failed: Protocol error: invalid token"
571        );
572    }
573
574    #[test]
575    fn tiberius_error_preserves_source_error() {
576        let err = Error::Tiberius {
577            source: tiberius::error::Error::BulkInput(Cow::Borrowed("row payload is malformed")),
578        };
579
580        let source = StdError::source(&err).expect("tiberius source should be preserved");
581        assert_eq!(
582            source.to_string(),
583            "BULK UPLOAD input failure: row payload is malformed"
584        );
585    }
586}