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}