A better Rust ATProto crate
at main 481 lines 16 kB view raw
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}