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