A better Rust ATProto crate
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}