Skip to main content

jacquard_common/
error.rs

1//! Error types for XRPC client operations
2
3use crate::xrpc::EncodeError;
4use alloc::boxed::Box;
5use alloc::string::ToString;
6use bytes::Bytes;
7use smol_str::SmolStr;
8
9#[cfg(feature = "std")]
10use miette::Diagnostic;
11
12/// Boxed error type for wrapping arbitrary errors
13pub type BoxError = Box<dyn core::error::Error + Send + Sync + 'static>;
14
15/// Client error type for all XRPC client operations
16#[derive(Debug, thiserror::Error)]
17#[cfg_attr(feature = "std", derive(Diagnostic))]
18#[error("{kind}")]
19pub struct ClientError {
20    #[cfg_attr(feature = "std", diagnostic_source)]
21    kind: ClientErrorKind,
22    #[source]
23    source: Option<BoxError>,
24    #[cfg_attr(feature = "std", help)]
25    help: Option<SmolStr>,
26    context: Option<SmolStr>,
27    url: Option<SmolStr>,
28    details: Option<SmolStr>,
29    location: Option<SmolStr>,
30}
31
32/// Error categories for client operations
33#[derive(Debug, thiserror::Error)]
34#[cfg_attr(feature = "std", derive(Diagnostic))]
35#[non_exhaustive]
36pub enum ClientErrorKind {
37    /// HTTP transport error (connection, timeout, etc.)
38    #[error("transport error")]
39    #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::transport)))]
40    Transport,
41
42    /// Request validation/construction failed
43    #[error("invalid request: {0}")]
44    #[cfg_attr(
45        feature = "std",
46        diagnostic(
47            code(jacquard::client::invalid_request),
48            help("check request parameters and format")
49        )
50    )]
51    InvalidRequest(SmolStr),
52
53    /// Request serialization failed
54    #[error("encode error: {0}")]
55    #[cfg_attr(
56        feature = "std",
57        diagnostic(
58            code(jacquard::client::encode),
59            help("check request body format and encoding")
60        )
61    )]
62    Encode(SmolStr),
63
64    /// Response deserialization failed
65    #[error("decode error: {0}")]
66    #[cfg_attr(
67        feature = "std",
68        diagnostic(
69            code(jacquard::client::decode),
70            help("check response format and encoding")
71        )
72    )]
73    Decode(SmolStr),
74
75    /// HTTP error response (non-200 status)
76    #[error("HTTP {status}")]
77    #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::http)))]
78    Http {
79        /// HTTP status code
80        status: http::StatusCode,
81    },
82
83    /// Authentication/authorization error
84    #[error("auth error: {0}")]
85    #[cfg_attr(feature = "std", diagnostic(code(jacquard::client::auth)))]
86    Auth(AuthError),
87
88    /// Identity resolution error (handle→DID, DID→Doc)
89    #[error("identity resolution failed")]
90    #[cfg_attr(
91        feature = "std",
92        diagnostic(
93            code(jacquard::client::identity_resolution),
94            help("check handle/DID is valid and network is accessible")
95        )
96    )]
97    IdentityResolution,
98
99    /// Storage/persistence error
100    #[error("storage error")]
101    #[cfg_attr(
102        feature = "std",
103        diagnostic(
104            code(jacquard::client::storage),
105            help("check storage backend is accessible and has sufficient permissions")
106        )
107    )]
108    Storage,
109}
110
111impl ClientError {
112    /// Create a new error with the given kind and optional source
113    pub fn new(kind: ClientErrorKind, source: Option<BoxError>) -> Self {
114        Self {
115            kind,
116            source,
117            help: None,
118            context: None,
119            url: None,
120            details: None,
121            location: None,
122        }
123    }
124
125    /// Get the error kind
126    pub fn kind(&self) -> &ClientErrorKind {
127        &self.kind
128    }
129
130    /// Get the source error if present
131    pub fn source_err(&self) -> Option<&BoxError> {
132        self.source.as_ref()
133    }
134
135    /// Returns the HTTP status code if this is an `Http` error kind.
136    pub fn status(&self) -> Option<http::StatusCode> {
137        match &self.kind {
138            ClientErrorKind::Http { status } => Some(*status),
139            _ => None,
140        }
141    }
142
143    /// Returns true if this is an authentication error (typed `Auth` kind or HTTP 401).
144    pub fn is_auth(&self) -> bool {
145        matches!(self.kind, ClientErrorKind::Auth(_))
146            || self.status() == Some(http::StatusCode::UNAUTHORIZED)
147    }
148
149    /// Returns true if this is an HTTP 404 response.
150    pub fn is_not_found(&self) -> bool {
151        self.status() == Some(http::StatusCode::NOT_FOUND)
152    }
153
154    /// Returns true if this is an HTTP 409 conflict response.
155    pub fn is_conflict(&self) -> bool {
156        self.status() == Some(http::StatusCode::CONFLICT)
157    }
158
159    /// Get the context string if present
160    pub fn context(&self) -> Option<&str> {
161        self.context.as_ref().map(|s| s.as_str())
162    }
163
164    /// Get the URL if present
165    pub fn url(&self) -> Option<&str> {
166        self.url.as_ref().map(|s| s.as_str())
167    }
168
169    /// Get the details if present
170    pub fn details(&self) -> Option<&str> {
171        self.details.as_ref().map(|s| s.as_str())
172    }
173
174    /// Get the location if present
175    pub fn location(&self) -> Option<&str> {
176        self.location.as_ref().map(|s| s.as_str())
177    }
178
179    /// Add help text to this error
180    pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
181        self.help = Some(help.into());
182        self
183    }
184
185    /// Add context to this error
186    pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
187        self.context = Some(context.into());
188        self
189    }
190
191    /// Add URL to this error
192    pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
193        self.url = Some(url.into());
194        self
195    }
196
197    /// Add details to this error
198    pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
199        self.details = Some(details.into());
200        self
201    }
202
203    /// Add location to this error
204    pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
205        self.location = Some(location.into());
206        self
207    }
208
209    /// Append additional context to existing context string.
210    ///
211    /// If context already exists, appends with ": " separator.
212    /// If no context exists, sets it directly.
213    pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
214        self.context = Some(match self.context.take() {
215            Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
216            None => additional.as_ref().into(),
217        });
218        self
219    }
220
221    /// Add NSID context for XRPC operations.
222    ///
223    /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`.
224    pub fn for_nsid(self, nsid: &str) -> Self {
225        self.append_context(smol_str::format_smolstr!("[{}]", nsid))
226    }
227
228    /// Add collection context for record operations.
229    ///
230    /// Use this when a record operation fails to indicate the target collection.
231    pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self {
232        self.append_context(smol_str::format_smolstr!(
233            "{} [{}]",
234            operation,
235            collection_nsid
236        ))
237    }
238
239    // Constructors for each kind
240
241    /// Create a transport error
242    pub fn transport(source: impl core::error::Error + Send + Sync + 'static) -> Self {
243        Self::new(ClientErrorKind::Transport, Some(Box::new(source)))
244    }
245
246    /// Create an invalid request error
247    pub fn invalid_request(msg: impl Into<SmolStr>) -> Self {
248        Self::new(ClientErrorKind::InvalidRequest(msg.into()), None)
249    }
250
251    /// Create an encode error
252    pub fn encode(msg: impl Into<SmolStr>) -> Self {
253        Self::new(ClientErrorKind::Encode(msg.into()), None)
254    }
255
256    /// Create a decode error
257    pub fn decode(msg: impl Into<SmolStr>) -> Self {
258        Self::new(ClientErrorKind::Decode(msg.into()), None)
259    }
260
261    /// Create an HTTP error with status code and optional body
262    pub fn http(status: http::StatusCode, body: Option<Bytes>) -> Self {
263        let http_err = HttpError { status, body };
264        Self::new(ClientErrorKind::Http { status }, Some(Box::new(http_err)))
265    }
266
267    /// Create an authentication error
268    pub fn auth(auth_error: AuthError) -> Self {
269        Self::new(ClientErrorKind::Auth(auth_error), None)
270    }
271
272    /// Create an identity resolution error
273    pub fn identity_resolution(source: impl core::error::Error + Send + Sync + 'static) -> Self {
274        Self::new(ClientErrorKind::IdentityResolution, Some(Box::new(source)))
275    }
276
277    /// Create a storage error
278    pub fn storage(source: impl core::error::Error + Send + Sync + 'static) -> Self {
279        Self::new(ClientErrorKind::Storage, Some(Box::new(source)))
280    }
281}
282
283/// Result type for client operations
284pub type XrpcResult<T> = Result<T, ClientError>;
285
286// ============================================================================
287// Old error types (deprecated)
288// ============================================================================
289
290/// Response deserialization errors
291///
292/// Preserves detailed error information from various deserialization backends.
293/// Can be converted to string for serialization while maintaining the full error context.
294#[derive(Debug, thiserror::Error)]
295#[cfg_attr(feature = "std", derive(Diagnostic))]
296#[non_exhaustive]
297pub enum DecodeError {
298    /// JSON deserialization failed
299    #[error("Failed to deserialize JSON: {0}")]
300    Json(
301        #[from]
302        #[source]
303        serde_json::Error,
304    ),
305    /// CBOR deserialization failed (local I/O)
306    #[cfg(feature = "std")]
307    #[error("Failed to deserialize CBOR: {0}")]
308    CborLocal(
309        #[from]
310        #[source]
311        serde_ipld_dagcbor::DecodeError<std::io::Error>,
312    ),
313    /// CBOR deserialization failed (remote/reqwest)
314    #[error("Failed to deserialize CBOR: {0}")]
315    CborRemote(
316        #[from]
317        #[source]
318        serde_ipld_dagcbor::DecodeError<HttpError>,
319    ),
320    /// DAG-CBOR deserialization failed (in-memory, e.g., WebSocket frames)
321    #[error("Failed to deserialize DAG-CBOR: {0}")]
322    DagCborInfallible(
323        #[from]
324        #[source]
325        serde_ipld_dagcbor::DecodeError<core::convert::Infallible>,
326    ),
327    /// CBOR header deserialization failed (framed WebSocket messages)
328    #[cfg(all(feature = "websocket", feature = "std"))]
329    #[error("Failed to deserialize cbor header: {0}")]
330    CborHeader(
331        #[from]
332        #[source]
333        ciborium::de::Error<std::io::Error>,
334    ),
335
336    /// CBOR header deserialization failed (framed WebSocket messages, no_std)
337    #[cfg(all(feature = "websocket", not(feature = "std")))]
338    #[error("Failed to deserialize cbor header: {0}")]
339    CborHeader(
340        #[from]
341        #[source]
342        ciborium::de::Error<core::convert::Infallible>,
343    ),
344
345    /// Unknown event type in framed message
346    #[cfg(feature = "websocket")]
347    #[error("Unknown event type: {0}")]
348    UnknownEventType(smol_str::SmolStr),
349}
350
351/// HTTP error response (non-200 status codes outside of XRPC error handling)
352#[derive(Debug, thiserror::Error)]
353#[cfg_attr(feature = "std", derive(Diagnostic))]
354pub struct HttpError {
355    /// HTTP status code
356    pub status: http::StatusCode,
357    /// Response body if available
358    pub body: Option<Bytes>,
359}
360
361impl core::fmt::Display for HttpError {
362    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
363        write!(f, "HTTP {}", self.status)?;
364        if let Some(body) = &self.body {
365            if let Ok(s) = core::str::from_utf8(body) {
366                write!(f, ":\n{}", s)?;
367            }
368        }
369        Ok(())
370    }
371}
372
373/// Authentication and authorization errors
374#[derive(Debug, thiserror::Error)]
375#[cfg_attr(feature = "std", derive(Diagnostic))]
376#[non_exhaustive]
377pub enum AuthError {
378    /// Access token has expired (use refresh token to get a new one)
379    #[error("Access token expired")]
380    TokenExpired,
381
382    /// Access token is invalid or malformed
383    #[error("Invalid access token")]
384    InvalidToken,
385
386    /// Token refresh request failed
387    #[error("Token refresh failed")]
388    RefreshFailed,
389
390    /// Request requires authentication but none was provided
391    #[error("No authentication provided, but endpoint requires auth")]
392    NotAuthenticated,
393
394    /// DPoP proof construction failed (key or signing issue)
395    #[error("DPoP proof construction failed")]
396    DpopProofFailed,
397
398    /// DPoP nonce retry failed (server rejected proof even after nonce update)
399    #[error("DPoP nonce negotiation failed")]
400    DpopNonceFailed,
401
402    /// Other authentication error
403    #[error("Authentication error: {0:?}")]
404    Other(http::HeaderValue),
405}
406
407impl crate::IntoStatic for AuthError {
408    type Output = AuthError;
409
410    fn into_static(self) -> Self::Output {
411        match self {
412            AuthError::TokenExpired => AuthError::TokenExpired,
413            AuthError::InvalidToken => AuthError::InvalidToken,
414            AuthError::RefreshFailed => AuthError::RefreshFailed,
415            AuthError::NotAuthenticated => AuthError::NotAuthenticated,
416            AuthError::DpopProofFailed => AuthError::DpopProofFailed,
417            AuthError::DpopNonceFailed => AuthError::DpopNonceFailed,
418            AuthError::Other(header) => AuthError::Other(header),
419        }
420    }
421}
422
423// ============================================================================
424// Conversions from old to new
425// ============================================================================
426
427impl From<DecodeError> for ClientError {
428    fn from(e: DecodeError) -> Self {
429        let msg = smol_str::format_smolstr!("{:?}", e);
430        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
431            .with_context("response deserialization failed")
432    }
433}
434
435impl From<HttpError> for ClientError {
436    fn from(e: HttpError) -> Self {
437        Self::http(e.status, e.body)
438    }
439}
440
441impl From<AuthError> for ClientError {
442    fn from(e: AuthError) -> Self {
443        Self::auth(e)
444    }
445}
446
447impl From<EncodeError> for ClientError {
448    fn from(e: EncodeError) -> Self {
449        let msg = smol_str::format_smolstr!("{:?}", e);
450        Self::new(ClientErrorKind::Encode(msg), Some(Box::new(e)))
451            .with_context("request encoding failed")
452    }
453}
454
455// Platform-specific conversions
456#[cfg(feature = "reqwest-client")]
457impl From<reqwest::Error> for ClientError {
458    #[cfg(not(target_arch = "wasm32"))]
459    fn from(e: reqwest::Error) -> Self {
460        Self::transport(e)
461    }
462
463    #[cfg(target_arch = "wasm32")]
464    fn from(e: reqwest::Error) -> Self {
465        Self::transport(e)
466    }
467}
468
469// Serde error conversions
470impl From<serde_json::Error> for ClientError {
471    fn from(e: serde_json::Error) -> Self {
472        let msg = smol_str::format_smolstr!("{:?}", e);
473        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
474            .with_context("JSON deserialization failed")
475    }
476}
477
478#[cfg(feature = "std")]
479impl From<serde_ipld_dagcbor::DecodeError<std::io::Error>> for ClientError {
480    fn from(e: serde_ipld_dagcbor::DecodeError<std::io::Error>) -> Self {
481        let msg = smol_str::format_smolstr!("{:?}", e);
482        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
483            .with_context("DAG-CBOR deserialization failed (local I/O)")
484    }
485}
486
487impl From<serde_ipld_dagcbor::DecodeError<HttpError>> for ClientError {
488    fn from(e: serde_ipld_dagcbor::DecodeError<HttpError>) -> Self {
489        let msg = smol_str::format_smolstr!("{:?}", e);
490        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
491            .with_context("DAG-CBOR deserialization failed (remote)")
492    }
493}
494
495impl From<serde_ipld_dagcbor::DecodeError<core::convert::Infallible>> for ClientError {
496    fn from(e: serde_ipld_dagcbor::DecodeError<core::convert::Infallible>) -> Self {
497        let msg = smol_str::format_smolstr!("{:?}", e);
498        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
499            .with_context("DAG-CBOR deserialization failed (in-memory)")
500    }
501}
502
503#[cfg(all(feature = "websocket", feature = "std"))]
504impl From<ciborium::de::Error<std::io::Error>> for ClientError {
505    fn from(e: ciborium::de::Error<std::io::Error>) -> Self {
506        let msg = smol_str::format_smolstr!("{:?}", e);
507        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
508            .with_context("CBOR header deserialization failed")
509    }
510}
511
512// Session store errors
513impl From<crate::session::SessionStoreError> for ClientError {
514    fn from(e: crate::session::SessionStoreError) -> Self {
515        Self::storage(e)
516    }
517}
518
519// fluent_uri parse errors
520impl From<crate::deps::fluent_uri::ParseError> for ClientError {
521    fn from(e: crate::deps::fluent_uri::ParseError) -> Self {
522        Self::invalid_request(e.to_string())
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use http::StatusCode;
530
531    #[test]
532    fn client_error_status_from_http() {
533        let err = ClientError::http(StatusCode::CONFLICT, None);
534        assert_eq!(err.status(), Some(StatusCode::CONFLICT));
535    }
536
537    #[test]
538    fn client_error_status_none_for_non_http() {
539        let err = ClientError::invalid_request("bad");
540        assert_eq!(err.status(), None);
541    }
542
543    #[test]
544    fn client_error_is_auth_typed() {
545        let err = ClientError::auth(AuthError::TokenExpired);
546        assert!(err.is_auth());
547    }
548
549    #[test]
550    fn client_error_is_auth_http_401() {
551        let err = ClientError::http(StatusCode::UNAUTHORIZED, None);
552        assert!(err.is_auth());
553    }
554
555    #[test]
556    fn client_error_is_not_found() {
557        assert!(ClientError::http(StatusCode::NOT_FOUND, None).is_not_found());
558        assert!(!ClientError::http(StatusCode::BAD_REQUEST, None).is_not_found());
559    }
560
561    #[test]
562    fn client_error_is_conflict() {
563        assert!(ClientError::http(StatusCode::CONFLICT, None).is_conflict());
564        assert!(!ClientError::http(StatusCode::NOT_FOUND, None).is_conflict());
565    }
566}