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