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) => {
256                #[cfg(not(target_arch = "wasm32"))]
257                return e.is_timeout() || e.is_connect();
258                #[cfg(target_arch = "wasm32")]
259                return e.is_timeout();
260            }
261            Self::Api { status_code, .. } => {
262                matches!(status_code, 429 | 500 | 502 | 503 | 504)
263            }
264            Self::RateLimited { .. } => true,
265            _ => false,
266        }
267    }
268
269    /// Returns a sanitized version of the error message safe for logging.
270    ///
271    /// This method:
272    /// - Removes control characters that could corrupt logs
273    /// - Truncates very long messages to prevent log flooding
274    /// - Redacts patterns that might indicate sensitive information
275    ///
276    /// # Example
277    ///
278    /// ```rust
279    /// use aptos_sdk::AptosError;
280    ///
281    /// let err = AptosError::api(500, "Internal server error with details...");
282    /// let safe_msg = err.sanitized_message();
283    /// // safe_msg is guaranteed to be safe for logging
284    /// ```
285    pub fn sanitized_message(&self) -> String {
286        let raw_message = self.to_string();
287        Self::sanitize_string(&raw_message)
288    }
289
290    /// Sanitizes a string for safe logging.
291    fn sanitize_string(s: &str) -> String {
292        // Remove control characters (except newline and tab for readability)
293        let cleaned: String = s
294            .chars()
295            .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
296            .collect();
297
298        // Check for sensitive patterns (case-insensitive)
299        let lower = cleaned.to_lowercase();
300        for pattern in SENSITIVE_PATTERNS {
301            if lower.contains(pattern) {
302                return format!("[REDACTED: message contained sensitive pattern '{pattern}']");
303            }
304        }
305
306        // SECURITY: Redact URLs with query parameters, which may contain API keys
307        // or other credentials not caught by keyword patterns above.
308        // e.g., reqwest errors include the request URL.
309        // Only redact when '?' appears within a URL token (after the scheme),
310        // not just anywhere in the message. Scan *every* URL occurrence: a
311        // message can contain several URLs of the same scheme and any one of
312        // them (not just the first) may carry a query string.
313        for scheme in ["http://", "https://"] {
314            let mut search_from = 0;
315            while let Some(rel_pos) = lower[search_from..].find(scheme) {
316                let url_start = search_from + rel_pos;
317                // Look for '?' after the scheme, within the URL token
318                // (URLs end at whitespace or common delimiters).
319                let url_rest = &lower[url_start..];
320                let url_end = url_rest
321                    .find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == '\'')
322                    .unwrap_or(url_rest.len());
323                let url_token = &url_rest[..url_end];
324                if url_token.contains('?') {
325                    return "[REDACTED: message contained URL with query parameters]".into();
326                }
327                // Advance past this scheme match to find any subsequent URLs.
328                search_from = url_start + scheme.len();
329            }
330        }
331
332        // Truncate if too long (find a valid UTF-8 boundary to avoid panic)
333        if cleaned.len() > MAX_ERROR_MESSAGE_LENGTH {
334            let mut end = MAX_ERROR_MESSAGE_LENGTH;
335            while end > 0 && !cleaned.is_char_boundary(end) {
336                end -= 1;
337            }
338            format!(
339                "{}... [truncated, total length: {}]",
340                &cleaned[..end],
341                cleaned.len()
342            )
343        } else {
344            cleaned
345        }
346    }
347
348    /// Returns the error message suitable for display to end users.
349    ///
350    /// This is a more conservative sanitization that provides less detail
351    /// but is safer for user-facing error messages.
352    pub fn user_message(&self) -> &'static str {
353        match self {
354            Self::Http(_) => "Network error occurred",
355            Self::Json(_) => "Failed to process response",
356            Self::Bcs(_) => "Failed to process data",
357            Self::Url(_) => "Invalid URL",
358            Self::Hex(_) => "Invalid hex format",
359            Self::InvalidAddress(_) => "Invalid account address",
360            Self::InvalidPublicKey(_) => "Invalid public key",
361            Self::InvalidPrivateKey(_) => "Invalid private key",
362            Self::InvalidSignature(_) => "Invalid signature",
363            Self::SignatureVerificationFailed => "Signature verification failed",
364            Self::InvalidTypeTag(_) => "Invalid type format",
365            Self::Transaction(_) => "Transaction error",
366            Self::SimulationFailed(_) => "Transaction simulation failed",
367            Self::SubmissionFailed(_) => "Transaction submission failed",
368            Self::ExecutionFailed { .. } => "Transaction execution failed",
369            Self::TransactionTimeout { .. } => "Transaction timed out",
370            Self::NotFound(_)
371            | Self::Api {
372                status_code: 404, ..
373            } => "Resource not found",
374            Self::RateLimited { .. }
375            | Self::Api {
376                status_code: 429, ..
377            } => "Rate limit exceeded",
378            Self::Api { status_code, .. } if *status_code >= 500 => "Server error",
379            Self::Api { .. } => "API error",
380            Self::AccountNotFound(_) => "Account not found",
381            Self::InvalidMnemonic(_) => "Invalid recovery phrase",
382            Self::InvalidJwt(_) => "Invalid authentication token",
383            Self::KeyDerivation(_) => "Key derivation failed",
384            Self::InsufficientSignatures { .. } => "Insufficient signatures",
385            Self::FeatureNotEnabled(_) => "Feature not enabled",
386            Self::Config(_) => "Configuration error",
387            Self::Internal(_) => "Internal error",
388            Self::Other(_) => "An error occurred",
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_error_display() {
399        let err = AptosError::InvalidAddress("bad address".to_string());
400        assert_eq!(err.to_string(), "Invalid address: bad address");
401    }
402
403    #[test]
404    fn test_is_not_found() {
405        assert!(AptosError::NotFound("test".to_string()).is_not_found());
406        assert!(AptosError::AccountNotFound("0x1".to_string()).is_not_found());
407        assert!(AptosError::api(404, "not found").is_not_found());
408        assert!(!AptosError::api(500, "server error").is_not_found());
409    }
410
411    #[test]
412    fn test_is_retryable() {
413        assert!(AptosError::api(429, "rate limited").is_retryable());
414        assert!(AptosError::api(503, "unavailable").is_retryable());
415        assert!(AptosError::api(500, "internal error").is_retryable());
416        assert!(AptosError::api(502, "bad gateway").is_retryable());
417        assert!(AptosError::api(504, "timeout").is_retryable());
418        assert!(!AptosError::api(400, "bad request").is_retryable());
419    }
420
421    #[test]
422    fn test_is_timeout() {
423        let err = AptosError::TransactionTimeout {
424            hash: "0x123".to_string(),
425            timeout_secs: 30,
426        };
427        assert!(err.is_timeout());
428        assert!(!AptosError::InvalidAddress("test".to_string()).is_timeout());
429    }
430
431    #[test]
432    fn test_bcs_error() {
433        let err = AptosError::bcs("serialization failed");
434        assert!(matches!(err, AptosError::Bcs(_)));
435        assert!(err.to_string().contains("serialization failed"));
436    }
437
438    #[test]
439    fn test_transaction_error() {
440        let err = AptosError::transaction("invalid payload");
441        assert!(matches!(err, AptosError::Transaction(_)));
442        assert!(err.to_string().contains("invalid payload"));
443    }
444
445    #[test]
446    fn test_api_error() {
447        let err = AptosError::api(400, "bad request");
448        assert!(err.to_string().contains("400"));
449        assert!(err.to_string().contains("bad request"));
450    }
451
452    #[test]
453    fn test_api_error_with_details() {
454        let err = AptosError::api_with_details(
455            400,
456            "invalid argument",
457            Some("INVALID_ARGUMENT".to_string()),
458            Some(42),
459        );
460        if let AptosError::Api {
461            status_code,
462            message,
463            error_code,
464            vm_error_code,
465        } = err
466        {
467            assert_eq!(status_code, 400);
468            assert_eq!(message, "invalid argument");
469            assert_eq!(error_code, Some("INVALID_ARGUMENT".to_string()));
470            assert_eq!(vm_error_code, Some(42));
471        } else {
472            panic!("Expected Api error variant");
473        }
474    }
475
476    #[test]
477    fn test_various_error_displays() {
478        assert!(
479            AptosError::InvalidPublicKey("bad key".to_string())
480                .to_string()
481                .contains("public key")
482        );
483        assert!(
484            AptosError::InvalidPrivateKey("bad key".to_string())
485                .to_string()
486                .contains("private key")
487        );
488        assert!(
489            AptosError::InvalidSignature("bad sig".to_string())
490                .to_string()
491                .contains("signature")
492        );
493        assert!(
494            AptosError::SignatureVerificationFailed
495                .to_string()
496                .contains("verification")
497        );
498        assert!(
499            AptosError::InvalidTypeTag("bad tag".to_string())
500                .to_string()
501                .contains("type tag")
502        );
503        assert!(
504            AptosError::SimulationFailed("error".to_string())
505                .to_string()
506                .contains("Simulation")
507        );
508        assert!(
509            AptosError::SubmissionFailed("error".to_string())
510                .to_string()
511                .contains("Submission")
512        );
513    }
514
515    #[test]
516    fn test_execution_failed() {
517        let err = AptosError::ExecutionFailed {
518            vm_status: "ABORTED".to_string(),
519        };
520        assert!(err.to_string().contains("ABORTED"));
521    }
522
523    #[test]
524    fn test_rate_limited() {
525        let err = AptosError::RateLimited {
526            retry_after_secs: Some(30),
527        };
528        assert!(err.to_string().contains("Rate limited"));
529    }
530
531    #[test]
532    fn test_insufficient_signatures() {
533        let err = AptosError::InsufficientSignatures {
534            required: 3,
535            provided: 1,
536        };
537        assert!(err.to_string().contains('3'));
538        assert!(err.to_string().contains('1'));
539    }
540
541    #[test]
542    fn test_feature_not_enabled() {
543        let err = AptosError::FeatureNotEnabled("ed25519".to_string());
544        assert!(err.to_string().contains("ed25519"));
545        assert!(err.to_string().contains("Cargo.toml"));
546    }
547
548    #[test]
549    fn test_config_error() {
550        let err = AptosError::Config("invalid config".to_string());
551        assert!(err.to_string().contains("Configuration"));
552    }
553
554    #[test]
555    fn test_internal_error() {
556        let err = AptosError::Internal("bug".to_string());
557        assert!(err.to_string().contains("Internal"));
558    }
559
560    #[test]
561    fn test_invalid_mnemonic() {
562        let err = AptosError::InvalidMnemonic("bad phrase".to_string());
563        assert!(err.to_string().contains("mnemonic"));
564    }
565
566    #[test]
567    fn test_invalid_jwt() {
568        let err = AptosError::InvalidJwt("bad token".to_string());
569        assert!(err.to_string().contains("JWT"));
570    }
571
572    #[test]
573    fn test_key_derivation() {
574        let err = AptosError::KeyDerivation("failed".to_string());
575        assert!(err.to_string().contains("derivation"));
576    }
577
578    #[test]
579    fn test_sanitized_message_basic() {
580        let err = AptosError::api(400, "bad request");
581        let sanitized = err.sanitized_message();
582        assert!(sanitized.contains("bad request"));
583    }
584
585    #[test]
586    fn test_sanitized_message_truncates_long_messages() {
587        let long_message = "x".repeat(2000);
588        let err = AptosError::api(500, long_message);
589        let sanitized = err.sanitized_message();
590        assert!(sanitized.len() < 1200); // Should be truncated
591        assert!(sanitized.contains("truncated"));
592    }
593
594    #[test]
595    fn test_sanitized_message_removes_control_chars() {
596        let err = AptosError::api(400, "bad\x00request\x1f");
597        let sanitized = err.sanitized_message();
598        assert!(!sanitized.contains('\x00'));
599        assert!(!sanitized.contains('\x1f'));
600    }
601
602    #[test]
603    fn test_sanitized_message_redacts_sensitive_patterns() {
604        let err = AptosError::Internal("private_key: abc123".to_string());
605        let sanitized = err.sanitized_message();
606        assert!(sanitized.contains("REDACTED"));
607        assert!(!sanitized.contains("abc123"));
608
609        let err = AptosError::Internal("mnemonic phrase here".to_string());
610        let sanitized = err.sanitized_message();
611        assert!(sanitized.contains("REDACTED"));
612    }
613
614    #[test]
615    fn test_sanitized_message_redacts_url_with_query() {
616        // A single URL carrying a query string is redacted. Use a query
617        // parameter name that is *not* itself a sensitive keyword so this
618        // exercises the URL logic rather than the keyword scan.
619        let err = AptosError::api(
620            500,
621            "request to https://node.example.com/v1?trace=abc failed",
622        );
623        let sanitized = err.sanitized_message();
624        assert!(sanitized.contains("REDACTED"));
625        assert!(sanitized.contains("query parameters"));
626        assert!(!sanitized.contains("trace=abc"));
627    }
628
629    #[test]
630    fn test_sanitized_message_redacts_second_url_with_query() {
631        // The FIRST URL has no query; only the SECOND (same scheme) does.
632        // The redactor must scan every URL, not just the first occurrence.
633        let msg = "tried https://a.example.com/ok then https://b.example.com/p?ref=xyz";
634        let err = AptosError::api(502, msg);
635        let sanitized = err.sanitized_message();
636        assert!(sanitized.contains("REDACTED"));
637        assert!(sanitized.contains("query parameters"));
638        assert!(!sanitized.contains("ref=xyz"));
639    }
640
641    #[test]
642    fn test_sanitized_message_keeps_url_without_query() {
643        // A plain URL with no query string must not be redacted.
644        let err = AptosError::api(500, "request to https://node.example.com/v1 failed");
645        let sanitized = err.sanitized_message();
646        assert!(sanitized.contains("node.example.com"));
647        assert!(!sanitized.contains("REDACTED"));
648    }
649
650    #[test]
651    fn test_user_message() {
652        assert_eq!(
653            AptosError::api(404, "not found").user_message(),
654            "Resource not found"
655        );
656        assert_eq!(
657            AptosError::api(429, "rate limited").user_message(),
658            "Rate limit exceeded"
659        );
660        assert_eq!(
661            AptosError::api(500, "internal error").user_message(),
662            "Server error"
663        );
664        assert_eq!(
665            AptosError::InvalidAddress("bad".to_string()).user_message(),
666            "Invalid account address"
667        );
668    }
669
670    #[test]
671    fn test_user_message_all_variants() {
672        // Test all user_message variants for coverage
673        assert_eq!(
674            AptosError::InvalidPublicKey("bad".to_string()).user_message(),
675            "Invalid public key"
676        );
677        assert_eq!(
678            AptosError::InvalidPrivateKey("bad".to_string()).user_message(),
679            "Invalid private key"
680        );
681        assert_eq!(
682            AptosError::InvalidSignature("bad".to_string()).user_message(),
683            "Invalid signature"
684        );
685        assert_eq!(
686            AptosError::SignatureVerificationFailed.user_message(),
687            "Signature verification failed"
688        );
689        assert_eq!(
690            AptosError::InvalidTypeTag("bad".to_string()).user_message(),
691            "Invalid type format"
692        );
693        assert_eq!(
694            AptosError::Transaction("bad".to_string()).user_message(),
695            "Transaction error"
696        );
697        assert_eq!(
698            AptosError::SimulationFailed("bad".to_string()).user_message(),
699            "Transaction simulation failed"
700        );
701        assert_eq!(
702            AptosError::SubmissionFailed("bad".to_string()).user_message(),
703            "Transaction submission failed"
704        );
705        assert_eq!(
706            AptosError::ExecutionFailed {
707                vm_status: "ABORTED".to_string()
708            }
709            .user_message(),
710            "Transaction execution failed"
711        );
712        assert_eq!(
713            AptosError::TransactionTimeout {
714                hash: "0x1".to_string(),
715                timeout_secs: 30
716            }
717            .user_message(),
718            "Transaction timed out"
719        );
720        assert_eq!(
721            AptosError::NotFound("x".to_string()).user_message(),
722            "Resource not found"
723        );
724        assert_eq!(
725            AptosError::RateLimited {
726                retry_after_secs: Some(30)
727            }
728            .user_message(),
729            "Rate limit exceeded"
730        );
731        assert_eq!(
732            AptosError::api(503, "unavailable").user_message(),
733            "Server error"
734        );
735        assert_eq!(
736            AptosError::api(400, "bad request").user_message(),
737            "API error"
738        );
739        assert_eq!(
740            AptosError::AccountNotFound("0x1".to_string()).user_message(),
741            "Account not found"
742        );
743        assert_eq!(
744            AptosError::InvalidMnemonic("bad".to_string()).user_message(),
745            "Invalid recovery phrase"
746        );
747        assert_eq!(
748            AptosError::InvalidJwt("bad".to_string()).user_message(),
749            "Invalid authentication token"
750        );
751        assert_eq!(
752            AptosError::KeyDerivation("bad".to_string()).user_message(),
753            "Key derivation failed"
754        );
755        assert_eq!(
756            AptosError::InsufficientSignatures {
757                required: 3,
758                provided: 1
759            }
760            .user_message(),
761            "Insufficient signatures"
762        );
763        assert_eq!(
764            AptosError::FeatureNotEnabled("ed25519".to_string()).user_message(),
765            "Feature not enabled"
766        );
767        assert_eq!(
768            AptosError::Config("bad".to_string()).user_message(),
769            "Configuration error"
770        );
771        assert_eq!(
772            AptosError::Internal("bug".to_string()).user_message(),
773            "Internal error"
774        );
775        assert_eq!(
776            AptosError::Other(anyhow::anyhow!("misc")).user_message(),
777            "An error occurred"
778        );
779    }
780
781    #[test]
782    fn test_is_retryable_http_errors() {
783        // We can't easily test reqwest errors, so just ensure non-http errors return false
784        assert!(!AptosError::InvalidAddress("x".to_string()).is_retryable());
785        assert!(!AptosError::Transaction("x".to_string()).is_retryable());
786        assert!(!AptosError::NotFound("x".to_string()).is_retryable());
787    }
788}