Skip to main content

mssql_client/
error.rs

1//! Client error types.
2//!
3//! All fallible operations return [`Error`](enum@Error). Beyond matching specific variants,
4//! the key question for resilience is whether an error is worth retrying.
5//!
6//! ## Transient vs terminal
7//!
8//! [`Error::is_transient`] reports whether an error is likely to succeed on
9//! retry (timeouts, connection drops, transient server conditions);
10//! [`Error::is_terminal`] reports the opposite (syntax errors, constraint
11//! violations, authentication failures). Retry only transient errors — retrying
12//! a terminal one just repeats the failure.
13//!
14//! ```rust,no_run
15//! # use mssql_client::Error;
16//! fn should_retry(err: &Error) -> bool {
17//!     err.is_transient()
18//! }
19//! ```
20//!
21//! Transient SQL Server conditions include deadlock victim (1205), Azure
22//! throttling (10928/10929) and service errors (40197/40501/40613), and
23//! failover (4060). Terminal server errors include syntax (102), invalid
24//! object/column (208/207), and constraint/unique violations (547/2627/2601).
25//! For server errors, [`Error::class`] exposes the TDS severity (classes 11-16
26//! are user errors; 17+ indicate resource or system problems).
27//!
28//! ## Retrying
29//!
30//! Retry transient errors with capped exponential backoff plus jitter and a
31//! small attempt limit (3-5). The pool and the configured `RetryPolicy` handle
32//! common connection-level cases automatically; apply application-level retries
33//! around whole logical operations, and only when the work is idempotent or
34//! transaction-wrapped.
35
36use std::sync::Arc;
37
38use thiserror::Error;
39
40/// Errors that can occur during client operations.
41#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum Error {
44    /// Connection failed.
45    #[error("connection failed: {0}")]
46    Connection(String),
47
48    /// Connection closed unexpectedly.
49    #[error("connection closed")]
50    ConnectionClosed,
51
52    /// Authentication failed.
53    #[error("authentication failed: {0}")]
54    Authentication(#[from] mssql_auth::AuthError),
55
56    /// TLS error.
57    #[cfg(feature = "tls")]
58    #[error("TLS error: {0}")]
59    Tls(#[from] mssql_tls::TlsError),
60
61    /// TLS error (when TLS feature is disabled, stores the message).
62    #[cfg(not(feature = "tls"))]
63    #[error("TLS error: {0}")]
64    Tls(String),
65
66    /// Protocol error from the TDS layer (preserves the source error chain).
67    #[error("protocol error: {0}")]
68    ProtocolError(#[from] tds_protocol::ProtocolError),
69
70    /// Protocol violation with a descriptive message.
71    #[error("protocol error: {0}")]
72    Protocol(String),
73
74    /// Codec error.
75    #[error("codec error: {0}")]
76    Codec(#[from] mssql_codec::CodecError),
77
78    /// Type conversion error.
79    #[error("type error: {0}")]
80    Type(#[from] mssql_types::TypeError),
81
82    /// Query execution error.
83    #[error("query error: {0}")]
84    Query(String),
85
86    /// Server returned an error.
87    #[error("server error {number} (severity {class}, state {state}): {message}{}", format_server_location(.server, .procedure, .line))]
88    Server {
89        /// Error number.
90        number: i32,
91        /// Error class/severity (0-25).
92        class: u8,
93        /// Error state.
94        state: u8,
95        /// Error message.
96        message: String,
97        /// Server name where error occurred.
98        server: Option<String>,
99        /// Stored procedure name (if applicable).
100        procedure: Option<String>,
101        /// Line number in the SQL batch or procedure.
102        line: u32,
103    },
104
105    /// Configuration error.
106    #[error("configuration error: {0}")]
107    Config(String),
108
109    /// TCP connection timeout occurred.
110    #[error("TCP connection timed out connecting to {host}:{port}")]
111    ConnectTimeout {
112        /// Target host.
113        host: String,
114        /// Target port.
115        port: u16,
116    },
117
118    /// TLS handshake timeout occurred.
119    #[error("TLS handshake timed out with {host}:{port}")]
120    TlsTimeout {
121        /// Target host.
122        host: String,
123        /// Target port.
124        port: u16,
125    },
126
127    /// Login/authentication response timeout occurred.
128    #[error("login timed out for {host}:{port}")]
129    LoginTimeout {
130        /// Target host.
131        host: String,
132        /// Target port.
133        port: u16,
134    },
135
136    /// Command execution timeout occurred.
137    #[error("command timed out")]
138    CommandTimeout,
139
140    /// Connection routing required (Azure SQL).
141    #[error("routing required to {host}:{port}")]
142    Routing {
143        /// Target host.
144        host: String,
145        /// Target port.
146        port: u16,
147    },
148
149    /// Too many redirects during connection.
150    #[error("too many redirects (max {max})")]
151    TooManyRedirects {
152        /// Maximum redirects allowed.
153        max: u8,
154    },
155
156    /// IO error (wrapped in Arc for Clone support).
157    #[error("IO error: {0}")]
158    Io(#[source] SharedIoError),
159
160    /// Invalid identifier (potential SQL injection attempt).
161    #[error("invalid identifier: {0}")]
162    InvalidIdentifier(String),
163
164    /// Connection pool exhausted.
165    #[error("connection pool exhausted")]
166    PoolExhausted,
167
168    /// Query cancellation error.
169    #[error("query cancellation failed: {0}")]
170    Cancel(String),
171
172    /// Query was cancelled by user request.
173    #[error("query cancelled")]
174    Cancelled,
175
176    /// SQL Browser service instance resolution failed.
177    #[error("SQL Browser resolution failed for instance '{instance}': {reason}")]
178    BrowserResolution {
179        /// The instance name that was being resolved.
180        instance: String,
181        /// Description of what went wrong.
182        reason: String,
183    },
184
185    /// FILESTREAM operation failed.
186    ///
187    /// This error occurs when opening or accessing a FILESTREAM BLOB fails,
188    /// typically due to a missing driver DLL, invalid path, or permission issue.
189    #[cfg(all(windows, feature = "filestream"))]
190    #[error("FILESTREAM error: {0}")]
191    FileStream(String),
192
193    /// Always Encrypted operation failed.
194    ///
195    /// This error occurs during CEK decryption, column value decryption, or
196    /// parameter encryption. Key material is never included in the error message.
197    #[cfg(feature = "always-encrypted")]
198    #[error("encryption error: {0}")]
199    Encryption(String),
200}
201
202// Note: From<mssql_tls::TlsError> and From<tds_protocol::ProtocolError> are
203// derived via #[from] on the enum variants above, preserving the full error chain.
204
205/// A cloneable wrapper around `std::io::Error` that preserves the error source chain.
206///
207/// `Arc<io::Error>` does not implement `std::error::Error`, which breaks
208/// `source()` chain traversal used by libraries like `anyhow` and `eyre`.
209/// This newtype bridges the gap.
210#[derive(Debug, Clone)]
211pub struct SharedIoError(Arc<std::io::Error>);
212
213impl std::fmt::Display for SharedIoError {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        self.0.fmt(f)
216    }
217}
218
219impl std::error::Error for SharedIoError {
220    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
221        self.0.source()
222    }
223}
224
225impl From<std::io::Error> for Error {
226    fn from(e: std::io::Error) -> Self {
227        Error::Io(SharedIoError(Arc::new(e)))
228    }
229}
230
231#[cfg(feature = "always-encrypted")]
232impl From<mssql_auth::EncryptionError> for Error {
233    fn from(e: mssql_auth::EncryptionError) -> Self {
234        // SECURITY: Do NOT include key material in the error message.
235        // EncryptionError::Display does not log keys, but we convert to
236        // String to ensure no internal state leaks.
237        Error::Encryption(e.to_string())
238    }
239}
240
241impl Error {
242    /// Check if this error is transient and may succeed on retry.
243    ///
244    /// Transient errors include timeouts, connection issues, and
245    /// certain server errors that may resolve themselves.
246    ///
247    /// Per ADR-009, the following server error codes are considered transient:
248    /// - 1205: Deadlock victim
249    /// - -2: Timeout
250    /// - 10928, 10929: Resource limit (Azure)
251    /// - 40197: Service error (Azure)
252    /// - 40501: Service busy (Azure)
253    /// - 40613: Database unavailable (Azure)
254    /// - 49918, 49919, 49920: Cannot process request (Azure)
255    /// - 4060: Cannot open database (may be transient during failover)
256    /// - 18456: Login failed (may be transient in Azure during failover)
257    #[must_use]
258    pub fn is_transient(&self) -> bool {
259        match self {
260            Self::ConnectTimeout { .. }
261            | Self::TlsTimeout { .. }
262            | Self::LoginTimeout { .. }
263            | Self::CommandTimeout
264            | Self::ConnectionClosed
265            | Self::Connection(_)
266            | Self::Routing { .. }
267            | Self::PoolExhausted
268            | Self::Io(_) => true,
269            Self::Server { number, .. } => Self::is_transient_server_error(*number),
270            _ => false,
271        }
272    }
273
274    /// Check if a server error number is transient (may succeed on retry).
275    ///
276    /// This follows the error codes specified in ADR-009.
277    ///
278    /// # Extending with custom error codes
279    ///
280    /// Applications with domain-specific transient error codes can compose
281    /// this method with their own logic:
282    ///
283    /// ```rust
284    /// use mssql_client::Error;
285    ///
286    /// fn is_transient_for_my_app(err: &Error) -> bool {
287    ///     // Check built-in transient codes first
288    ///     if err.is_transient() {
289    ///         return true;
290    ///     }
291    ///     // Add application-specific transient server errors
292    ///     if let Error::Server { number, .. } = err {
293    ///         matches!(number, 50001 | 50002) // custom app error codes
294    ///     } else {
295    ///         false
296    ///     }
297    /// }
298    /// ```
299    #[must_use]
300    pub fn is_transient_server_error(number: i32) -> bool {
301        matches!(
302            number,
303            1205 |      // Deadlock victim
304            -2 |        // Timeout
305            10928 |     // Resource limit (Azure)
306            10929 |     // Resource limit (Azure)
307            40197 |     // Service error (Azure)
308            40501 |     // Service busy (Azure)
309            40613 |     // Database unavailable (Azure)
310            49918 |     // Cannot process request (Azure)
311            49919 |     // Cannot process create/update (Azure)
312            49920 |     // Cannot process request (Azure)
313            4060 |      // Cannot open database
314            18456 // Login failed (may be transient in Azure)
315        )
316    }
317
318    /// Check if this is a terminal error that will never succeed on retry.
319    ///
320    /// Terminal errors include syntax errors, constraint violations, and
321    /// other errors that indicate programmer error or data issues.
322    ///
323    /// Per ADR-009, the following server error codes are terminal:
324    /// - 102: Syntax error
325    /// - 207: Invalid column
326    /// - 208: Invalid object
327    /// - 547: Constraint violation
328    /// - 2627: Unique constraint violation
329    /// - 2601: Duplicate key
330    #[must_use]
331    pub fn is_terminal(&self) -> bool {
332        match self {
333            Self::Config(_)
334            | Self::InvalidIdentifier(_)
335            | Self::Protocol(_)
336            | Self::ProtocolError(_)
337            | Self::Tls(_)
338            | Self::Authentication(_)
339            | Self::Cancel(_) => true,
340            Self::Server { number, .. } => Self::is_terminal_server_error(*number),
341            _ => false,
342        }
343    }
344
345    /// Check if a server error number is terminal (will never succeed on retry).
346    ///
347    /// This follows the error codes specified in ADR-009.
348    #[must_use]
349    pub fn is_terminal_server_error(number: i32) -> bool {
350        matches!(
351            number,
352            102 |       // Syntax error
353            207 |       // Invalid column
354            208 |       // Invalid object
355            547 |       // Constraint violation
356            2627 |      // Unique constraint violation
357            2601 // Duplicate key
358        )
359    }
360
361    /// Check if this error indicates a protocol/driver bug.
362    ///
363    /// Protocol errors typically indicate a bug in the driver implementation
364    /// rather than a user error or server issue. These are always terminal.
365    #[must_use]
366    pub fn is_protocol_error(&self) -> bool {
367        matches!(self, Self::Protocol(_) | Self::ProtocolError(_))
368    }
369
370    /// Check if this is a TLS/encryption error.
371    ///
372    /// TLS errors indicate certificate, handshake, or encryption failures.
373    /// These are terminal — TLS timeouts are reported as [`Error::TlsTimeout`] instead.
374    #[must_use]
375    pub fn is_tls_error(&self) -> bool {
376        matches!(self, Self::Tls(_) | Self::TlsTimeout { .. })
377    }
378
379    /// Check if this is an authentication error.
380    #[must_use]
381    pub fn is_authentication_error(&self) -> bool {
382        matches!(self, Self::Authentication(_))
383    }
384
385    /// Check if this is a configuration error.
386    ///
387    /// Configuration errors are always terminal — they indicate invalid
388    /// settings that cannot be resolved by retrying.
389    #[must_use]
390    pub fn is_config_error(&self) -> bool {
391        matches!(self, Self::Config(_))
392    }
393
394    /// Check if this is a server error with a specific number.
395    #[must_use]
396    pub fn is_server_error(&self, number: i32) -> bool {
397        matches!(self, Self::Server { number: n, .. } if *n == number)
398    }
399
400    /// Get the error class/severity if this is a server error.
401    ///
402    /// SQL Server error classes range from 0-25:
403    /// - 0-10: Informational
404    /// - 11-16: User errors
405    /// - 17-19: Resource/hardware errors
406    /// - 20-25: System errors (connection terminating)
407    #[must_use]
408    pub fn class(&self) -> Option<u8> {
409        match self {
410            Self::Server { class, .. } => Some(*class),
411            _ => None,
412        }
413    }
414
415    /// Alias for `class()` - returns error severity.
416    #[must_use]
417    pub fn severity(&self) -> Option<u8> {
418        self.class()
419    }
420}
421
422/// Format the server/procedure/line suffix for server error Display.
423fn format_server_location(
424    server: &Option<String>,
425    procedure: &Option<String>,
426    line: &u32,
427) -> String {
428    let mut parts = Vec::new();
429    if let Some(srv) = server {
430        if !srv.is_empty() {
431            parts.push(format!("server: {srv}"));
432        }
433    }
434    if let Some(proc) = procedure {
435        if !proc.is_empty() {
436            parts.push(format!("procedure: {proc}"));
437        }
438    }
439    if *line > 0 {
440        parts.push(format!("line: {line}"));
441    }
442    if parts.is_empty() {
443        String::new()
444    } else {
445        format!(" [{}]", parts.join(", "))
446    }
447}
448
449/// Result type for client operations.
450pub type Result<T> = std::result::Result<T, Error>;
451
452#[cfg(test)]
453#[allow(clippy::unwrap_used)]
454mod tests {
455    use super::*;
456    use std::sync::Arc;
457
458    fn make_server_error(number: i32) -> Error {
459        Error::Server {
460            number,
461            class: 16,
462            state: 1,
463            message: "Test error".to_string(),
464            server: None,
465            procedure: None,
466            line: 1,
467        }
468    }
469
470    #[test]
471    fn test_is_transient_connection_errors() {
472        assert!(
473            Error::ConnectTimeout {
474                host: "test".into(),
475                port: 1433
476            }
477            .is_transient()
478        );
479        assert!(
480            Error::TlsTimeout {
481                host: "test".into(),
482                port: 1433
483            }
484            .is_transient()
485        );
486        assert!(
487            Error::LoginTimeout {
488                host: "test".into(),
489                port: 1433
490            }
491            .is_transient()
492        );
493        assert!(Error::CommandTimeout.is_transient());
494        assert!(Error::ConnectionClosed.is_transient());
495        assert!(Error::PoolExhausted.is_transient());
496        assert!(
497            Error::Routing {
498                host: "test".into(),
499                port: 1433,
500            }
501            .is_transient()
502        );
503    }
504
505    #[test]
506    fn test_is_transient_io_error() {
507        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
508        assert!(Error::Io(SharedIoError(Arc::new(io_err))).is_transient());
509    }
510
511    #[test]
512    fn test_is_transient_server_errors_deadlock() {
513        // 1205 - Deadlock victim
514        assert!(make_server_error(1205).is_transient());
515    }
516
517    #[test]
518    fn test_is_transient_server_errors_timeout() {
519        // -2 - Timeout
520        assert!(make_server_error(-2).is_transient());
521    }
522
523    #[test]
524    fn test_is_transient_server_errors_azure() {
525        // Azure-specific transient errors
526        assert!(make_server_error(10928).is_transient()); // Resource limit
527        assert!(make_server_error(10929).is_transient()); // Resource limit
528        assert!(make_server_error(40197).is_transient()); // Service error
529        assert!(make_server_error(40501).is_transient()); // Service busy
530        assert!(make_server_error(40613).is_transient()); // Database unavailable
531        assert!(make_server_error(49918).is_transient()); // Cannot process request
532        assert!(make_server_error(49919).is_transient()); // Cannot process create/update
533        assert!(make_server_error(49920).is_transient()); // Cannot process request
534    }
535
536    #[test]
537    fn test_is_transient_server_errors_other() {
538        // Other transient errors
539        assert!(make_server_error(4060).is_transient()); // Cannot open database
540        assert!(make_server_error(18456).is_transient()); // Login failed (Azure failover)
541    }
542
543    #[test]
544    fn test_is_not_transient() {
545        // Non-transient errors
546        assert!(!Error::Config("bad config".into()).is_transient());
547        assert!(!Error::Query("syntax error".into()).is_transient());
548        assert!(!Error::InvalidIdentifier("bad id".into()).is_transient());
549        assert!(!make_server_error(102).is_transient()); // Syntax error
550    }
551
552    #[test]
553    fn test_is_terminal_server_errors() {
554        // Terminal SQL errors per ADR-009
555        assert!(make_server_error(102).is_terminal()); // Syntax error
556        assert!(make_server_error(207).is_terminal()); // Invalid column
557        assert!(make_server_error(208).is_terminal()); // Invalid object
558        assert!(make_server_error(547).is_terminal()); // Constraint violation
559        assert!(make_server_error(2627).is_terminal()); // Unique constraint violation
560        assert!(make_server_error(2601).is_terminal()); // Duplicate key
561    }
562
563    #[test]
564    fn test_is_terminal_config_errors() {
565        assert!(Error::Config("bad config".into()).is_terminal());
566        assert!(Error::InvalidIdentifier("bad id".into()).is_terminal());
567    }
568
569    #[test]
570    fn test_is_not_terminal() {
571        // Non-terminal errors (may be transient or other)
572        assert!(
573            !Error::ConnectTimeout {
574                host: "test".into(),
575                port: 1433
576            }
577            .is_terminal()
578        );
579        assert!(!make_server_error(1205).is_terminal()); // Deadlock - transient, not terminal
580        assert!(!make_server_error(40501).is_terminal()); // Service busy - transient
581    }
582
583    #[test]
584    fn test_transient_server_error_static() {
585        // Test the static helper function
586        assert!(Error::is_transient_server_error(1205));
587        assert!(Error::is_transient_server_error(40501));
588        assert!(!Error::is_transient_server_error(102));
589    }
590
591    #[test]
592    fn test_terminal_server_error_static() {
593        // Test the static helper function
594        assert!(Error::is_terminal_server_error(102));
595        assert!(Error::is_terminal_server_error(2627));
596        assert!(!Error::is_terminal_server_error(1205));
597    }
598
599    #[test]
600    fn test_error_class() {
601        let err = make_server_error(102);
602        assert_eq!(err.class(), Some(16));
603        assert_eq!(err.severity(), Some(16));
604
605        assert_eq!(
606            Error::ConnectTimeout {
607                host: "test".into(),
608                port: 1433
609            }
610            .class(),
611            None
612        );
613    }
614
615    #[test]
616    fn test_is_server_error() {
617        let err = make_server_error(102);
618        assert!(err.is_server_error(102));
619        assert!(!err.is_server_error(103));
620
621        assert!(
622            !Error::ConnectTimeout {
623                host: "test".into(),
624                port: 1433
625            }
626            .is_server_error(102)
627        );
628    }
629}