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