Skip to main content

aptos_sdk/
error.rs

1//! Error types for the Aptos SDK.
2//!
3//! This module provides a unified error type [`AptosError`] that encompasses
4//! all possible errors that can occur when using the SDK.
5
6use std::fmt;
7use thiserror::Error;
8
9/// A specialized Result type for Aptos SDK operations.
10pub type AptosResult<T> = Result<T, AptosError>;
11
12/// The main error type for the Aptos SDK.
13///
14/// This enum covers all possible error conditions that can occur when
15/// interacting with the Aptos blockchain through this SDK.
16///
17/// # Security: Logging Errors
18///
19/// **WARNING:** The `Display` implementation on this type may include sensitive
20/// information (e.g., partial key material, JWT tokens, or mnemonic phrases) in
21/// its output. When logging errors, always use [`sanitized_message()`](AptosError::sanitized_message)
22/// instead of `to_string()` or `Display`:
23///
24/// ```rust,ignore
25/// // WRONG - may leak sensitive data:
26/// log::error!("Failed: {}", err);
27///
28/// // CORRECT - redacts sensitive patterns:
29/// log::error!("Failed: {}", err.sanitized_message());
30/// ```
31#[derive(Error, Debug)]
32pub enum AptosError {
33    /// Error occurred during HTTP communication
34    #[error("HTTP error: {0}")]
35    Http(#[from] reqwest::Error),
36
37    /// Error occurred during JSON serialization/deserialization
38    #[error("JSON error: {0}")]
39    Json(#[from] serde_json::Error),
40
41    /// Error occurred during BCS serialization/deserialization
42    #[error("BCS error: {0}")]
43    Bcs(String),
44
45    /// Error occurred during URL parsing
46    #[error("URL error: {0}")]
47    Url(#[from] url::ParseError),
48
49    /// Error occurred during hex encoding/decoding
50    #[error("Hex error: {0}")]
51    Hex(#[from] const_hex::FromHexError),
52
53    /// Invalid account address
54    #[error("Invalid address: {0}")]
55    InvalidAddress(String),
56
57    /// Invalid public key
58    #[error("Invalid public key: {0}")]
59    InvalidPublicKey(String),
60
61    /// Invalid private key
62    #[error("Invalid private key: {0}")]
63    InvalidPrivateKey(String),
64
65    /// Invalid signature
66    #[error("Invalid signature: {0}")]
67    InvalidSignature(String),
68
69    /// Signature verification failed
70    #[error("Signature verification failed")]
71    SignatureVerificationFailed,
72
73    /// Invalid type tag format
74    #[error("Invalid type tag: {0}")]
75    InvalidTypeTag(String),
76
77    /// Transaction building error
78    #[error("Transaction error: {0}")]
79    Transaction(String),
80
81    /// Transaction simulation failed
82    #[error("Simulation failed: {0}")]
83    SimulationFailed(String),
84
85    /// Transaction submission failed
86    #[error("Submission failed: {0}")]
87    SubmissionFailed(String),
88
89    /// Transaction execution failed on chain
90    #[error("Execution failed: {vm_status}")]
91    ExecutionFailed {
92        /// The VM status message explaining the failure
93        vm_status: String,
94    },
95
96    /// Transaction timed out waiting for confirmation
97    #[error("Transaction timed out after {timeout_secs} seconds")]
98    TransactionTimeout {
99        /// The hash of the transaction that timed out
100        hash: String,
101        /// How long we waited before timing out
102        timeout_secs: u64,
103    },
104
105    /// API returned an error response
106    #[error("API error ({status_code}): {message}")]
107    Api {
108        /// HTTP status code
109        status_code: u16,
110        /// Error message from the API
111        message: String,
112        /// Optional error code from the API
113        error_code: Option<String>,
114        /// Optional VM error code
115        vm_error_code: Option<u64>,
116    },
117
118    /// Rate limited by the API
119    #[error("Rate limited: retry after {retry_after_secs:?} seconds")]
120    RateLimited {
121        /// How long to wait before retrying (if provided)
122        retry_after_secs: Option<u64>,
123    },
124
125    /// Resource not found
126    #[error("Resource not found: {0}")]
127    NotFound(String),
128
129    /// Account not found
130    #[error("Account not found: {0}")]
131    AccountNotFound(String),
132
133    /// Invalid mnemonic phrase
134    #[error("Invalid mnemonic: {0}")]
135    InvalidMnemonic(String),
136
137    /// Invalid JWT
138    #[error("Invalid JWT: {0}")]
139    InvalidJwt(String),
140
141    /// Key derivation error
142    #[error("Key derivation error: {0}")]
143    KeyDerivation(String),
144
145    /// Insufficient signatures for multi-signature operation
146    #[error("Insufficient signatures: need {required}, got {provided}")]
147    InsufficientSignatures {
148        /// Number of signatures required
149        required: usize,
150        /// Number of signatures provided
151        provided: usize,
152    },
153
154    /// Feature not enabled
155    #[error("Feature not enabled: {0}. Enable the '{0}' feature in Cargo.toml")]
156    FeatureNotEnabled(String),
157
158    /// Configuration error
159    #[error("Configuration error: {0}")]
160    Config(String),
161
162    /// Internal SDK error (should not happen)
163    #[error("Internal error: {0}")]
164    Internal(String),
165
166    /// Any other error
167    #[error("{0}")]
168    Other(#[from] anyhow::Error),
169}
170
171/// Maximum length for error messages to prevent excessive memory usage in logs.
172const MAX_ERROR_MESSAGE_LENGTH: usize = 1000;
173
174/// Patterns that might indicate sensitive information in error messages.
175///
176/// # Security
177///
178/// This list is used by [`AptosError::sanitized_message()`] to redact potentially
179/// sensitive content from error messages before logging. The check is case-insensitive.
180const SENSITIVE_PATTERNS: &[&str] = &[
181    "private_key",
182    "secret",
183    "password",
184    "mnemonic",
185    "seed",
186    "bearer",
187    "authorization",
188    "token",
189    "jwt",
190    "credential",
191    "api_key",
192    "apikey",
193    "access_token",
194    "refresh_token",
195    "pepper",
196];
197
198impl AptosError {
199    /// Creates a new BCS error
200    pub fn bcs<E: fmt::Display>(err: E) -> Self {
201        Self::Bcs(err.to_string())
202    }
203
204    /// Creates a new transaction error
205    pub fn transaction<S: Into<String>>(msg: S) -> Self {
206        Self::Transaction(msg.into())
207    }
208
209    /// Creates a new API error from response details
210    pub fn api(status_code: u16, message: impl Into<String>) -> Self {
211        Self::Api {
212            status_code,
213            message: message.into(),
214            error_code: None,
215            vm_error_code: None,
216        }
217    }
218
219    /// Creates a new API error with additional details
220    pub fn api_with_details(
221        status_code: u16,
222        message: impl Into<String>,
223        error_code: Option<String>,
224        vm_error_code: Option<u64>,
225    ) -> Self {
226        Self::Api {
227            status_code,
228            message: message.into(),
229            error_code,
230            vm_error_code,
231        }
232    }
233
234    /// Returns true if this is a "not found" error
235    pub fn is_not_found(&self) -> bool {
236        matches!(
237            self,
238            Self::NotFound(_)
239                | Self::AccountNotFound(_)
240                | Self::Api {
241                    status_code: 404,
242                    ..
243                }
244        )
245    }
246
247    /// Returns true if this is a timeout error
248    pub fn is_timeout(&self) -> bool {
249        matches!(self, Self::TransactionTimeout { .. })
250    }
251
252    /// Returns true if this is a transient error that might succeed on retry
253    pub fn is_retryable(&self) -> bool {
254        match self {
255            Self::Http(e) => e.is_timeout() || e.is_connect(),
256            Self::Api { status_code, .. } => {
257                matches!(status_code, 429 | 500 | 502 | 503 | 504)
258            }
259            Self::RateLimited { .. } => true,
260            _ => false,
261        }
262    }
263
264    /// Returns a sanitized version of the error message safe for logging.
265    ///
266    /// This method:
267    /// - Removes control characters that could corrupt logs
268    /// - Truncates very long messages to prevent log flooding
269    /// - Redacts patterns that might indicate sensitive information
270    ///
271    /// # Example
272    ///
273    /// ```rust
274    /// use aptos_sdk::AptosError;
275    ///
276    /// let err = AptosError::api(500, "Internal server error with details...");
277    /// let safe_msg = err.sanitized_message();
278    /// // safe_msg is guaranteed to be safe for logging
279    /// ```
280    pub fn sanitized_message(&self) -> String {
281        let raw_message = self.to_string();
282        Self::sanitize_string(&raw_message)
283    }
284
285    /// Sanitizes a string for safe logging.
286    fn sanitize_string(s: &str) -> String {
287        // Remove control characters (except newline and tab for readability)
288        let cleaned: String = s
289            .chars()
290            .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
291            .collect();
292
293        // Check for sensitive patterns (case-insensitive)
294        let lower = cleaned.to_lowercase();
295        for pattern in SENSITIVE_PATTERNS {
296            if lower.contains(pattern) {
297                return format!("[REDACTED: message contained sensitive pattern '{pattern}']");
298            }
299        }
300
301        // SECURITY: Redact URLs with query parameters, which may contain API keys
302        // or other credentials not caught by keyword patterns above.
303        // e.g., reqwest errors include the request URL.
304        // Only redact when '?' appears within a URL token (after the scheme),
305        // not just anywhere in the message. Scan *every* URL occurrence: a
306        // message can contain several URLs of the same scheme and any one of
307        // them (not just the first) may carry a query string.
308        for scheme in ["http://", "https://"] {
309            let mut search_from = 0;
310            while let Some(rel_pos) = lower[search_from..].find(scheme) {
311                let url_start = search_from + rel_pos;
312                // Look for '?' after the scheme, within the URL token
313                // (URLs end at whitespace or common delimiters).
314                let url_rest = &lower[url_start..];
315                let url_end = url_rest
316                    .find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == '\'')
317                    .unwrap_or(url_rest.len());
318                let url_token = &url_rest[..url_end];
319                if url_token.contains('?') {
320                    return "[REDACTED: message contained URL with query parameters]".into();
321                }
322                // Advance past this scheme match to find any subsequent URLs.
323                search_from = url_start + scheme.len();
324            }
325        }
326
327        // Truncate if too long (find a valid UTF-8 boundary to avoid panic)
328        if cleaned.len() > MAX_ERROR_MESSAGE_LENGTH {
329            let mut end = MAX_ERROR_MESSAGE_LENGTH;
330            while end > 0 && !cleaned.is_char_boundary(end) {
331                end -= 1;
332            }
333            format!(
334                "{}... [truncated, total length: {}]",
335                &cleaned[..end],
336                cleaned.len()
337            )
338        } else {
339            cleaned
340        }
341    }
342
343    /// Returns the error message suitable for display to end users.
344    ///
345    /// This is a more conservative sanitization that provides less detail
346    /// but is safer for user-facing error messages.
347    pub fn user_message(&self) -> &'static str {
348        match self {
349            Self::Http(_) => "Network error occurred",
350            Self::Json(_) => "Failed to process response",
351            Self::Bcs(_) => "Failed to process data",
352            Self::Url(_) => "Invalid URL",
353            Self::Hex(_) => "Invalid hex format",
354            Self::InvalidAddress(_) => "Invalid account address",
355            Self::InvalidPublicKey(_) => "Invalid public key",
356            Self::InvalidPrivateKey(_) => "Invalid private key",
357            Self::InvalidSignature(_) => "Invalid signature",
358            Self::SignatureVerificationFailed => "Signature verification failed",
359            Self::InvalidTypeTag(_) => "Invalid type format",
360            Self::Transaction(_) => "Transaction error",
361            Self::SimulationFailed(_) => "Transaction simulation failed",
362            Self::SubmissionFailed(_) => "Transaction submission failed",
363            Self::ExecutionFailed { .. } => "Transaction execution failed",
364            Self::TransactionTimeout { .. } => "Transaction timed out",
365            Self::NotFound(_)
366            | Self::Api {
367                status_code: 404, ..
368            } => "Resource not found",
369            Self::RateLimited { .. }
370            | Self::Api {
371                status_code: 429, ..
372            } => "Rate limit exceeded",
373            Self::Api { status_code, .. } if *status_code >= 500 => "Server error",
374            Self::Api { .. } => "API error",
375            Self::AccountNotFound(_) => "Account not found",
376            Self::InvalidMnemonic(_) => "Invalid recovery phrase",
377            Self::InvalidJwt(_) => "Invalid authentication token",
378            Self::KeyDerivation(_) => "Key derivation failed",
379            Self::InsufficientSignatures { .. } => "Insufficient signatures",
380            Self::FeatureNotEnabled(_) => "Feature not enabled",
381            Self::Config(_) => "Configuration error",
382            Self::Internal(_) => "Internal error",
383            Self::Other(_) => "An error occurred",
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_error_display() {
394        let err = AptosError::InvalidAddress("bad address".to_string());
395        assert_eq!(err.to_string(), "Invalid address: bad address");
396    }
397
398    #[test]
399    fn test_is_not_found() {
400        assert!(AptosError::NotFound("test".to_string()).is_not_found());
401        assert!(AptosError::AccountNotFound("0x1".to_string()).is_not_found());
402        assert!(AptosError::api(404, "not found").is_not_found());
403        assert!(!AptosError::api(500, "server error").is_not_found());
404    }
405
406    #[test]
407    fn test_is_retryable() {
408        assert!(AptosError::api(429, "rate limited").is_retryable());
409        assert!(AptosError::api(503, "unavailable").is_retryable());
410        assert!(AptosError::api(500, "internal error").is_retryable());
411        assert!(AptosError::api(502, "bad gateway").is_retryable());
412        assert!(AptosError::api(504, "timeout").is_retryable());
413        assert!(!AptosError::api(400, "bad request").is_retryable());
414    }
415
416    #[test]
417    fn test_is_timeout() {
418        let err = AptosError::TransactionTimeout {
419            hash: "0x123".to_string(),
420            timeout_secs: 30,
421        };
422        assert!(err.is_timeout());
423        assert!(!AptosError::InvalidAddress("test".to_string()).is_timeout());
424    }
425
426    #[test]
427    fn test_bcs_error() {
428        let err = AptosError::bcs("serialization failed");
429        assert!(matches!(err, AptosError::Bcs(_)));
430        assert!(err.to_string().contains("serialization failed"));
431    }
432
433    #[test]
434    fn test_transaction_error() {
435        let err = AptosError::transaction("invalid payload");
436        assert!(matches!(err, AptosError::Transaction(_)));
437        assert!(err.to_string().contains("invalid payload"));
438    }
439
440    #[test]
441    fn test_api_error() {
442        let err = AptosError::api(400, "bad request");
443        assert!(err.to_string().contains("400"));
444        assert!(err.to_string().contains("bad request"));
445    }
446
447    #[test]
448    fn test_api_error_with_details() {
449        let err = AptosError::api_with_details(
450            400,
451            "invalid argument",
452            Some("INVALID_ARGUMENT".to_string()),
453            Some(42),
454        );
455        if let AptosError::Api {
456            status_code,
457            message,
458            error_code,
459            vm_error_code,
460        } = err
461        {
462            assert_eq!(status_code, 400);
463            assert_eq!(message, "invalid argument");
464            assert_eq!(error_code, Some("INVALID_ARGUMENT".to_string()));
465            assert_eq!(vm_error_code, Some(42));
466        } else {
467            panic!("Expected Api error variant");
468        }
469    }
470
471    #[test]
472    fn test_various_error_displays() {
473        assert!(
474            AptosError::InvalidPublicKey("bad key".to_string())
475                .to_string()
476                .contains("public key")
477        );
478        assert!(
479            AptosError::InvalidPrivateKey("bad key".to_string())
480                .to_string()
481                .contains("private key")
482        );
483        assert!(
484            AptosError::InvalidSignature("bad sig".to_string())
485                .to_string()
486                .contains("signature")
487        );
488        assert!(
489            AptosError::SignatureVerificationFailed
490                .to_string()
491                .contains("verification")
492        );
493        assert!(
494            AptosError::InvalidTypeTag("bad tag".to_string())
495                .to_string()
496                .contains("type tag")
497        );
498        assert!(
499            AptosError::SimulationFailed("error".to_string())
500                .to_string()
501                .contains("Simulation")
502        );
503        assert!(
504            AptosError::SubmissionFailed("error".to_string())
505                .to_string()
506                .contains("Submission")
507        );
508    }
509
510    #[test]
511    fn test_execution_failed() {
512        let err = AptosError::ExecutionFailed {
513            vm_status: "ABORTED".to_string(),
514        };
515        assert!(err.to_string().contains("ABORTED"));
516    }
517
518    #[test]
519    fn test_rate_limited() {
520        let err = AptosError::RateLimited {
521            retry_after_secs: Some(30),
522        };
523        assert!(err.to_string().contains("Rate limited"));
524    }
525
526    #[test]
527    fn test_insufficient_signatures() {
528        let err = AptosError::InsufficientSignatures {
529            required: 3,
530            provided: 1,
531        };
532        assert!(err.to_string().contains('3'));
533        assert!(err.to_string().contains('1'));
534    }
535
536    #[test]
537    fn test_feature_not_enabled() {
538        let err = AptosError::FeatureNotEnabled("ed25519".to_string());
539        assert!(err.to_string().contains("ed25519"));
540        assert!(err.to_string().contains("Cargo.toml"));
541    }
542
543    #[test]
544    fn test_config_error() {
545        let err = AptosError::Config("invalid config".to_string());
546        assert!(err.to_string().contains("Configuration"));
547    }
548
549    #[test]
550    fn test_internal_error() {
551        let err = AptosError::Internal("bug".to_string());
552        assert!(err.to_string().contains("Internal"));
553    }
554
555    #[test]
556    fn test_invalid_mnemonic() {
557        let err = AptosError::InvalidMnemonic("bad phrase".to_string());
558        assert!(err.to_string().contains("mnemonic"));
559    }
560
561    #[test]
562    fn test_invalid_jwt() {
563        let err = AptosError::InvalidJwt("bad token".to_string());
564        assert!(err.to_string().contains("JWT"));
565    }
566
567    #[test]
568    fn test_key_derivation() {
569        let err = AptosError::KeyDerivation("failed".to_string());
570        assert!(err.to_string().contains("derivation"));
571    }
572
573    #[test]
574    fn test_sanitized_message_basic() {
575        let err = AptosError::api(400, "bad request");
576        let sanitized = err.sanitized_message();
577        assert!(sanitized.contains("bad request"));
578    }
579
580    #[test]
581    fn test_sanitized_message_truncates_long_messages() {
582        let long_message = "x".repeat(2000);
583        let err = AptosError::api(500, long_message);
584        let sanitized = err.sanitized_message();
585        assert!(sanitized.len() < 1200); // Should be truncated
586        assert!(sanitized.contains("truncated"));
587    }
588
589    #[test]
590    fn test_sanitized_message_removes_control_chars() {
591        let err = AptosError::api(400, "bad\x00request\x1f");
592        let sanitized = err.sanitized_message();
593        assert!(!sanitized.contains('\x00'));
594        assert!(!sanitized.contains('\x1f'));
595    }
596
597    #[test]
598    fn test_sanitized_message_redacts_sensitive_patterns() {
599        let err = AptosError::Internal("private_key: abc123".to_string());
600        let sanitized = err.sanitized_message();
601        assert!(sanitized.contains("REDACTED"));
602        assert!(!sanitized.contains("abc123"));
603
604        let err = AptosError::Internal("mnemonic phrase here".to_string());
605        let sanitized = err.sanitized_message();
606        assert!(sanitized.contains("REDACTED"));
607    }
608
609    #[test]
610    fn test_sanitized_message_redacts_url_with_query() {
611        // A single URL carrying a query string is redacted. Use a query
612        // parameter name that is *not* itself a sensitive keyword so this
613        // exercises the URL logic rather than the keyword scan.
614        let err = AptosError::api(
615            500,
616            "request to https://node.example.com/v1?trace=abc failed",
617        );
618        let sanitized = err.sanitized_message();
619        assert!(sanitized.contains("REDACTED"));
620        assert!(sanitized.contains("query parameters"));
621        assert!(!sanitized.contains("trace=abc"));
622    }
623
624    #[test]
625    fn test_sanitized_message_redacts_second_url_with_query() {
626        // The FIRST URL has no query; only the SECOND (same scheme) does.
627        // The redactor must scan every URL, not just the first occurrence.
628        let msg = "tried https://a.example.com/ok then https://b.example.com/p?ref=xyz";
629        let err = AptosError::api(502, msg);
630        let sanitized = err.sanitized_message();
631        assert!(sanitized.contains("REDACTED"));
632        assert!(sanitized.contains("query parameters"));
633        assert!(!sanitized.contains("ref=xyz"));
634    }
635
636    #[test]
637    fn test_sanitized_message_keeps_url_without_query() {
638        // A plain URL with no query string must not be redacted.
639        let err = AptosError::api(500, "request to https://node.example.com/v1 failed");
640        let sanitized = err.sanitized_message();
641        assert!(sanitized.contains("node.example.com"));
642        assert!(!sanitized.contains("REDACTED"));
643    }
644
645    #[test]
646    fn test_user_message() {
647        assert_eq!(
648            AptosError::api(404, "not found").user_message(),
649            "Resource not found"
650        );
651        assert_eq!(
652            AptosError::api(429, "rate limited").user_message(),
653            "Rate limit exceeded"
654        );
655        assert_eq!(
656            AptosError::api(500, "internal error").user_message(),
657            "Server error"
658        );
659        assert_eq!(
660            AptosError::InvalidAddress("bad".to_string()).user_message(),
661            "Invalid account address"
662        );
663    }
664
665    #[test]
666    fn test_user_message_all_variants() {
667        // Test all user_message variants for coverage
668        assert_eq!(
669            AptosError::InvalidPublicKey("bad".to_string()).user_message(),
670            "Invalid public key"
671        );
672        assert_eq!(
673            AptosError::InvalidPrivateKey("bad".to_string()).user_message(),
674            "Invalid private key"
675        );
676        assert_eq!(
677            AptosError::InvalidSignature("bad".to_string()).user_message(),
678            "Invalid signature"
679        );
680        assert_eq!(
681            AptosError::SignatureVerificationFailed.user_message(),
682            "Signature verification failed"
683        );
684        assert_eq!(
685            AptosError::InvalidTypeTag("bad".to_string()).user_message(),
686            "Invalid type format"
687        );
688        assert_eq!(
689            AptosError::Transaction("bad".to_string()).user_message(),
690            "Transaction error"
691        );
692        assert_eq!(
693            AptosError::SimulationFailed("bad".to_string()).user_message(),
694            "Transaction simulation failed"
695        );
696        assert_eq!(
697            AptosError::SubmissionFailed("bad".to_string()).user_message(),
698            "Transaction submission failed"
699        );
700        assert_eq!(
701            AptosError::ExecutionFailed {
702                vm_status: "ABORTED".to_string()
703            }
704            .user_message(),
705            "Transaction execution failed"
706        );
707        assert_eq!(
708            AptosError::TransactionTimeout {
709                hash: "0x1".to_string(),
710                timeout_secs: 30
711            }
712            .user_message(),
713            "Transaction timed out"
714        );
715        assert_eq!(
716            AptosError::NotFound("x".to_string()).user_message(),
717            "Resource not found"
718        );
719        assert_eq!(
720            AptosError::RateLimited {
721                retry_after_secs: Some(30)
722            }
723            .user_message(),
724            "Rate limit exceeded"
725        );
726        assert_eq!(
727            AptosError::api(503, "unavailable").user_message(),
728            "Server error"
729        );
730        assert_eq!(
731            AptosError::api(400, "bad request").user_message(),
732            "API error"
733        );
734        assert_eq!(
735            AptosError::AccountNotFound("0x1".to_string()).user_message(),
736            "Account not found"
737        );
738        assert_eq!(
739            AptosError::InvalidMnemonic("bad".to_string()).user_message(),
740            "Invalid recovery phrase"
741        );
742        assert_eq!(
743            AptosError::InvalidJwt("bad".to_string()).user_message(),
744            "Invalid authentication token"
745        );
746        assert_eq!(
747            AptosError::KeyDerivation("bad".to_string()).user_message(),
748            "Key derivation failed"
749        );
750        assert_eq!(
751            AptosError::InsufficientSignatures {
752                required: 3,
753                provided: 1
754            }
755            .user_message(),
756            "Insufficient signatures"
757        );
758        assert_eq!(
759            AptosError::FeatureNotEnabled("ed25519".to_string()).user_message(),
760            "Feature not enabled"
761        );
762        assert_eq!(
763            AptosError::Config("bad".to_string()).user_message(),
764            "Configuration error"
765        );
766        assert_eq!(
767            AptosError::Internal("bug".to_string()).user_message(),
768            "Internal error"
769        );
770        assert_eq!(
771            AptosError::Other(anyhow::anyhow!("misc")).user_message(),
772            "An error occurred"
773        );
774    }
775
776    #[test]
777    fn test_is_retryable_http_errors() {
778        // We can't easily test reqwest errors, so just ensure non-http errors return false
779        assert!(!AptosError::InvalidAddress("x".to_string()).is_retryable());
780        assert!(!AptosError::Transaction("x".to_string()).is_retryable());
781        assert!(!AptosError::NotFound("x".to_string()).is_retryable());
782    }
783}