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    /// Get the context string if present
136    pub fn context(&self) -> Option<&str> {
137        self.context.as_ref().map(|s| s.as_str())
138    }
139
140    /// Get the URL if present
141    pub fn url(&self) -> Option<&str> {
142        self.url.as_ref().map(|s| s.as_str())
143    }
144
145    /// Get the details if present
146    pub fn details(&self) -> Option<&str> {
147        self.details.as_ref().map(|s| s.as_str())
148    }
149
150    /// Get the location if present
151    pub fn location(&self) -> Option<&str> {
152        self.location.as_ref().map(|s| s.as_str())
153    }
154
155    /// Add help text to this error
156    pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
157        self.help = Some(help.into());
158        self
159    }
160
161    /// Add context to this error
162    pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
163        self.context = Some(context.into());
164        self
165    }
166
167    /// Add URL to this error
168    pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
169        self.url = Some(url.into());
170        self
171    }
172
173    /// Add details to this error
174    pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
175        self.details = Some(details.into());
176        self
177    }
178
179    /// Add location to this error
180    pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
181        self.location = Some(location.into());
182        self
183    }
184
185    /// Append additional context to existing context string.
186    ///
187    /// If context already exists, appends with ": " separator.
188    /// If no context exists, sets it directly.
189    pub fn append_context(mut self, additional: impl AsRef<str>) -> Self {
190        self.context = Some(match self.context.take() {
191            Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()),
192            None => additional.as_ref().into(),
193        });
194        self
195    }
196
197    /// Add NSID context for XRPC operations.
198    ///
199    /// Appends the NSID in brackets to existing context, e.g. `"network timeout: [com.atproto.repo.getRecord]"`.
200    pub fn for_nsid(self, nsid: &str) -> Self {
201        self.append_context(smol_str::format_smolstr!("[{}]", nsid))
202    }
203
204    /// Add collection context for record operations.
205    ///
206    /// Use this when a record operation fails to indicate the target collection.
207    pub fn for_collection(self, operation: &str, collection_nsid: &str) -> Self {
208        self.append_context(smol_str::format_smolstr!(
209            "{} [{}]",
210            operation,
211            collection_nsid
212        ))
213    }
214
215    // Constructors for each kind
216
217    /// Create a transport error
218    pub fn transport(source: impl core::error::Error + Send + Sync + 'static) -> Self {
219        Self::new(ClientErrorKind::Transport, Some(Box::new(source)))
220    }
221
222    /// Create an invalid request error
223    pub fn invalid_request(msg: impl Into<SmolStr>) -> Self {
224        Self::new(ClientErrorKind::InvalidRequest(msg.into()), None)
225    }
226
227    /// Create an encode error
228    pub fn encode(msg: impl Into<SmolStr>) -> Self {
229        Self::new(ClientErrorKind::Encode(msg.into()), None)
230    }
231
232    /// Create a decode error
233    pub fn decode(msg: impl Into<SmolStr>) -> Self {
234        Self::new(ClientErrorKind::Decode(msg.into()), None)
235    }
236
237    /// Create an HTTP error with status code and optional body
238    pub fn http(status: http::StatusCode, body: Option<Bytes>) -> Self {
239        let http_err = HttpError { status, body };
240        Self::new(ClientErrorKind::Http { status }, Some(Box::new(http_err)))
241    }
242
243    /// Create an authentication error
244    pub fn auth(auth_error: AuthError) -> Self {
245        Self::new(ClientErrorKind::Auth(auth_error), None)
246    }
247
248    /// Create an identity resolution error
249    pub fn identity_resolution(source: impl core::error::Error + Send + Sync + 'static) -> Self {
250        Self::new(ClientErrorKind::IdentityResolution, Some(Box::new(source)))
251    }
252
253    /// Create a storage error
254    pub fn storage(source: impl core::error::Error + Send + Sync + 'static) -> Self {
255        Self::new(ClientErrorKind::Storage, Some(Box::new(source)))
256    }
257}
258
259/// Result type for client operations
260pub type XrpcResult<T> = Result<T, ClientError>;
261
262// ============================================================================
263// Old error types (deprecated)
264// ============================================================================
265
266/// Response deserialization errors
267///
268/// Preserves detailed error information from various deserialization backends.
269/// Can be converted to string for serialization while maintaining the full error context.
270#[derive(Debug, thiserror::Error)]
271#[cfg_attr(feature = "std", derive(Diagnostic))]
272#[non_exhaustive]
273pub enum DecodeError {
274    /// JSON deserialization failed
275    #[error("Failed to deserialize JSON: {0}")]
276    Json(
277        #[from]
278        #[source]
279        serde_json::Error,
280    ),
281    /// CBOR deserialization failed (local I/O)
282    #[cfg(feature = "std")]
283    #[error("Failed to deserialize CBOR: {0}")]
284    CborLocal(
285        #[from]
286        #[source]
287        serde_ipld_dagcbor::DecodeError<std::io::Error>,
288    ),
289    /// CBOR deserialization failed (remote/reqwest)
290    #[error("Failed to deserialize CBOR: {0}")]
291    CborRemote(
292        #[from]
293        #[source]
294        serde_ipld_dagcbor::DecodeError<HttpError>,
295    ),
296    /// DAG-CBOR deserialization failed (in-memory, e.g., WebSocket frames)
297    #[error("Failed to deserialize DAG-CBOR: {0}")]
298    DagCborInfallible(
299        #[from]
300        #[source]
301        serde_ipld_dagcbor::DecodeError<core::convert::Infallible>,
302    ),
303    /// CBOR header deserialization failed (framed WebSocket messages)
304    #[cfg(all(feature = "websocket", feature = "std"))]
305    #[error("Failed to deserialize cbor header: {0}")]
306    CborHeader(
307        #[from]
308        #[source]
309        ciborium::de::Error<std::io::Error>,
310    ),
311
312    /// CBOR header deserialization failed (framed WebSocket messages, no_std)
313    #[cfg(all(feature = "websocket", not(feature = "std")))]
314    #[error("Failed to deserialize cbor header: {0}")]
315    CborHeader(
316        #[from]
317        #[source]
318        ciborium::de::Error<core::convert::Infallible>,
319    ),
320
321    /// Unknown event type in framed message
322    #[cfg(feature = "websocket")]
323    #[error("Unknown event type: {0}")]
324    UnknownEventType(smol_str::SmolStr),
325}
326
327/// HTTP error response (non-200 status codes outside of XRPC error handling)
328#[derive(Debug, thiserror::Error)]
329#[cfg_attr(feature = "std", derive(Diagnostic))]
330pub struct HttpError {
331    /// HTTP status code
332    pub status: http::StatusCode,
333    /// Response body if available
334    pub body: Option<Bytes>,
335}
336
337impl core::fmt::Display for HttpError {
338    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
339        write!(f, "HTTP {}", self.status)?;
340        if let Some(body) = &self.body {
341            if let Ok(s) = core::str::from_utf8(body) {
342                write!(f, ":\n{}", s)?;
343            }
344        }
345        Ok(())
346    }
347}
348
349/// Authentication and authorization errors
350#[derive(Debug, thiserror::Error)]
351#[cfg_attr(feature = "std", derive(Diagnostic))]
352#[non_exhaustive]
353pub enum AuthError {
354    /// Access token has expired (use refresh token to get a new one)
355    #[error("Access token expired")]
356    TokenExpired,
357
358    /// Access token is invalid or malformed
359    #[error("Invalid access token")]
360    InvalidToken,
361
362    /// Token refresh request failed
363    #[error("Token refresh failed")]
364    RefreshFailed,
365
366    /// Request requires authentication but none was provided
367    #[error("No authentication provided, but endpoint requires auth")]
368    NotAuthenticated,
369
370    /// DPoP proof construction failed (key or signing issue)
371    #[error("DPoP proof construction failed")]
372    DpopProofFailed,
373
374    /// DPoP nonce retry failed (server rejected proof even after nonce update)
375    #[error("DPoP nonce negotiation failed")]
376    DpopNonceFailed,
377
378    /// Other authentication error
379    #[error("Authentication error: {0:?}")]
380    Other(http::HeaderValue),
381}
382
383impl crate::IntoStatic for AuthError {
384    type Output = AuthError;
385
386    fn into_static(self) -> Self::Output {
387        match self {
388            AuthError::TokenExpired => AuthError::TokenExpired,
389            AuthError::InvalidToken => AuthError::InvalidToken,
390            AuthError::RefreshFailed => AuthError::RefreshFailed,
391            AuthError::NotAuthenticated => AuthError::NotAuthenticated,
392            AuthError::DpopProofFailed => AuthError::DpopProofFailed,
393            AuthError::DpopNonceFailed => AuthError::DpopNonceFailed,
394            AuthError::Other(header) => AuthError::Other(header),
395        }
396    }
397}
398
399// ============================================================================
400// Conversions from old to new
401// ============================================================================
402
403impl From<DecodeError> for ClientError {
404    fn from(e: DecodeError) -> Self {
405        let msg = smol_str::format_smolstr!("{:?}", e);
406        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
407            .with_context("response deserialization failed")
408    }
409}
410
411impl From<HttpError> for ClientError {
412    fn from(e: HttpError) -> Self {
413        Self::http(e.status, e.body)
414    }
415}
416
417impl From<AuthError> for ClientError {
418    fn from(e: AuthError) -> Self {
419        Self::auth(e)
420    }
421}
422
423impl From<EncodeError> for ClientError {
424    fn from(e: EncodeError) -> Self {
425        let msg = smol_str::format_smolstr!("{:?}", e);
426        Self::new(ClientErrorKind::Encode(msg), Some(Box::new(e)))
427            .with_context("request encoding failed")
428    }
429}
430
431// Platform-specific conversions
432#[cfg(feature = "reqwest-client")]
433impl From<reqwest::Error> for ClientError {
434    #[cfg(not(target_arch = "wasm32"))]
435    fn from(e: reqwest::Error) -> Self {
436        Self::transport(e)
437    }
438
439    #[cfg(target_arch = "wasm32")]
440    fn from(e: reqwest::Error) -> Self {
441        Self::transport(e)
442    }
443}
444
445// Serde error conversions
446impl From<serde_json::Error> for ClientError {
447    fn from(e: serde_json::Error) -> Self {
448        let msg = smol_str::format_smolstr!("{:?}", e);
449        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
450            .with_context("JSON deserialization failed")
451    }
452}
453
454#[cfg(feature = "std")]
455impl From<serde_ipld_dagcbor::DecodeError<std::io::Error>> for ClientError {
456    fn from(e: serde_ipld_dagcbor::DecodeError<std::io::Error>) -> Self {
457        let msg = smol_str::format_smolstr!("{:?}", e);
458        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
459            .with_context("DAG-CBOR deserialization failed (local I/O)")
460    }
461}
462
463impl From<serde_ipld_dagcbor::DecodeError<HttpError>> for ClientError {
464    fn from(e: serde_ipld_dagcbor::DecodeError<HttpError>) -> Self {
465        let msg = smol_str::format_smolstr!("{:?}", e);
466        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
467            .with_context("DAG-CBOR deserialization failed (remote)")
468    }
469}
470
471impl From<serde_ipld_dagcbor::DecodeError<core::convert::Infallible>> for ClientError {
472    fn from(e: serde_ipld_dagcbor::DecodeError<core::convert::Infallible>) -> Self {
473        let msg = smol_str::format_smolstr!("{:?}", e);
474        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
475            .with_context("DAG-CBOR deserialization failed (in-memory)")
476    }
477}
478
479#[cfg(all(feature = "websocket", feature = "std"))]
480impl From<ciborium::de::Error<std::io::Error>> for ClientError {
481    fn from(e: ciborium::de::Error<std::io::Error>) -> Self {
482        let msg = smol_str::format_smolstr!("{:?}", e);
483        Self::new(ClientErrorKind::Decode(msg), Some(Box::new(e)))
484            .with_context("CBOR header deserialization failed")
485    }
486}
487
488// Session store errors
489impl From<crate::session::SessionStoreError> for ClientError {
490    fn from(e: crate::session::SessionStoreError) -> Self {
491        Self::storage(e)
492    }
493}
494
495// fluent_uri parse errors
496impl From<crate::deps::fluent_uri::ParseError> for ClientError {
497    fn from(e: crate::deps::fluent_uri::ParseError) -> Self {
498        Self::invalid_request(e.to_string())
499    }
500}