Skip to main content

lastid_sdk/error/
mod.rs

1//! Error types for the `LastID` SDK.
2//!
3//! This module provides a comprehensive error hierarchy:
4//! - [`LastIDError`]: Top-level SDK error type
5//! - [`HttpError`]: HTTP client errors with retry classification
6//! - [`TrustRegistryError`]: Trust registry specific errors
7
8mod http;
9mod trust_registry;
10
11pub use http::HttpError;
12pub use trust_registry::TrustRegistryError;
13
14#[cfg(feature = "websocket")]
15mod websocket;
16use thiserror::Error;
17#[cfg(feature = "websocket")]
18pub use websocket::WebSocketError;
19
20/// Top-level SDK error type.
21///
22/// All SDK operations return this error type, which encompasses configuration
23/// errors, HTTP errors, trust registry errors, cryptographic errors, and more.
24///
25/// # Error Handling
26///
27/// Use pattern matching to handle specific error cases:
28///
29/// ```rust,no_run
30/// use lastid_sdk::{HttpError, LastIDError};
31///
32/// fn handle_error(err: LastIDError) {
33///     match err {
34///         LastIDError::Http(HttpError::RateLimited {
35///             retry_after_seconds,
36///         }) => {
37///             println!(
38///                 "Rate limited, retry after {} seconds",
39///                 retry_after_seconds
40///             );
41///         }
42///         LastIDError::TrustRegistry(e) => {
43///             println!("Trust registry error: {}", e);
44///         }
45///         LastIDError::Timeout(seconds) => {
46///             println!("Request timed out after {} seconds", seconds);
47///         }
48///         _ => println!("Error: {}", err),
49///     }
50/// }
51/// ```
52#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum LastIDError {
55    /// Configuration error (missing or invalid config)
56    #[error("Configuration error: {0}")]
57    Config(String),
58
59    /// HTTP request failed
60    #[error("HTTP request failed: {0}")]
61    Http(#[from] HttpError),
62
63    /// Trust registry error
64    #[error("Trust registry error: {0}")]
65    TrustRegistry(#[from] TrustRegistryError),
66
67    /// Cryptographic operation failed
68    #[error("Cryptographic operation failed: {0}")]
69    Crypto(String),
70
71    /// Invalid credential (parsing or validation failed)
72    #[error("Invalid credential: {0}")]
73    InvalidCredential(String),
74
75    /// Request timeout
76    #[error("Request timeout after {0} seconds")]
77    Timeout(u64),
78
79    /// Policy validation failed
80    #[error("Policy validation failed: {0}")]
81    PolicyValidation(String),
82
83    /// Serialization error
84    #[error("Serialization error: {0}")]
85    Serialization(#[from] serde_json::Error),
86
87    /// IO error
88    #[error("IO error: {0}")]
89    Io(#[from] std::io::Error),
90
91    /// TOML parsing error
92    #[error("TOML parsing error: {0}")]
93    TomlParse(#[from] toml::de::Error),
94
95    /// WebSocket error
96    #[cfg(feature = "websocket")]
97    #[error("WebSocket error: {0}")]
98    WebSocket(#[from] WebSocketError),
99
100    /// Invalid DID format
101    #[error("Invalid DID format: {0}")]
102    InvalidDid(String),
103
104    /// Invalid client ID (empty or whitespace-only)
105    #[error("Client ID cannot be empty")]
106    EmptyClientId,
107
108    /// HTTP URL not allowed in production (SEC-001)
109    #[error("HTTP URLs are not allowed; use HTTPS (except localhost for dev): {0}")]
110    InsecureUrl(String),
111
112    /// Clock skew exceeded tolerance (SEC-005)
113    #[error("Clock skew exceeded {tolerance_seconds}s tolerance")]
114    ClockSkewExceeded {
115        /// Maximum allowed clock skew in seconds
116        tolerance_seconds: u64,
117    },
118
119    /// `DPoP` token binding mismatch (SEC-004)
120    #[error("Token binding mismatch: expected thumbprint {expected}, got {actual}")]
121    TokenBindingMismatch {
122        /// Expected JWK thumbprint from our `DPoP` key
123        expected: String,
124        /// Actual JWK thumbprint from token's cnf.jkt claim
125        actual: String,
126    },
127}
128
129impl LastIDError {
130    /// Create a configuration error.
131    #[must_use]
132    pub fn config(message: impl Into<String>) -> Self {
133        Self::Config(message.into())
134    }
135
136    /// Create a crypto error.
137    #[must_use]
138    pub fn crypto(message: impl Into<String>) -> Self {
139        Self::Crypto(message.into())
140    }
141
142    /// Create an invalid credential error.
143    #[must_use]
144    pub fn invalid_credential(message: impl Into<String>) -> Self {
145        Self::InvalidCredential(message.into())
146    }
147
148    /// Create a credential parsing error.
149    ///
150    /// This is an alias for `invalid_credential` used specifically when
151    /// `TryFrom` conversions fail due to type mismatch or missing required
152    /// fields.
153    ///
154    /// # Example
155    ///
156    /// ```rust
157    /// use lastid_sdk::LastIDError;
158    ///
159    /// let err = LastIDError::credential_parse("Expected LastID.Persona, got LastID.Employment");
160    /// assert!(err.to_string().contains("LastID.Persona"));
161    /// ```
162    #[must_use]
163    pub fn credential_parse(message: impl Into<String>) -> Self {
164        Self::InvalidCredential(message.into())
165    }
166
167    /// Create a policy validation error.
168    #[must_use]
169    pub fn policy_validation(message: impl Into<String>) -> Self {
170        Self::PolicyValidation(message.into())
171    }
172
173    /// Check if this error is retryable.
174    #[must_use]
175    #[allow(clippy::missing_const_for_fn)] // Cannot be const: calls methods on nested types
176    pub fn is_retryable(&self) -> bool {
177        match self {
178            Self::Http(e) => e.is_retryable(),
179            Self::TrustRegistry(e) => e.is_retryable(),
180            Self::Timeout(_) => true,
181            _ => false,
182        }
183    }
184
185    /// Get suggested retry delay in milliseconds.
186    ///
187    /// Returns `Some(delay)` for retryable errors with a recommended wait time.
188    /// Returns `None` for non-retryable errors.
189    ///
190    /// # Retry Strategy Hints
191    ///
192    /// - Rate limited: Use the `retry_after` value from the response
193    /// - Network errors: Start with short delays (100-500ms), use exponential
194    ///   backoff
195    /// - Server errors (5xx): Use moderate delays (1-5s), with exponential
196    ///   backoff
197    /// - Timeout: Increase timeout or use exponential backoff
198    ///
199    /// # Example
200    ///
201    /// ```rust,no_run
202    /// use lastid_sdk::LastIDError;
203    ///
204    /// fn should_retry(err: &LastIDError, attempt: u32) -> Option<u64> {
205    ///     if !err.is_retryable() || attempt >= 3 {
206    ///         return None;
207    ///     }
208    ///     err.suggested_retry_delay_ms()
209    /// }
210    /// ```
211    #[must_use]
212    #[allow(clippy::missing_const_for_fn)] // Cannot be const: calls methods on nested types
213    pub fn suggested_retry_delay_ms(&self) -> Option<u64> {
214        match self {
215            Self::Http(e) => e.suggested_retry_delay_ms(),
216            Self::TrustRegistry(e) => e.suggested_retry_delay_ms(),
217            Self::Timeout(_) => Some(2000), // 2 second base delay for timeouts
218            _ => None,
219        }
220    }
221
222    /// Get the error category for logging and metrics.
223    ///
224    /// Returns a static string describing the error category,
225    /// useful for structured logging and metrics.
226    #[must_use]
227    pub const fn category(&self) -> &'static str {
228        match self {
229            Self::Config(_) => "config",
230            Self::Http(_) => "http",
231            Self::TrustRegistry(_) => "trust_registry",
232            Self::Crypto(_) => "crypto",
233            Self::InvalidCredential(_) => "invalid_credential",
234            Self::Timeout(_) => "timeout",
235            Self::PolicyValidation(_) => "policy_validation",
236            Self::Serialization(_) => "serialization",
237            Self::Io(_) => "io",
238            Self::TomlParse(_) => "toml_parse",
239            #[cfg(feature = "websocket")]
240            Self::WebSocket(_) => "websocket",
241            Self::InvalidDid(_) => "invalid_did",
242            Self::EmptyClientId => "empty_client_id",
243            Self::InsecureUrl(_) => "insecure_url",
244            Self::ClockSkewExceeded { .. } => "clock_skew",
245            Self::TokenBindingMismatch { .. } => "token_binding",
246        }
247    }
248
249    /// Create an invalid DID error.
250    #[must_use]
251    pub fn invalid_did(did: impl Into<String>) -> Self {
252        Self::InvalidDid(did.into())
253    }
254
255    /// Create an insecure URL error.
256    #[must_use]
257    pub fn insecure_url(url: impl Into<String>) -> Self {
258        Self::InsecureUrl(url.into())
259    }
260
261    /// Create a clock skew exceeded error.
262    #[must_use]
263    pub const fn clock_skew_exceeded(tolerance_seconds: u64) -> Self {
264        Self::ClockSkewExceeded { tolerance_seconds }
265    }
266
267    /// Create a token binding mismatch error.
268    #[must_use]
269    pub fn token_binding_mismatch(expected: impl Into<String>, actual: impl Into<String>) -> Self {
270        Self::TokenBindingMismatch {
271            expected: expected.into(),
272            actual: actual.into(),
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::trust_registry::IssuerStatus;
281
282    #[test]
283    fn test_http_error_is_retryable() {
284        assert!(HttpError::network("connection refused").is_retryable());
285        assert!(HttpError::Timeout.is_retryable());
286        assert!(HttpError::status(500, "internal error").is_retryable());
287        assert!(HttpError::status(503, "unavailable").is_retryable());
288        assert!(HttpError::rate_limited(60).is_retryable());
289
290        assert!(!HttpError::status(400, "bad request").is_retryable());
291        assert!(!HttpError::status(401, "unauthorized").is_retryable());
292        assert!(!HttpError::status(404, "not found").is_retryable());
293    }
294
295    #[test]
296    fn test_http_error_is_rate_limited() {
297        assert!(HttpError::rate_limited(60).is_rate_limited());
298        assert!(HttpError::status(429, "too many requests").is_rate_limited());
299        assert!(!HttpError::status(500, "error").is_rate_limited());
300    }
301
302    #[test]
303    fn test_lastid_error_is_retryable() {
304        assert!(LastIDError::Http(HttpError::Timeout).is_retryable());
305        assert!(LastIDError::Timeout(300).is_retryable());
306        assert!(!LastIDError::config("missing").is_retryable());
307        assert!(!LastIDError::crypto("invalid key").is_retryable());
308    }
309
310    #[test]
311    fn test_trust_registry_error_is_retryable() {
312        assert!(TrustRegistryError::Http(HttpError::Timeout).is_retryable());
313        assert!(!TrustRegistryError::issuer_not_found("did:test").is_retryable());
314        assert!(!TrustRegistryError::invalid_status(IssuerStatus::Suspended).is_retryable());
315    }
316
317    #[test]
318    fn test_http_error_suggested_retry_delay() {
319        // Network errors: 500ms
320        assert_eq!(
321            HttpError::network("err").suggested_retry_delay_ms(),
322            Some(500)
323        );
324
325        // Timeout: 1000ms
326        assert_eq!(HttpError::Timeout.suggested_retry_delay_ms(), Some(1000));
327
328        // 5xx errors: 2000ms
329        assert_eq!(
330            HttpError::status(500, "error").suggested_retry_delay_ms(),
331            Some(2000)
332        );
333        assert_eq!(
334            HttpError::status(503, "error").suggested_retry_delay_ms(),
335            Some(2000)
336        );
337
338        // Rate limited: retry_after * 1000
339        assert_eq!(
340            HttpError::rate_limited(60).suggested_retry_delay_ms(),
341            Some(60_000)
342        );
343
344        // 4xx errors: None (not retryable)
345        assert_eq!(
346            HttpError::status(400, "error").suggested_retry_delay_ms(),
347            None
348        );
349        assert_eq!(
350            HttpError::status(401, "error").suggested_retry_delay_ms(),
351            None
352        );
353        assert_eq!(
354            HttpError::status(404, "error").suggested_retry_delay_ms(),
355            None
356        );
357    }
358
359    #[test]
360    fn test_lastid_error_suggested_retry_delay() {
361        // HTTP errors delegate to HttpError
362        assert_eq!(
363            LastIDError::Http(HttpError::Timeout).suggested_retry_delay_ms(),
364            Some(1000)
365        );
366
367        // Timeout errors: 2000ms
368        assert_eq!(
369            LastIDError::Timeout(300).suggested_retry_delay_ms(),
370            Some(2000)
371        );
372
373        // Non-retryable errors: None
374        assert_eq!(
375            LastIDError::config("error").suggested_retry_delay_ms(),
376            None
377        );
378        assert_eq!(
379            LastIDError::crypto("error").suggested_retry_delay_ms(),
380            None
381        );
382    }
383
384    #[test]
385    fn test_trust_registry_error_suggested_retry_delay() {
386        // HTTP errors delegate
387        assert_eq!(
388            TrustRegistryError::Http(HttpError::Timeout).suggested_retry_delay_ms(),
389            Some(1000)
390        );
391
392        // Non-retryable errors: None
393        assert_eq!(
394            TrustRegistryError::issuer_not_found("did:test").suggested_retry_delay_ms(),
395            None
396        );
397    }
398
399    #[test]
400    fn test_http_error_status_code() {
401        assert_eq!(HttpError::status(404, "not found").status_code(), Some(404));
402        assert_eq!(HttpError::rate_limited(60).status_code(), Some(429));
403        assert_eq!(HttpError::network("error").status_code(), None);
404        assert_eq!(HttpError::Timeout.status_code(), None);
405    }
406
407    #[test]
408    fn test_error_categories() {
409        // LastIDError categories
410        assert_eq!(LastIDError::config("error").category(), "config");
411        assert_eq!(LastIDError::Http(HttpError::Timeout).category(), "http");
412        assert_eq!(LastIDError::Timeout(60).category(), "timeout");
413
414        // HttpError categories
415        assert_eq!(HttpError::network("error").category(), "network");
416        assert_eq!(HttpError::Timeout.category(), "timeout");
417        assert_eq!(HttpError::rate_limited(60).category(), "rate_limited");
418        assert_eq!(HttpError::status(500, "error").category(), "server_error");
419        assert_eq!(HttpError::status(400, "error").category(), "client_error");
420
421        // TrustRegistryError categories
422        assert_eq!(
423            TrustRegistryError::issuer_not_found("did").category(),
424            "issuer_not_found"
425        );
426        assert_eq!(
427            TrustRegistryError::invalid_status(IssuerStatus::Suspended).category(),
428            "invalid_issuer_status"
429        );
430    }
431}